JAVA / THREADS AND CONCURRENCY
Wait, notify and producer-consumer coordination
Use wait and notifyAll to build a bounded buffer where the producer blocks when full, the consumer blocks when empty, and both shut down cleanly.
What you will learn
- Call wait and notifyAll only while holding that exact object's monitor
- Wrap every wait() in a while loop that re-tests the guarded condition
- Build a bounded buffer: put waits while full, take waits while empty
- Signal shutdown with a closed flag plus notifyAll so consumers can exit
Understanding Wait, notify and producer-consumer coordination
Every Java object carries an intrinsic monitor and the earlier lessons used only half of it, the lock; the other half is a wait set. Calling obj.wait() from code that holds obj's monitor does two things atomically: it releases that monitor and parks the calling thread in the wait set, where it burns no CPU. obj.notify() moves one arbitrary waiter out of the set and obj.notifyAll() moves all of them, but a woken thread still cannot continue until it re-acquires the monitor, so the notifier keeps running until it leaves its own synchronized block. All three methods throw IllegalMonitorStateException when the caller does not hold that object's monitor, because releasing a lock you never took has no meaning.
The consequence that matters is that a returning wait() proves nothing about the world. notifyAll() releases every waiter even if only one item arrived, another thread can grab the lock and change the state between the wakeup and the reacquisition, and the language specification explicitly permits spurious wakeups where nobody notified at all. So read wait() as "sleep until it is worth looking again" and always put it in a while loop that re-tests the real condition. Notifications are also not stored: a notify sent while nobody is waiting simply vanishes, which is why the shared state is the truth and the signal is only a nudge to re-read it.
Producer-consumer coordination falls straight out of that. A bounded buffer holds a queue and a capacity, and both operations run under the buffer's own monitor: put waits while the queue is full, take waits while it is empty, and each calls notifyAll() after mutating the queue so the other side re-tests its condition. Because producers and consumers wait on the same monitor for different conditions, notify() can wake a producer when a consumer needed waking and stall the pipeline, so notifyAll() is the safe default. Termination needs its own state as well: without a closed flag or a poison pill inside the wait condition, consumers park forever on an empty queue and those non-daemon threads keep the JVM alive.
import java.util.ArrayDeque;
import java.util.Queue;
public class BoundedBuffer {
private static final int CAPACITY = 1; // 1 forces a strict handoff, so the trace is repeatable
private final Queue<Integer> items = new ArrayDeque<>();
public synchronized void put(int value) throws InterruptedException {
while (items.size() == CAPACITY) { // full: release the lock and sleep
wait();
}
items.add(value);
System.out.println("produced " + value);
notifyAll(); // the buffer is no longer empty
}
public synchronized int take() throws InterruptedException {
while (items.isEmpty()) { // empty: release the lock and sleep
wait();
}
int value = items.remove();
System.out.println("consumed " + value);
notifyAll(); // there is room again
return value;
}
public static void main(String[] args) throws InterruptedException {
BoundedBuffer buffer = new BoundedBuffer();
Thread producer = new Thread(() -> {
try {
for (int i = 1; i <= 4; i++) {
buffer.put(i);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
Thread consumer = new Thread(() -> {
try {
for (int i = 1; i <= 4; i++) {
buffer.take();
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
producer.start();
consumer.start();
producer.join();
consumer.join();
System.out.println("both threads finished");
}
}A waiting thread must trust the guarded state it re-checks under the lock, not the notification that woke it; wait() only releases the monitor and parks the thread until it is worth checking again.
Worked examples
wait() gives up the monitor, sleep() does not
Shows that a thread parked in wait() no longer holds the lock, and that notify() hands nothing over until the notifier leaves its synchronized block.
public class WaitReleasesTheLock {
private static final Object lock = new Object();
private static boolean ready = false;
public static void main(String[] args) throws InterruptedException {
Thread waiter = new Thread(() -> {
synchronized (lock) {
System.out.println("waiter: inside synchronized, about to wait");
while (!ready) {
try {
lock.wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
System.out.println("waiter: resumed, ready = " + ready);
}
});
waiter.start();
Thread.sleep(200); // only to make the trace readable
synchronized (lock) {
System.out.println("main: took the same lock while the waiter sat in wait()");
ready = true;
lock.notify();
System.out.println("main: notify did not release the lock");
}
waiter.join();
System.out.println("main: done");
}
}Example explained
Line 1lock.wait() atomically releases the monitor and parks the thread in the lock's wait set, which is the only reason main can enter synchronized (lock) 200 ms later.
Line 2Had the waiter used Thread.sleep(...) instead, it would still own the monitor and main would block at the synchronized keyword.
Line 3lock.notify() only marks the waiter runnable, so "main: notify did not release the lock" always prints before the waiter continues.
Line 4The waiter re-acquires the monitor when main leaves the block, re-tests ready in the while loop, finds true, and falls through.
Closing the channel so the consumer can leave
Adds a closed flag to the wait condition so the consumer drains everything published and then returns instead of blocking forever.
import java.util.ArrayDeque;
import java.util.Queue;
public class StringChannel {
private final Queue<String> queue = new ArrayDeque<>();
private boolean closed = false;
synchronized void publish(String item) {
queue.add(item);
notifyAll();
}
synchronized void close() {
closed = true;
notifyAll();
}
synchronized String consume() throws InterruptedException {
while (queue.isEmpty() && !closed) {
wait();
}
return queue.poll(); // null only when closed and drained
}
public static void main(String[] args) throws InterruptedException {
StringChannel channel = new StringChannel();
StringBuilder seen = new StringBuilder();
Thread consumer = new Thread(() -> {
try {
String item;
while ((item = channel.consume()) != null) {
seen.append(item).append(' ');
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
});
consumer.start();
channel.publish("a");
channel.publish("b");
channel.publish("c");
channel.close();
consumer.join();
System.out.println("consumed: " + seen.toString().trim());
System.out.println("consumer still alive: " + consumer.isAlive());
}
}Example explained
Line 1The wait condition is queue.isEmpty() && !closed, so a closed and empty channel returns from consume() instead of parking.
Line 2close() must call notifyAll(), otherwise a consumer already inside wait() would never re-test the flag and join() would hang.
Line 3queue.poll() returns the item when the channel is closed but not yet drained, so nothing published before close() is lost.
Line 4join() before reading seen creates a happens-before edge to the consumer's writes, which is why the plain StringBuilder is safe here.
A notification sent too early is gone
Demonstrates that notifications are not remembered, and that only the guarded flag keeps a late waiter from blocking forever.
public class LostNotification {
private static final Object lock = new Object();
private static boolean ready = false;
public static void main(String[] args) throws InterruptedException {
synchronized (lock) {
ready = true;
lock.notifyAll(); // nobody is waiting yet: this wakes no one, ever
}
Thread late = new Thread(() -> {
synchronized (lock) {
while (!ready) { // the flag survived; the notification did not
try {
lock.wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
}
System.out.println("late thread proceeded on the flag, not on a signal");
}
});
late.start();
late.join();
System.out.println("finished without deadlock");
}
}Example explained
Line 1The notifyAll() at the top runs before any thread joins the wait set, and the JVM stores nothing, so that wakeup is discarded.
Line 2ready = true does persist, so the while (!ready) test skips wait() entirely and the thread never parks.
Line 3Replacing the guard with a bare lock.wait() would block the late thread forever, because the only notification has already happened.
Line 4"finished without deadlock" proves join() returned, which is the practical difference between checking state and trusting signals.
Important notes
notify() is a valid optimisation only when every thread in the wait set waits for the same condition and any one of them can consume the event; with producers and consumers on one monitor it can wake the wrong side and stall the pipeline.
The alternating trace above is repeatable only because the capacity is 1; with a larger buffer the produced and consumed lines interleave differently per run. ArrayBlockingQueue and Lock with Condition objects package this pattern for real code, but internally they do exactly this loop.
Common mistakes
Writing if (items.isEmpty()) wait(); instead of while: notifyAll releases both consumers, the second one runs items.remove() on an empty deque and throws NoSuchElementException.
Calling wait() or notify() outside synchronized, or on a different object than the lock held, as in synchronized (a) { b.wait(); }: IllegalMonitorStateException at runtime with no compile-time warning.
Using Thread.sleep(20) inside the synchronized block to wait for room: sleep keeps the monitor, so the consumer can never enter take(), room never appears, and the program hangs with both threads alive.
Try it yourself
Change, predict, then run
Raise CAPACITY to 3, let the producer publish 8 items, add Thread.sleep(20) after each take in the consumer, and print items.size() in both methods to find the moment the producer starts blocking on a full buffer.
Open the Java workspaceCheck your understanding
A consumer runs synchronized (buf) { if (buf.isEmpty()) buf.wait(); return buf.remove(); }. Two such consumers are waiting when a producer adds one item and calls buf.notifyAll(). What is the likely result?
- Both consumers wake, one takes the item, and the other finds the buffer empty again and throws NoSuchElementException from remove()
- Nothing goes wrong, because notifyAll only wakes threads whose condition has actually become true
- The item is lost, because notifyAll was called after add() instead of before it
- The second consumer can never run at all, because wait() keeps the monitor until remove() completes
Show answer
notifyAll releases every thread in the wait set; each then re-acquires the lock in turn, and the second one arrives after the item is gone. Since the code re-tests nothing, it falls straight into remove() on an empty buffer. Option 2 is tempting because notifyAll feels targeted, but the JVM evaluates no predicate, and the specification even allows spurious wakeups with no notify at all, which is exactly why the check belongs in a while loop. Option 4 is wrong because wait() releases the monitor, which is what let the producer run in the first place.