Mastering Java Monitor Synchronization: Wait, Notify, and Coordination Patterns

Understanding Monitor Interaction

Thread synchronization in Java relies heavily on the intrinsic lock associated with every object. When a thread enters a synchronized block, it acquires the monitor. If conditions within that critical section are not yet met, the thread can voluntarily release the monitor and pause execution using wait().

Mechanism Workflow

  1. Release Lock: A thread holding the monitor detects a condition that prevents immediate progress. It calls wait(), releasing the lock and transitioning its state to WAITING.
  2. Monitor Handoff: Once the lock is released, blocked threads waiting in the EntryList are woken up to compete for ownership.
  3. Re-entrance: Upon being notified, a thread does not immediately resume execution. It re-acquires the monitor from the current owner or competes for it before resuming.
  4. Notification: Threads can signal others to check their conditions again via notify() (random selection) or notifyAll() (all waiting threads).

Core APIs

These methods belong to the Object class, meaning any instance can serve as a monitor. Crucially, they require thread synchronization. Invoking them outside a synchronized context throws IllegalMonitorStateException.

obj.wait();       // Blocks indefinitely until notified
obj.wait(long);   // Blocks for a specified duration
obj.notify();     // Wakes up one waiting thread
obj.notifyAll();  // Wakes up all waiting threads

Practical Demonstration

Attempting to call wait() without holding the object's lock results in an exception.

public class MonitorUsage {
    public static void main(String[] args) {
        Object lock = new Object();
        
        new Thread(() -> {
            synchronized (lock) {
                System.out.println("Thread acquired lock");
                try {
                    lock.wait();
                    System.out.println("Thread resumed");
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }
        }, "Worker").start();

        try {
            Thread.sleep(1000);
        } catch (InterruptedException ignored) {}

        synchronized (lock) {
            lock.notify();
        }
    }
}

wait() vs sleep()

Feature wait() sleep()
Class Object method Thread static method
Lock Handling Releases the monitor Retains the monitor
Requirement Must be called inside synchronized No special requirement
State TIME_WAITING (waiting for notify) TIMED_WAITING (time elapsed)

Safeguarding Against Spurious Wakeups

A "spurious wakeup" occurs when a thread resumes execution with out an explicit notify() call, though rare, relying on it causes logical errors. To mitigate this, always wrap the condition check in a while loop rather than an if statement.

synchronized (lock) {
    while (!conditionMet()) {
        lock.wait();
    }
    // Proceed with task
}

// Signaler
synchronized (lock) {
    notifyAll();
}

Guarded Suspension Pattern

This pattern addresses scenarios where one thread must suspend execution until another thread produces data. It effectively decouples the waiter from the producer.

Implementation Example

A wrapper class manages the result availability. The consumer waits, while the producer signals completion.

class ResultGuard {
    private volatile String data;

    public synchronized Object get() throws InterruptedException {
        while (data == null) {
            wait();
        }
        return data;
    }

    public synchronized void set(String newData) {
        data = newData;
        notifyAll();
    }
}

Timed Waiting Support

Sometimes a task cannot hang indefinitely. We modify the guard to track elapsed time during the wait loop.

class TimeLimitedGuard {
    private String payload;

    public synchronized Object get(long timeoutMillis) throws InterruptedException {
        long deadline = System.currentTimeMillis() + timeoutMillis;
        while (payload == null) {
            long remaining = deadline - System.currentTimeMillis();
            if (remaining <= 0) return null;
            wait(remaining);
        }
        return payload;
    }

    public synchronized void complete(String val) {
        payload = val;
        notifyAll();
    }
}

Integration with Thread.join()

The standard Thread.join() method internally implements a form of guarded suspension. It loops while the target thread is alive, invoking wait(0) to pause execution until the other thread terminates.

Decoupled Management: The Mailbox Approach

Managing individual guards for many pairs becomes cumbersome. A centralized mailbox system allows multiple producers to send to multiple consumers dynamically.

Architecture:

  1. Unique ID assigned to each request.
  2. Central manager holds a registry of active requests.
  3. Producers claim IDs, Consumers retrieve objects by ID.
import java.util.concurrent.ConcurrentHashMap;
import java.util.Set;

class MailboxRegistry {
    private ConcurrentHashMap<Integer, Object> cache = new ConcurrentHashMap<>();
    private int idCounter = 0;

    public synchronized Integer createRequest() {
        return ++idCounter;
    }

    public synchronized void deliver(Integer id, Object content) {
        // Simulated storage logic with notification would go here
        // Using a separate GuardedObject per ID is typical in this pattern
    }
}

Producer-Consumer Pattern

Unlike guarded suspension which is typically synchronous and 1-to-1, this pattern uses a buffer (queue) to allow asynchronous production and consumption. Resources are balanced via queue capacity limits.

Design Considerations

  • Blocking: Producers wait if the queue is full; Consumers wait if the queue is empty.
  • Capacity: A fixed size prevents unbounded memory growth.
  • Decoupling: Producers don't know who consumes the data.

Custom Blocking Channel

import java.util.LinkedList;
import java.util.Queue;

class BlockingChannel<T> {
    private final Queue<T> buffer;
    private final int maxSize;

    public BlockingChannel(int size) {
        this.maxSize = size;
        this.buffer = new LinkedList<>();
    }

    public synchronized T take() throws InterruptedException {
        while (buffer.isEmpty()) {
            wait();
        }
        T item = buffer.removeFirst();
        notifyAll(); // Alert producers that space is available
        return item;
    }

    public synchronized void put(T item) throws InterruptedException {
        while (buffer.size() == maxSize) {
            wait();
        }
        buffer.addLast(item);
        notifyAll(); // Alert consumers that data is available
    }
}

Execution Flow

Multiple producer threads add data to the channel. A single consumer thread retrieves and processes items. If the buffer reaches capacity, further putss block. If the buffer empties, gets block until new data arrives.

public class ChannelDemo {
    public static void main(String[] args) throws Exception {
        BlockingChannel<String> queue = new BlockingChannel<>(5);

        for (int i = 0; i < 10; i++) {
            final int index = i;
            new Thread(() -> {
                try {
                    queue.put("Item-" + index);
                    System.out.println("Produced: Item-" + index);
                } catch (InterruptedException e) {
                    e.printStackTrace();
                }
            }).start();
        }

        new Thread(() -> {
            try {
                while (true) {
                    String item = queue.take();
                    System.out.println("Consumed: " + item);
                }
            } catch (InterruptedException e) {
                e.printStackTrace();
            }
        }).start();
        
        Thread.sleep(10000);
    }
}

This implementation demonstrates how wait and notify form the foundation of thread coordination, allowing developers to build complex synchronization structures beyond basic locking.

Tags: java Concurrency multithreading wait-notify design-patterns

Posted on Sat, 11 Jul 2026 17:04:39 +0000 by icarpenter