Java Inter-Thread Communication
Coordinate Java threads with wait, notify, and notifyAll on shared monitor locks — and when to prefer higher-level primitives.
Java Inter-Thread Communication
Mutual exclusion gets you safe shared state. It doesn't let one thread signal another that the state has changed. That's what the trio wait, notify, and notifyAll on java.lang.Object is for. They're the lowest-level coordination primitive Java exposes — every higher-level mechanism (blocking queues, latches, semaphores, Condition) is built on top of this idea: a thread waits inside a monitor until another thread tells it to wake up.
Modern code rarely calls wait/notify directly. You'll use BlockingQueue, CountDownLatch, or Condition instead. But you have to know the underlying mechanism, because (a) it's still what those classes use under the hood, (b) every library you ever read uses it, and (c) when something goes wrong with high-level code, the diagnosis often runs all the way down to a missed notify.
The trio
Defined on java.lang.Object, so every object has them:
void wait() throws InterruptedException;
void wait(long timeoutMillis) throws InterruptedException;
void notify();
void notifyAll();The hard rule: you can only call these methods while you own the monitor of the object you're calling them on. That is, you must be inside a synchronized (obj) { ... } block (or a synchronized method that locks on the same obj). Calling obj.wait() without holding obj's monitor throws IllegalMonitorStateException immediately.
synchronized (lock) {
lock.wait(); // ok — we hold lock
lock.notify(); // ok — same
}
lock.wait(); // IllegalMonitorStateExceptionThat rule is what makes the API work: the wait and the notify are guaranteed to happen with the lock held, so the state they're talking about is consistent.
What wait() actually does
wait() is not "sleep." It atomically does three things:
- Releases the monitor of the object you called it on.
- Parks the current thread in that monitor's wait set.
- When woken (by
notify/notifyAll/interrupt/timeout) it re-acquires the monitor before returning.
The "atomically releases and parks" part is what makes wait safe: a notify that arrives between "we decided to wait" and "we actually started waiting" would otherwise be lost. With wait, that gap doesn't exist.
After wait() returns, you're back inside the synchronized block with the lock held — that's why your code after wait() can safely read the shared state.
What notify() and notifyAll() do
notify() picks one thread (JVM-defined which one — typically not FIFO) from the wait set and moves it from WAITING/TIMED_WAITING to BLOCKED. The notified thread is still waiting for the monitor; the notifier is still holding the monitor. The notified thread can only re-acquire when the notifier exits the synchronized block.
notifyAll() wakes every thread in the wait set the same way. They all become BLOCKED; they all line up for the lock; they re-acquire one at a time as the lock becomes available.
notify is faster (one thread woken) but dangerous: if you wake the wrong thread (one whose condition isn't actually satisfied), it goes back to wait() and nothing useful happens. notifyAll is safer (some waiter who can make progress will) but more expensive. Default to notifyAll; switch to notify only when you can prove all waiters are interchangeable.
The mandatory while-loop pattern
The single most important rule about wait:
Always call
wait()inside awhileloop that re-checks the condition.
synchronized (lock) {
while (!conditionHolds()) {
lock.wait();
}
// now condition holds AND we own the lock
}Three reasons for the loop, not an if:
- Spurious wakeups. The JVM is allowed to wake a
waitfor no reason at all. The loop catches them. notifyAllwakes more than one. When all of them race for the lock, the one that wins may have nothing useful to do — somebody else already consumed the resource. The loop sends it back towait.- Other state can change. Between
notifyand the time you re-acquire the lock, somebody else with the lock may have undone whatever you were waiting for. The loop re-checks.
if (!condition) wait() is the single most common bug in wait/notify code. It works in tests; it breaks in production at 3 a.m.
The classic producer–consumer
The canonical use case for wait/notify is a bounded buffer:
class Buffer<T> {
private final Object lock = new Object();
private final Object[] data;
private int count, head, tail;
Buffer(int capacity) { data = new Object[capacity]; }
void put(T item) throws InterruptedException {
synchronized (lock) {
while (count == data.length) lock.wait(); // wait for room
data[tail] = item;
tail = (tail + 1) % data.length;
count++;
lock.notifyAll(); // wake any consumer
}
}
@SuppressWarnings("unchecked")
T take() throws InterruptedException {
synchronized (lock) {
while (count == 0) lock.wait(); // wait for an item
T item = (T) data[head];
data[head] = null;
head = (head + 1) % data.length;
count--;
lock.notifyAll(); // wake any producer
return item;
}
}
}A few things this gets right:
- Same lock for both methods (
lock). One monitor protects all state. - Both waits are inside
whileloops. notifyAllon both sides — because both producers and consumers wait on the same monitor and waking only one might be the wrong kind.- Lock held while
waiting (thewaitreleases it internally, then re-acquires before returning).
In production you'd use BlockingQueue instead of writing this by hand. But the pattern is what BlockingQueue does internally.
Why notifyAll is the safer default
If you replaced notifyAll with notify in the buffer above, you have a subtle bug. Two consumers and one producer wait on the same monitor. Producer calls notify; the JVM picks a thread; if it picks a consumer when the wake-up was meant for "the queue has room" (irrelevant to consumers), the consumer re-checks its condition (queue is still possibly empty), goes back to wait, and the producer that's actually supposed to wake up never gets woken. Stuck queue, no exception.
To use notify safely you need: all waiters wait for the same condition, all are interchangeable, and the protocol ensures progress. That's a strict bar. Default to notifyAll; use notify when the perf win matters and you can prove the invariant.
The deprecated alternatives
There's old code that uses Thread.suspend() and Thread.resume(). Don't. They were deprecated in Java 1.2 because they leave locks held and break invariants. The wait/notify mechanism is the only safe way to get one thread to wait for another using only Object methods.
There's also Thread.sleep — but sleep doesn't release locks. A thread that sleeps inside a synchronized block blocks every other thread that wants the same lock until it wakes. Use wait (which does release) for any "wait for something to happen" scenario; reserve sleep for "wait for a fixed amount of time, holding nothing important."
What to use instead in production
wait/notify are correct but error-prone. Modern code prefers the higher-level building blocks:
| Need | Use |
|---|---|
| Bounded producer–consumer | ArrayBlockingQueue, LinkedBlockingQueue |
| Wait for N things to finish | CountDownLatch |
| Wait for all N parties to meet | CyclicBarrier, Phaser |
| Multiple condition variables on one lock | Condition (from ReentrantLock.newCondition()) |
| Resource permits | Semaphore |
| Single-shot future result | CompletableFuture |
Each of these has the right while-loop, the right notifyAll/signalAll semantics, and the right interruption handling baked in. We'll meet them all in this part of the book.
A worked example: producer–consumer with wait and notifyAll
The program below runs two producers and three consumers against the bounded buffer above. The producers each put 1000 items; the consumers run until they've collectively taken 2000.
What to take from the run:
- The sums matched. Every item put by a producer was taken by exactly one consumer; nothing was duplicated, nothing was lost. That's the producer–consumer correctness property, achieved with just one monitor and the
wait/notifyAllpair. - The buffer was only 4 slots, so producers consistently filled it and consumers consistently drained it. The
whileloops let them park and re-park as the queue cycled. Withoutwait, the producers would have spun oncount == capacity, burning CPU; with it, they sleep until the consumer signals. - The
notifyAllwas called on the same lock both producers and consumers held. That's the entire coordination mechanism: one monitor, mutual exclusion and signaling, with thewhileloop catching any wake-up that wasn't relevant. - The final
waitoutsidesynchronizedthrewIllegalMonitorStateExceptionimmediately. That's the JVM's enforcement of the rule: you can only wait/notify on a monitor you currently own. If you see this exception, the code path got towaitwithout going throughsynchronizedfirst. - The same shape — bounded buffer, mutual exclusion, signal on every state change — is what
ArrayBlockingQueuedoes internally, except it uses twoConditions (one for "not full," one for "not empty") instead of one bignotifyAll. That's the right way to write this in production; thewait/notifyAllversion is the underlying mechanism every higher-level class is built on.
What's next
The next chapter, Java Deadlock, looks at the failure mode that makes locking subtle in the first place — two threads each holding what the other wants — and the strategies that prevent it.
Practice
Why must `obj.wait()` always be called from inside a `synchronized (obj)` block (or a `synchronized` method that locks on `obj`)?