Concurrency Basics

wait, notify & Coordination

15 min Lesson 8 of 13

wait, notify & Coordination

Mutual exclusion keeps threads from corrupting shared state, but it does not help them cooperate. Consider a classic producer-consumer pair: the consumer must not proceed until there is data to consume, and the producer must not overfill a bounded buffer. Both threads need a way to pause and resume based on a condition — that is exactly what wait, notify, and notifyAll provide.

The Guarded Block Pattern

A guarded block is the fundamental idiom: a thread checks a condition and, if the condition is not yet satisfied, suspends itself inside the monitor until another thread signals that something has changed. The sleeping thread releases the lock while it waits, so other threads can enter the monitor and make progress.

Key insight — wait releases the lock. When a thread calls object.wait() it atomically (1) releases the monitor on object, (2) suspends itself, and (3) re-acquires the monitor before returning. This is what makes coordination possible: the waiting thread steps aside so the notifying thread can enter the same synchronized block.

The correct skeleton always uses a while loop, never an if:

synchronized (lock) { while (!conditionIsMet()) { // re-check after waking up lock.wait(); // release lock, sleep, re-acquire on wake } // ... condition is now true — proceed safely }
Always loop, never if. Two problems make the if version broken: (1) spurious wakeups — the JVM is permitted to wake a waiting thread even when nobody called notify; (2) missed signals — between the moment a thread is woken and the moment it re-acquires the lock, another thread might have consumed the very item that satisfied the condition. The while loop guards against both.

notify vs notifyAll

notify() wakes exactly one thread that is waiting on the same monitor — which one is chosen by the JVM scheduler and is not predictable. notifyAll() wakes every waiting thread; each then races to re-acquire the lock, and all but one go back to waiting.

  • Use notify() when all waiting threads are waiting for the same condition and only one can proceed at a time. A pool of identical workers is the textbook example.
  • Use notifyAll() when different threads wait for different conditions on the same monitor (e.g., a producer and a consumer both waiting on one object). notify() might wake the wrong thread, which goes back to sleep, leaving the ready thread stranded — a liveness failure.
When in doubt, use notifyAll. It is less efficient but correct in all cases. Switch to notify() only after you have verified that every thread waiting on the monitor waits for exactly the same condition.

A Complete Producer-Consumer Example

The example below models a single-slot buffer: the producer puts one item in and then waits until the consumer has taken it, and vice versa.

public class OneSlotBuffer<T> { private T item; private boolean hasItem = false; public synchronized void put(T value) throws InterruptedException { while (hasItem) { // buffer full — wait for consumer wait(); } this.item = value; this.hasItem = true; System.out.println("Produced: " + value); notifyAll(); // wake the consumer (and anyone else waiting) } public synchronized T take() throws InterruptedException { while (!hasItem) { // buffer empty — wait for producer wait(); } T result = this.item; this.item = null; this.hasItem = false; System.out.println("Consumed: " + result); notifyAll(); // wake the producer return result; } }

Notice that both put and take are synchronized on this, so they share the same monitor. When the producer calls wait(), it releases the lock and the consumer can enter take(). After the consumer calls notifyAll() and exits, the producer re-acquires the lock, finds hasItem == false, and proceeds.

A minimal harness to test it:

public class Demo { public static void main(String[] args) { OneSlotBuffer<Integer> buffer = new OneSlotBuffer<>(); Thread producer = new Thread(() -> { try { for (int i = 1; i <= 5; i++) { buffer.put(i); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); Thread consumer = new Thread(() -> { try { for (int i = 0; i < 5; i++) { buffer.take(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); producer.start(); consumer.start(); } }

The Three Rules You Must Never Break

  1. Always call wait/notify/notifyAll inside a synchronized block on the same object. Calling them outside throws IllegalMonitorStateException at runtime.
  2. Always re-check the condition in a while loop after returning from wait. Spurious wakeups and competing threads make the if version unsafe.
  3. Call notifyAll unless you can prove notify is correct. A missed wakeup with notify() leaves a thread suspended forever — the hardest kind of bug to reproduce.

Handling InterruptedException Correctly

wait() declares throws InterruptedException. If another thread calls interrupt() on a waiting thread, wait() throws this exception immediately. The correct response is almost always to restore the interrupted status and let the exception propagate:

try { synchronized (lock) { while (!ready) { lock.wait(); } } } catch (InterruptedException e) { Thread.currentThread().interrupt(); // restore the flag // then either return, throw a checked exception, or clean up }

Swallowing the exception silently (empty catch block) destroys the signal and makes it impossible to shut down the thread cleanly.

wait/notify vs Higher-Level Alternatives

The java.util.concurrent package introduced in Java 5 provides higher-level tools built on top of the same underlying mechanisms:

  • BlockingQueue (LinkedBlockingQueue, ArrayBlockingQueue) — a ready-to-use bounded or unbounded producer-consumer channel. Prefer this over hand-rolling a buffer with wait/notify.
  • Condition (from ReentrantLock.newCondition()) — provides await() / signal() with the same semantics as wait/notify but allows multiple named conditions on a single lock and supports timed waits more cleanly.
  • CountDownLatch, CyclicBarrier, Semaphore — purpose-built synchronizers for one-time countdowns, rendezvous points, and rate limiting.
When to reach for wait/notify today. In new code, prefer BlockingQueue or Condition. Understanding wait/notify remains essential: it underpins every higher-level tool, it appears in legacy codebases you will need to maintain, and interview questions routinely test it. It is also the only option when you cannot introduce a dependency on java.util.concurrent.

Summary

Guarded blocks built around wait and notifyAll are the foundation of inter-thread coordination in Java. The rules are strict but consistent: always hold the monitor, always loop, always prefer notifyAll, and always propagate InterruptedException. In the next lesson we will examine deadlocks — what they are, how to diagnose them, and how to design your way out of them.