intermediate~2h

wait(), notify() & notifyAll()

synchronized answers how to stop two threads from touching the same data at once. It does not answer how one thread tells another that the thing it's waiting for just happened. wait()/notify() is Java's original inter-thread messaging system, and its exact rules are some of the most misunderstood -- and most interviewed -- details in the language.

Learning objectives

  • Explain why wait() must release the lock it holds, unlike sleep()
  • Explain why wait() must always be called inside a while loop, never an if statement
  • Explain the risk notify() carries versus notifyAll(), and when each is safe to use
  • Implement a correct bounded-buffer producer-consumer using wait()/notifyAll()
  • Choose a higher-level alternative (BlockingQueue, Lock + Condition) over hand-rolled wait/notify for production code

◆ The problem

You're waiting for a food delivery. You could stand at the window staring at the street the entire time — you'll notice the courier the instant they arrive, but you've wasted the whole wait doing nothing else and burning full attention on it. Or you could glance out the window every ten minutes instead — better, but you might miss the doorbell by up to ten minutes, or check needlessly nine times before anyone ever shows up. The sensible option is to go about your business and let the doorbell tell you the instant it matters.

Translate this into threads. Thread A needs data that Thread B hasn't produced yet. The first option — while (!ready) {} — is a busy wait: Thread A burns 100% of a CPU core spinning, and may even prevent Thread B from getting scheduled at all. The second option — while (!ready) { sleep(10); } — is a sleep loop: less wasted CPU, but it introduces latency, since Thread A only checks every ten milliseconds whether the condition became true. wait()/notify() is Java's doorbell: Thread A calls wait() and instantly goes to the WAITING state, using zero CPU while it waits. Thread B calls notify() (or notifyAll()) the moment the condition actually changes, and Thread A wakes up exactly when needed — not sooner, not with a polling delay.

This topic is about getting that doorbell exactly right, because the rules around it are stricter than they first appear, and small deviations produce bugs that only show up under real concurrent load.

wait() is not a single action — it's three things happening as one atomic unit. First, it releases the intrinsic lock on the object it's called on (which is exactly why wait() must be called inside a synchronized block — it needs a lock to release, and calling it outside one throws IllegalMonitorStateException). Second, it adds the calling thread to the object monitor's wait-set and moves it into the WAITING state, using zero CPU. Third, when a notify()/notifyAll() wakes it, the thread re-acquires the lock — competing with any other threads for it — before wait() actually returns. That middle step, releasing the lock, is the fundamental difference from sleep(): a sleeping thread that still holds a lock blocks everyone else forever, while a waiting thread hands the lock back so the very thread it's waiting on can get in and do the work.

The single most important rule in this entire topic: always call wait() inside a while loop checking the condition, never inside an if. There are two independent reasons this matters. First, the JLS explicitly permits wait() to return spuriously — waking up with no notify() ever having been called at all, a deliberate allowance rooted in how OS-level thread-parking primitives work on some platforms. Second, and more commonly relevant, notifyAll() wakes every waiting thread at once; they all then compete to re-acquire the lock, and only the first one to get it will find the condition actually true. Every other woken thread finds the condition false again and must wait once more. An if would let those threads barrel ahead anyway, acting on a condition that isn't actually satisfied.

💻 Code example

