Imagine a coffee shop with one barista and a line of customers. When the barista runs out of coffee beans, they put up a 'Wait' sign — customers stop and sit down instead of crowding the counter. When the beans arrive, the barista either taps one specific customer (notify) or shouts 'everyone back in line!' (notifyAll). Java's wait/notify is exactly that: a polite way for threads to pause and resume without burning CPU cycles staring at a wall.
Every non-trivial Java application eventually has threads that need to coordinate — a producer building up data that a consumer needs to process, a cache loader that other threads must wait on, or a connection pool that hands out slots only when one is free. Get this coordination wrong and you get wasted CPU from busy-waiting, deadlocks that freeze your app at 2 AM, or race conditions that corrupt data in ways that are nearly impossible to reproduce in a debugger.
wait(), notify(), and notifyAll() are the original, low-level answer to this problem baked into every Java object since JDK 1.0. They let one thread voluntarily release a lock and sleep until another thread signals that the condition it was waiting for might now be true. That 'might' is doing a lot of work in that sentence — and it's the source of most of the bugs people write with these methods.
By the end of this article you'll be able to build a correct producer-consumer pipeline using wait/notify, explain exactly what happens to a thread's lock when it calls wait(), know why notifyAll() is almost always safer than notify(), and walk into a senior Java interview with the internals sharp enough to answer the follow-up questions that trip most candidates up.
How wait() and notify() Actually Work Inside the JVM
Every Java object carries two hidden data structures inside its monitor: a lock (a mutex) and a wait-set (a queue of sleeping threads). When you call synchronized(someObject) you're competing for that object's lock. Once you hold it, calling someObject.wait() does three things atomically: it adds the calling thread to the wait-set, releases the lock, and suspends the thread. That release is crucial — without it, no other thread could ever call notify() because they'd never acquire the lock.
When another thread calls someObject.notify(), the JVM picks one thread from the wait-set and moves it to the entry-set — the queue competing for the lock. The notified thread doesn't run immediately; it re-acquires the lock first, then returns from wait(). This is why you must always re-check your condition after wait() returns using a while loop, not an if. The window between being notified and re-acquiring the lock is a real window, and another thread can slip in and invalidate the condition you were waiting for.
The JVM spec also permits spurious wakeups — a thread can return from wait() with no notify() having been called at all. This isn't just theoretical; it happens on Linux due to how POSIX condition variables are implemented under the hood. The while-loop pattern isn't defensive paranoia — it's mandatory correctness.
MonitorInternalsDemo.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
/**
* Demonstrates the fundamental wait/notify contract.
* Shows the mandatory while-loop pattern and the lock-release behaviour.
*
* Runthis and watch how the producer thread parks the consumer
* until data is actually ready — zero busy-waiting.
*/
publicclassMonitorInternalsDemo {
privatestaticfinalObject lock = newObject();
private static String sharedData = null; // guarded by 'lock'
private static boolean dataReady = false; // condition variablepublicstaticvoidmain(String[] args) throwsInterruptedException {
// --- CONSUMER THREAD ---Thread consumer = newThread(() -> {
synchronized (lock) { // 1. acquire the monitorSystem.out.println("[Consumer] Lock acquired. Checking condition...");
// CRITICAL: while — not if — because of spurious wakeups// and because another consumer could steal the data first.while (!dataReady) {
System.out.println("[Consumer] Data not ready. Releasing lock and sleeping...");
try {
lock.wait(); // 2. atomically: releases lock + parks thread
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // preserve interrupt flagreturn;
}
// 3. After wakeup: lock is re-acquired before we get here.// We MUST re-check dataReady — it may have been consumed already.System.out.println("[Consumer] Woke up. Re-checking condition...");
}
// Safe to read: we hold the lock AND the condition is confirmed true.System.out.println("[Consumer] Data received: " + sharedData);
}
}, "ConsumerThread");
// --- PRODUCER THREAD ---Thread producer = newThread(() -> {
try {
Thread.sleep(1500); // simulate work before data is ready
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
return;
}
synchronized (lock) { // must hold lock before notify()
sharedData = "Order #42 — Double Espresso";
dataReady = true;
System.out.println("[Producer] Data prepared. Notifying consumer...");
lock.notify(); // wake one waiting thread
} // lock released here
}, "ProducerThread");
consumer.start();
producer.start();
consumer.join();
producer.join();
System.out.println("[Main] Both threads finished cleanly.");
}
}
Output
[Consumer] Lock acquired. Checking condition...
[Consumer] Data not ready. Releasing lock and sleeping...
[Producer] Data prepared. Notifying consumer...
[Consumer] Woke up. Re-checking condition...
[Consumer] Data received: Order #42 — Double Espresso
[Main] Both threads finished cleanly.
Watch Out: Never Use if — Always Use while
Replacing the while loop with an if statement is the single most common wait/notify bug. Spurious wakeups and competing threads can both return wait() without your condition being true. The while loop costs nothing and prevents data corruption that only shows up under load.
Building a Correct Bounded Producer-Consumer Queue
The classic application of wait/notify is a bounded blocking queue — a buffer with a fixed capacity shared between producers and consumers. Producers wait when the buffer is full; consumers wait when it's empty. This pattern appears everywhere: thread pools, message brokers, async pipelines.
The two conditions to model are: 'buffer is not full' (producers wait on this) and 'buffer is not empty' (consumers wait on this). With a single lock object, both conditions share the same wait-set, which is why notifyAll() becomes important here — a notify() might wake the wrong type of waiter.
Pay close attention to where notify/notifyAll is called in the code below: inside the synchronized block, after the state change, before the lock is released. This ordering guarantees the notified thread will see the updated state when it re-acquires the lock.
BoundedBlockingQueue.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
import java.util.ArrayDeque;
import java.util.Deque;
/**
* A thread-safe bounded queue built with wait/notify.
* Producers block when full. Consumers block when empty.
* UsesnotifyAll() deliberately — see comment inside offer().
*/
publicclassBoundedBlockingQueue<T> {
privatefinalDeque<T> buffer;
privatefinalint capacity;
privatefinalObject lock = newObject();
publicBoundedBlockingQueue(int capacity) {
this.capacity = capacity;
this.buffer = newArrayDeque<>(capacity);
}
/**
* Adds an item. Blocksif the buffer is full.
*/
publicvoidoffer(T item) throwsInterruptedException {
synchronized (lock) {
// Wait until there's space — re-check after every wakeupwhile (buffer.size() == capacity) {
System.out.printf("[%s] Buffer full (%d/%d). Producer waiting...%n",
Thread.currentThread().getName(), buffer.size(), capacity);
lock.wait();
}
buffer.addLast(item);
System.out.printf("[%s] Produced: %-20s | Buffer size: %d/%d%n",
Thread.currentThread().getName(), item, buffer.size(), capacity);
// notifyAll() instead of notify() because:// Both producers AND consumers share this wait-set.// A notify() might wake another blocked producer (wrong thread type)// leaving a starved consumer sleeping forever.
lock.notifyAll();
}
}
/**
* Removes and returns an item. Blocksif the buffer is empty.
*/
public T poll() throwsInterruptedException {
synchronized (lock) {
// Wait until there's something to consumewhile (buffer.isEmpty()) {
System.out.printf("[%s] Buffer empty. Consumer waiting...%n",
Thread.currentThread().getName());
lock.wait();
}
T item = buffer.removeFirst();
System.out.printf("[%s] Consumed: %-20s | Buffer size: %d/%d%n",
Thread.currentThread().getName(), item, buffer.size(), capacity);
lock.notifyAll(); // wake blocked producers now that space existsreturn item;
}
}
// ---------------------------------------------------------------// Demo: 2 producers, 3 consumers, capacity 3// ---------------------------------------------------------------publicstaticvoidmain(String[] args) throwsInterruptedException {
BoundedBlockingQueue<String> queue = newBoundedBlockingQueue<>(3);
Runnable producerTask = () -> {
String[] orders = {"Latte", "Espresso", "Cappuccino", "Americano"};
for (String order : orders) {
try {
queue.offer(order);
Thread.sleep(200); // simulate production time
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
};
Runnable consumerTask = () -> {
for (int i = 0; i < 3; i++) {
try {
queue.poll();
Thread.sleep(500); // consumers are slower than producers
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}
};
Thread p1 = newThread(producerTask, "Producer-1");
Thread p2 = newThread(producerTask, "Producer-2");
Thread c1 = newThread(consumerTask, "Consumer-1");
Thread c2 = newThread(consumerTask, "Consumer-2");
Thread c3 = newThread(consumerTask, "Consumer-3");
p1.start(); p2.start();
c1.start(); c2.start(); c3.start();
p1.join(); p2.join();
c1.join(); c2.join(); c3.join();
System.out.println("[Main] All threads done. Queue size: " + queue.buffer.size());
}
}
[Consumer-1] Consumed: Americano | Buffer size: 0/3
... (remaining consumers wait and finish)
[Main] All threads done. Queue size: 0
Pro Tip: Prefer notifyAll() When Multiple Condition Types Share One Lock
If producers and consumers both block on the same object, notify() might wake a thread that still can't proceed (e.g., waking a producer when the buffer is still full). notifyAll() is O(n) on the number of waiting threads but prevents missed signals and is correct by default. Switch to notify() only after profiling proves the wakeup storm is a real bottleneck — typically with a single condition and many identical waiters.
notify() vs notifyAll() — When Each One Is the Right Tool
This is one of the most misunderstood distinctions in Java concurrency. The short version: notify() is an optimisation, not a default. Use it only when you can guarantee that exactly one waiting thread can make progress after the state change, and all waiting threads are waiting for the same condition.
notifyAll() wakes all threads in the wait-set. Each one re-acquires the lock in turn, re-checks the condition, and either proceeds or goes back to sleep. Yes, this produces a 'thundering herd' — every woken thread competes for the lock, and most will just go back to sleep. Under high contention with hundreds of waiting threads this overhead is measurable. But it's always safe.
notify() wakes exactly one thread — JVM-chosen, not your choice. If that thread can't proceed (wrong condition), no other thread gets woken. You now have a system that's deadlocked even though progress is possible. This is called a 'missed signal' or 'lost wakeup' and it is brutally hard to debug.
The canonical rule: you can safely use notify() if and only if both conditions hold — (1) all threads waiting on this object are waiting for the same condition, and (2) one notification is sufficient to allow exactly one thread to proceed.
NotifyVsNotifyAll.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
/**
* Demonstrates the dangerous scenario where notify() causes a missed wakeup
* and why notifyAll() fixes it.
*
* Scenario: Twoconsumers (waiters forDIFFERENT conditions) share one lock.
* A producer does notify() — wakes the wrong consumer — deadlock-like stall.
*/
publicclassNotifyVsNotifyAll {
privatestaticfinalObject lock = newObject();
privatestaticboolean coffeeReady = false;
privatestaticboolean teaReady = false;
publicstaticvoidmain(String[] args) throwsInterruptedException {
// Consumer A is waiting for coffeeThread coffeeWaiter = newThread(() -> {
synchronized (lock) {
while (!coffeeReady) {
System.out.println("[CoffeeWaiter] Waiting for coffee...");
try { lock.wait(); } catch (InterruptedException e) {
Thread.currentThread().interrupt(); return;
}
}
System.out.println("[CoffeeWaiter] Got coffee! ");
}
}, "CoffeeWaiter");
// Consumer B is waiting for teaThread teaWaiter = newThread(() -> {
synchronized (lock) {
while (!teaReady) {
System.out.println("[TeaWaiter] Waiting for tea...");
try { lock.wait(); } catch (InterruptedException e) {
Thread.currentThread().interrupt(); return;
}
}
System.out.println("[TeaWaiter] Got tea! ");
}
}, "TeaWaiter");
coffeeWaiter.start();
teaWaiter.start();
Thread.sleep(500); // let both threads park in wait-set// SAFE VERSION — notifyAll() wakes both waiters.// Each re-checks their own condition. The right one proceeds.synchronized (lock) {
coffeeReady = true;
System.out.println("[Barista] Coffee ready. Using notifyAll()...");
lock.notifyAll(); // try replacing with notify() — tea waiter may never wake
}
// Give tea waiter something to wake for tooThread.sleep(200);
synchronized (lock) {
teaReady = true;
System.out.println("[Barista] Tea ready. Using notifyAll()...");
lock.notifyAll();
}
coffeeWaiter.join();
teaWaiter.join();
System.out.println("[Main] All customers served.");
}
}
Output
[CoffeeWaiter] Waiting for coffee...
[TeaWaiter] Waiting for tea...
[Barista] Coffee ready. Using notifyAll()...
[CoffeeWaiter] Got coffee! ☕
[TeaWaiter] Waiting for tea...
[Barista] Tea ready. Using notifyAll()...
[TeaWaiter] Got tea! 🍵
[Main] All customers served.
Interview Gold: The Modern Alternative
java.util.concurrent.locks.Condition (from ReentrantLock) solves the notify() problem elegantly. You create separate Condition objects — one per logical condition — so notFull.signal() wakes only producers and notEmpty.signal() wakes only consumers. This is exactly how LinkedBlockingQueue is implemented in the JDK. Know both: wait/notify for the interview, Condition for production code.
Production Gotchas — What Goes Wrong in Real Systems
Knowing the API is only half the battle. Here's what actually bites engineers in production.
Calling wait() outside a synchronized block throws IllegalMonitorStateException immediately — no data corruption, just a crash. Easy to catch. The harder bug is calling notify() on a different object than the one you called wait() on. Both compile silently and both cause missed signals.
InterruptedException handling is where a lot of production code quietly breaks. Swallowing the interrupt (catch block that does nothing) means the thread will never respond to a shutdown signal. Always either re-throw or call Thread.currentThread().interrupt() to restore the flag so the caller can react.
Holding the lock too long is a performance killer. Everything inside the synchronized block is serialised. If your condition check or state update does I/O, database calls, or heavy computation, every other thread queues up. Push heavy work outside the synchronized block; use the lock only for reading/writing the shared state and calling wait/notify.
Nested locks — never call wait() while holding two locks. If Thread A holds Lock1 and waits on Lock2's monitor, and Thread B holds Lock2 and waits on Lock1's monitor, you have a classic deadlock. Lock ordering rules exist for this reason.
ProductionSafeWaitNotify.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
/**
* Production-grade pattern showing:
* 1. CorrectInterruptedException handling
* 2. Timeout on wait() to prevent infinite blocking
* 3. Lock held only for state access — heavy work done outside
*/
publicclassProductionSafeWaitNotify {
privatestaticfinalObject stateLock = newObject();
privatestaticvolatileboolean orderComplete = false;
// Maximum time a consumer should wait before giving up (ms)privatestaticfinallong WAIT_TIMEOUT_MS = 3000;
/**
* Consumer: waits for order with a timeout.
* Returnstrueif order arrived, falseif timed out.
*/
publicstaticbooleanwaitForOrder() throwsInterruptedException {
long deadline = System.currentTimeMillis() + WAIT_TIMEOUT_MS;
synchronized (stateLock) {
while (!orderComplete) {
long remaining = deadline - System.currentTimeMillis();
if (remaining <= 0) {
System.out.println("[Consumer] Timed out waiting for order.");
return false; // did not get the order in time
}
System.out.printf("[Consumer] Waiting up to %dms for order...%n", remaining);
// wait(timeout) — returns when notified OR when timeout expires// We re-check the condition (while loop) either way
stateLock.wait(remaining);
}
return true; // condition is confirmed true, lock is held
}
}
/**
* Producer: does heavy work OUTSIDE the lock, then signals quickly.
*/
publicstaticvoidprepareAndSignalOrder() throwsInterruptedException {
// --- Heavy work happens OUTSIDE the synchronized block ---System.out.println("[Producer] Preparing order (heavy work)...");
Thread.sleep(1200); // simulate DB call, I/O, etc. — NOT inside lockString preparedOrder = "FlatWhite x2"; // result computed outside lock// --- Minimal critical section: just update state and signal ---synchronized (stateLock) {
orderComplete = true;
System.out.println("[Producer] Order ready: " + preparedOrder + ". Notifying...");
stateLock.notifyAll();
} // lock released immediately after signal
}
publicstaticvoidmain(String[] args) throwsInterruptedException {
Thread consumerThread = newThread(() -> {
try {
boolean received = waitForOrder();
System.out.println("[Consumer] Order received? " + received);
} catch (InterruptedException e) {
// CORRECT: restore the interrupt flag, don't swallow itThread.currentThread().interrupt();
System.out.println("[Consumer] Interrupted. Shutting down.");
}
}, "ConsumerThread");
Thread producerThread = newThread(() -> {
try {
prepareAndSignalOrder();
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
}, "ProducerThread");
consumerThread.start();
producerThread.start();
consumerThread.join();
producerThread.join();
}
}
Output
[Consumer] Waiting up to 3000ms for order...
[Producer] Preparing order (heavy work)...
[Producer] Order ready: Flat White x2. Notifying...
[Consumer] Order received? true
Watch Out: wait(0) is the Same as wait()
Calling lock.wait(0) does NOT return immediately — it waits indefinitely, identical to lock.wait(). This surprises engineers who expect it to be a non-blocking check. If you want a non-blocking check, just read the condition variable directly (inside a synchronized block) without calling wait() at all.
Aspect
notify()
notifyAll()
Threads woken
Exactly one (JVM-chosen)
All threads in the wait-set
Safety with multiple conditions
Dangerous — can wake wrong thread type
Safe — each thread re-checks its own condition
Risk of missed signal / livelock
High if misused
None — all waiters re-evaluate
Performance (many waiting threads)
Better — O(1) wakeup cost
Worse — O(n) thundering herd
Correct use case
Single condition + all waiters identical
Multiple conditions or uncertain waiter types
JDK internal usage example
Rarely used directly
LinkedBlockingQueue, Object.wait docs
Production default recommendation
Optimisation — apply after profiling
Yes — start here, always safe
Key takeaways
1
wait() atomically releases the lock AND parks the thread
no lock is held while sleeping, which is exactly what makes the coordination possible.
2
The while loop around wait() is not defensive paranoia
it's mandatory because of spurious wakeups and because the condition can be invalidated between notify() and lock re-acquisition.
3
notifyAll() is the safe default when multiple thread types share one lock; notify() is an optimisation valid only when all waiting threads are identical and exactly one can always proceed.
4
Do heavy computation outside the synchronized block
hold the lock only long enough to read/write shared state and call notify. Lock contention is a silent performance killer in production systems.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
FAQ · 3 QUESTIONS
Frequently Asked Questions
01
What is the difference between notify() and notifyAll() in Java?
notify() wakes exactly one thread from the wait-set — the JVM picks which one and you have no control. notifyAll() wakes all waiting threads, each of which then re-acquires the lock and re-checks its condition. Use notifyAll() as your default; only switch to notify() when all waiting threads share the same condition and exactly one can proceed after each notification.
Was this helpful?
02
Why do I get IllegalMonitorStateException when calling wait()?
wait(), notify(), and notifyAll() must be called from within a synchronized block or method on the same object. If you synchronize on 'this' but call wait() on a different object — or forget the synchronized block entirely — the JVM throws IllegalMonitorStateException. Double-check that the object you lock on and the object you call wait/notify on are the same reference.
Was this helpful?
03
Can a thread be woken from wait() without notify() being called?
Yes — this is called a spurious wakeup, and it's allowed by the Java specification and actually occurs on Linux because the underlying POSIX pthread_cond_wait() can return spuriously. This is the definitive reason you must always re-check your condition in a while loop after wait() returns, not just once with an if statement.