package concurrency.waitnotify; /** * wait()/notifyAll() inside a synchronized method, and the mandatory * while-loop guard around wait() -- unsafeConsume() (if) vs * safeConsume() (while) side by side. */ public class WaitSemanticsAndTheWhileLoop { private boolean ready = false; private String data; // Correct: while(!ready) re-checks the condition after every wakeup, // guarding against spurious wakeups and threads that lost the race. public synchronized String consume() throws InterruptedException { while (!ready) { wait(); } ready = false; return data; } // ANTI-PATTERN: if(!ready) assumes the condition is still true the // moment wait() returns -- true only by luck in low-contention demos. public synchronized String unsafeConsume() throws InterruptedException { if (!ready) { wait(); } return data; // may run even if `ready` flipped back to false } public synchronized void produce(String value) { data = value; ready = true; notifyAll(); // wake every waiter; each re-checks its own condition } public static void main(String[] args) throws Exception { WaitSemanticsAndTheWhileLoop demo = new WaitSemanticsAndTheWhileLoop(); Thread consumer = new Thread(() -> { try { System.out.println("Consumer: waiting for data..."); String val = demo.consume(); System.out.println("Consumer: received " + val); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); Thread producer = new Thread(() -> { try { Thread.sleep(500); demo.produce("Hello from producer!"); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); consumer.start(); producer.start(); consumer.join(); producer.join(); } }

Put the while-loop discipline together with a real coordination problem and you get the classic bounded buffer: fixed capacity, producers block when it's full, consumers block when it's empty, and a single lock has to arbitrate both conditions at once. This is the textbook example every other wait/notify pattern is really building toward, and it's worth building by hand once, because higher-level tools like ArrayBlockingQueue are, internally, a more refined version of exactly this.

Both put() and take() must call notifyAll(), not notify(), in this design. The shared wait-set here holds both producers (waiting for "not full") and consumers (waiting for "not empty") at the same time — a mixed population where notify()'s single arbitrary wakeup could easily wake the wrong role. notifyAll() wakes everyone; each thread re-checks its own while condition and either proceeds or waits again, so there's no risk of waking a thread that can't actually make progress.

Write raw bounded-buffer code like this in constrained environments, teaching contexts, or interviews. In production, prefer the built-in ArrayBlockingQueue, which solves the identical problem with less room for error — as the next section covers.

💻 Code example

package concurrency.waitnotify; import java.util.LinkedList; import java.util.Queue; /** * A complete, correct hand-rolled bounded buffer: synchronized methods, * while-guarded wait(), and notifyAll() to safely wake a mixed population * of producers and consumers sharing one wait-set. */ public class BoundedBufferProducerConsumer { static class BoundedBuffer<T> { private final Queue<T> queue = new LinkedList<>(); private final int capacity; BoundedBuffer(int capacity) { this.capacity = capacity; } public synchronized void put(T item) throws InterruptedException { while (queue.size() == capacity) { wait(); // full: release the lock and wait for a consumer } queue.offer(item); System.out.println("Produced: " + item + " | size: " + queue.size()); notifyAll(); // wake everyone -- producers AND consumers share this wait-set } public synchronized T take() throws InterruptedException { while (queue.isEmpty()) { wait(); // empty: release the lock and wait for a producer } T item = queue.poll(); System.out.println("Consumed: " + item + " | size: " + queue.size()); notifyAll(); return item; } } public static void main(String[] args) throws Exception { BoundedBuffer<Integer> buffer = new BoundedBuffer<>(5); Thread producer = new Thread(() -> { for (int i = 0; i < 20; i++) { try { buffer.put(i); Thread.sleep(50); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }, "producer"); Thread consumer = new Thread(() -> { for (int i = 0; i < 20; i++) { try { buffer.take(); Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } } }, "consumer"); producer.start(); consumer.start(); producer.join(); consumer.join(); } }

▲ Common mistake

Using notify() instead of notifyAll() when the wait-set can hold threads with different roles or different conditions. notify() wakes exactly one arbitrary thread from the wait-set — Java gives no say in which one. A monitor's wait-set doesn't know or care about "roles"; it's just a pool of parked threads. If both producers waiting for space and consumers waiting for data can end up in the same wait-set, and a consumer calls notify() hoping to wake a producer, the JVM is completely free to wake a different, already-satisfied consumer instead — wasting the wakeup while the thread that actually needed it sleeps on.

This bug is dangerous specifically because it's timing-dependent: it shows up as intermittent, hard-to-reproduce hangs — a thread pool that occasionally "loses" a worker, a connection pool where a borrower blocks forever even though a connection was returned, a custom queue that stalls under real load but "works fine" in dev with low concurrency, because with only one waiter type active at a time, notify() happens to always wake the right thread by accident. The default guidance is to use notifyAll() unless you can prove the wait-set only ever holds threads waiting on an identical, symmetric condition.

▲ Common mistake

Calling wait() or notify() outside a synchronized block on the same object. This throws IllegalMonitorStateException immediately. The JVM needs to know exactly which lock's wait-set to park a thread on, and it needs the calling thread to already own that lock so it can atomically release it and register itself with no gap where another thread could sneak in, change the condition, and call notify() before this thread was ever listening.

▲ Edge case

Using if instead of while around wait() is not defending against just one narrow race — it's defending against three separate ways a wakeup can turn out to be premature: a spurious wakeup with no notify() at all, a resource already claimed by another thread that got to the lock first, or unrelated lock contention. This is why "always wait in a loop" is treated as an absolute rule, not a style preference.

The Java Language Specification explicitly permits wait() to return on its own, with no notify()/notifyAll() ever having been called. This is not a JVM bug; it's a deliberate allowance rooted in how underlying OS thread-parking primitives behave on some platforms. Guaranteeing a never-spurious wakeup would require extra bookkeeping and synchronization overhead on every platform Java runs on, purely to rule out an event that's harmless as long as the caller's code already re-checks its condition — which the mandatory while loop already does. So instead of forcing that cost onto every JVM implementation, the JLS requires callers to write correct, self-checking code, and the practical upshot is that you never need to specifically detect or guard against a spurious wakeup: the exact same while loop that protects against a stolen resource protects against this too.

A second, easy-to-miss requirement: every thread coordinating through wait()/notify() on a shared piece of state must be synchronizing on the same object. If a producer synchronizes on this while a consumer accidentally synchronizes on a different object entirely, there is no shared wait-set connecting them at all — notifyAll() on one object never wakes threads parked on another. This mistake usually surfaces as a permanent hang rather than an intermittent bug, since the two sides are, in effect, using two completely disconnected doorbells.

It's also worth noting that wait()/notify()/notifyAll() are methods on Object itself, available on every Java object — not a separate API you opt into. That universality is part of why the discipline around them (same lock, always a loop, always inside synchronized) matters so much: nothing about the type system stops you from calling them incorrectly, and the compiler will not catch any of the mistakes described in this topic.

Nearly every conclusion in this topic points toward the same practical takeaway: hand-rolled wait()/notify() code is exactly the kind of thing you should almost never write in production, even though understanding it is essential, because the higher-level tools that replace it are built out of these same primitives underneath.

For producer-consumer problems specifically, BlockingQueue implementations like ArrayBlockingQueue do all of this automatically — put() and take() block exactly as the hand-rolled version does, with no locking or waiting code required from the caller at all. Internally, ArrayBlockingQueue is implemented with precisely one ReentrantLock and two separate Condition objects (notFull and notEmpty) — which is the second major real-world pattern: when custom coordination logic doesn't map cleanly onto a simple queue, ReentrantLock combined with Condition lets a single lock produce multiple independent wait-sets. notFull.signal() wakes only a thread waiting on notFull; notEmpty.signal() wakes only a thread waiting on notEmpty — precisely solving the "wrong thread woken" problem notify() has, without needing notifyAll()'s wake-everyone approach at all.

This pattern shows up constantly in real systems: connection pools coordinating borrowers and returners, task queues coordinating producers and worker threads, rate limiters coordinating permit acquisition and release. Reach for raw wait()/notify() only as a genuine last resort — when no existing abstraction fits the exact coordination shape needed — or specifically to understand what these higher-level tools are doing beneath the surface.

💻 Code example

package concurrency.waitnotify; import java.util.LinkedList; import java.util.Queue; import java.util.concurrent.ArrayBlockingQueue; import java.util.concurrent.BlockingQueue; import java.util.concurrent.locks.Condition; import java.util.concurrent.locks.Lock; import java.util.concurrent.locks.ReentrantLock; /** * Two production-grade replacements for hand-rolled wait/notify: * ReentrantLock + two Conditions (precise signaling), and the fully * built-in ArrayBlockingQueue. */ public class ModernAlternativesToWaitNotify { static class ConditionBuffer<T> { private final Lock lock = new ReentrantLock(); private final Condition notFull = lock.newCondition(); // producers wait here private final Condition notEmpty = lock.newCondition(); // consumers wait here private final Queue<T> buffer = new LinkedList<>(); private final int capacity = 5; public void put(T item) throws InterruptedException { lock.lock(); try { while (buffer.size() == capacity) { notFull.await(); } buffer.add(item); notEmpty.signal(); // wakes only a consumer, never another producer } finally { lock.unlock(); } } public T take() throws InterruptedException { lock.lock(); try { while (buffer.isEmpty()) { notEmpty.await(); } T item = buffer.poll(); notFull.signal(); // wakes only a producer return item; } finally { lock.unlock(); } } } public static void main(String[] args) throws Exception { // Fully built-in: blocks automatically, no manual locking at all. BlockingQueue<Integer> queue = new ArrayBlockingQueue<>(5); queue.put(42); System.out.println("Took from BlockingQueue: " + queue.take()); ConditionBuffer<Integer> condBuffer = new ConditionBuffer<>(); condBuffer.put(100); System.out.println("Took from ConditionBuffer: " + condBuffer.take()); } }

Q: What is the fundamental difference between wait() and sleep()?

A: wait() releases the intrinsic lock it's holding before parking the thread, letting other threads enter synchronized blocks on that same object. sleep() holds any locks it has for the entire duration, blocking everyone else who needs them. wait() must be called inside a synchronized block; sleep() has no such requirement.

Q: Why must wait() always be called inside a while loop, never an if?

A: Three reasons a woken thread's condition may already be false again: a spurious wakeup (the JLS explicitly permits wait() to return with no notify() at all), another thread claiming the resource first after a shared notifyAll(), or unrelated lock contention. A while loop re-checks the condition every time and waits again if it's still false; an if would let the thread proceed incorrectly.

Q: Why is notify() dangerous when the wait-set holds more than one kind of waiting thread?

A: notify() wakes exactly one arbitrary thread with no control over which one. If producers and consumers share a wait-set, notify() might wake another producer instead of a waiting consumer, wasting the signal while the thread that could actually make progress keeps sleeping -- a bug that shows up as intermittent hangs under real load.

Q: What does notifyAll() do that fixes the notify() problem, and what's the trade-off?

A: It wakes every waiting thread, and each one re-checks its own while condition before proceeding or waiting again -- no risk of waking the wrong role. The trade-off is more threads competing for the lock at once (more contention) since only one can actually proceed per wakeup round.

Q: What should production code use instead of hand-rolled wait()/notify()?

A: BlockingQueue (like ArrayBlockingQueue) for producer-consumer problems -- it blocks automatically with no manual locking code. For custom coordination that doesn't map onto a simple queue, ReentrantLock plus multiple Condition objects lets you signal precisely (e.g. notFull vs notEmpty) instead of waking every waiter with notifyAll().

Want a visual for this concept?

Generate a diagram tailored to “wait(), notify() & notifyAll()” — the AI picks whichever visual (flowchart, comparison, sequence, etc.) best fits.

Sign in to generate a visual →

Practice quiz

Next Step

Continue to ExecutorService & Thread Pools← Back to all Java Concurrency & Multithreading chapters