Java's wait(), notify(), and notifyAll() are the JVM's lowest-level inter-thread coordination primitives, baked into every object via Object's final methods. They solve a fundamental problem: how to make one thread pause until another thread signals that a condition has changed, without burning CPU in a busy-wait loop.
★
Imagine a coffee shop with one barista and a line of customers.
You call wait() inside a synchronized block to release the monitor and block the thread; another thread calls notify() or notifyAll() on the same object to wake one or all waiting threads, which then reacquire the monitor and continue. This is the raw mechanism behind Java's built-in condition variables, predating java.util.concurrent locks and Condition objects by over a decade.
These methods are not a general-purpose concurrency toolkit—they're the assembly language of thread synchronization. In production, you almost never use them directly unless you're building foundational infrastructure like a bounded blocking queue, a thread pool, or a custom lock.
The java.util.concurrent package (e.g., BlockingQueue, ReentrantLock, CountDownLatch) wraps these primitives with safer, higher-level APIs that handle edge cases like spurious wakeups, lost signals, and fairness. If you're reaching for wait/notify in application code today, you're probably overcomplicating things—use LinkedBlockingQueue or Semaphore instead.
But when you need to understand why ReentrantLock.newCondition().await() works, or debug a deadlock in a legacy system, you must know exactly how wait/notify behaves inside the JVM.
The critical insight that separates senior engineers from the rest: wait() must always be called inside a while loop checking the condition, never an if. The JVM allows spurious wakeups—threads can emerge from wait() without a corresponding notify—and the condition may have changed between the signal and the thread reacquiring the monitor.
Ignore this, and you'll ship a producer-consumer queue that occasionally returns null or deadlocks at 2 AM under load. notify() vs notifyAll() is another sharp edge: notify() wakes one thread (efficient but risky if multiple conditions share the same monitor), while notifyAll() wakes everyone (safe but causes thundering herd). Real systems like Apache Tomcat's thread pool and early versions of java.util.concurrent had bugs from misusing these choices.
Master this API, and you understand the foundation that all higher-level Java concurrency rests on.
Plain-English First
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.
Wait, notify, and notifyAll are the lowest-level thread coordination primitives in Java. They're also the most dangerous. Master them, and you can build high-performance, resource-efficient concurrent systems. Get them wrong, and your application will deadlock silently in production, or burn CPU with busy loops while waiting for work that never arrives.
How wait/notify/notifyAll Actually Coordinates Threads
wait, notify, and notifyAll are the low-level inter-thread communication primitives in Java, built into every object via the Object class. A thread calling wait() on an object releases that object's intrinsic lock (monitor) and enters the object's wait set, suspending execution until another thread calls notify() or notifyAll() on the same object. This is not a polling mechanism — it's a precise signaling contract that requires the calling thread to already hold the object's monitor (i.e., be inside a synchronized block or method).
The key property: wait() atomically releases the lock and blocks, so there is no race between releasing and waiting. When a thread is notified, it does not resume immediately — it must re-acquire the lock before returning from wait(). This means the notified thread competes with other threads for the monitor, and the order of resumption is not guaranteed (JVM-dependent). notify() wakes one arbitrarily chosen thread; notifyAll() wakes all waiting threads. The awakened threads then re-check their condition, typically in a loop, because spurious wakeups are possible and the condition may have changed by the time they reacquire the lock.
Use wait/notify/notifyAll when you need a thread to wait for a condition that depends on another thread's action — for example, a producer-consumer queue where a consumer must wait until an item is available. In production systems, this pattern is the foundation of bounded blocking queues (like ArrayBlockingQueue) and thread pools. The alternative — busy-waiting with a while loop and sleep — wastes CPU cycles and introduces latency. But the low-level API is error-prone; in modern Java, prefer java.util.concurrent constructs (BlockingQueue, CountDownLatch, Phaser) unless you have a very specific reason to manage the monitor yourself.
Always Wait in a Loop
Never use if (condition) wait(); — always use while (condition) wait(); to guard against spurious wakeups and ensure the condition is truly met before proceeding.
Production Insight
A team used notify() instead of notifyAll() in a thread pool's task queue, causing a priority inversion where a high-priority task waited indefinitely because a low-priority thread was the one woken.
Symptom: threads hang randomly under load, thread dumps show threads in WAITING state on the same monitor with no progress.
Rule: Use notifyAll() unless you can prove exactly one thread can proceed — otherwise, you risk lost notifications and thread starvation.
Key Takeaway
wait() releases the lock atomically; notify() does not release the lock — the notifying thread keeps the monitor until its synchronized block exits.
Always guard wait() in a while loop checking the condition — spurious wakeups are real and platform-dependent.
Prefer java.util.concurrent over raw wait/notify — the low-level API is too easy to misuse in production.
thecodeforge.io
Java wait/notify/notifyAll Coordination Flow
Wait Notify Notifyall Java
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.
The Spurious Wakeup — Why Your while() Loop Saves Your Ass
Competitors will tell you to use a while loop around wait(). They won't tell you why you'll get paged at 3 AM if you don't.
Spurious wakeups are real. The JVM spec explicitly allows wait() to return without a corresponding notify() or notifyAll(). Some operating systems (looking at you, certain POSIX implementations) deliver them when signals interrupt a thread. Your code must be hardened against this.
The fix is simple: never use if around wait(). Always while. The condition you waited on might be false when you wake up. Check it again. Loop until it's true.
Here's the pattern that'll keep your production systems green: the thread checks the predicate, waits if false, and rechecks when it awakens. No exceptions. No shortcuts.
SpuriousWakeupGuard.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
// io.thecodeforge — java tutorialpublicclassWaitQueue {
privatefinalObject lock = newObject();
privateboolean itemReady = false;
publicvoidconsume() throwsInterruptedException {
synchronized (lock) {
// WRONG: if (!itemReady) lock.wait();// RIGHT:while (!itemReady) {
lock.wait(); // may wake up without notify()
}
// itemReady is guaranteed true hereSystem.out.println("Consumed item");
itemReady = false;
}
}
publicvoidproduce() {
synchronized (lock) {
itemReady = true;
lock.notifyAll();
}
}
}
Output
Consumed item
// No output if while() prevents premature consumption
Production Trap: The if() Bug
I've seen this cause silent data corruption in a payment processing system. A thread woke up, thought a queue item was ready, and processed a stale reference. Use while(), not if(). Every. Single. Time.
Key Takeaway
Wrap every wait() in a while loop checking the condition. Spurious wakeups are guaranteed by the JVM spec, and your code must handle them.
Lost Wakeup — The Silent Killer of Thread Coordination
The wait/notify contract has a hidden landmine: lost wakeups. It happens when you call notify() before the waiting thread has entered wait(). The notification vanishes. The waiting thread waits forever.
This is not a theoretical edge case. It's the #1 bug I find in code review from teams new to synchronization. The root cause: thinking of notify() as a broadcast when it's actually a pulse.
The fix is defensive: use notifyAll() unless you have a concrete reason for single-thread wakeup. And always pair your state changes with notifications inside the same synchronized block. The producer must set the condition flag AND call notify() before releasing the lock.
Race conditions between checking and waiting? That's what happens when you try to be clever with timing. Don't. Use the lock correctly.
// With badProducer(): waiting thread may hang forever
// With correctProducer(): always delivered
Senior Shortcut: The One-Lock Rule
If your notify() and state mutation aren't in the same synchronized block, you have a bug. Full stop. The JVM's happens-before guarantee only applies within the same lock acquisition.
Key Takeaway
Always set your condition flag and call notify()/notifyAll() inside the same synchronized block. Never split them across separate lock acquisitions.
wait(long timeout, int nanos) — The Nanosecond Mirage
You've used wait(1000) to sleep a thread for a second. But wait(long timeout, int nanos) exists for a reason, and it's not because the JVM team had nothing better to do. The nanos argument is not a precision tool; it's a rounding hint. The JVM will never guarantee nanosecond accuracy. On most operating systems, thread scheduling quantums are in the millisecond range. That 500,000 nanosecond (0.5ms) timeout? It'll likely round up to the next OS tick.
Why does this matter in production? Because developers cargo-cult this API thinking they can build microsecond-precision timeouts. You can't. The real value is in interval-based polling where you want finer granularity than a full millisecond, but you're still at the mercy of the OS scheduler. If you need real-time precision, you're in the wrong language. Use this to prevent tight loops from burning CPU when you know a condition will resolve in sub-millisecond time, but never as a substitute for a proper cooldown period.
PreciseWaitExample.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
// io.thecodeforge — java tutorialpublicclassPreciseWaitExample {
privatefinalObject lock = newObject();
privateboolean ready = false;
publicvoidwaitForReady() throwsInterruptedException {
synchronized (lock) {
long start = System.nanoTime();
while (!ready) {
// Wait 2.5ms — rounds to next OS tick
lock.wait(0, 500_000);
}
long elapsed = System.nanoTime() - start;
System.out.println("Woke up after " + (elapsed / 1_000_000) + " ms");
}
}
publicvoidsignal() {
synchronized (lock) {
ready = true;
lock.notify();
}
}
publicstaticvoidmain(String[] args) throwsInterruptedException {
PreciseWaitExample ex = newPreciseWaitExample();
newThread(ex::signal).start();
ex.waitForReady();
}
}
Output
Woke up after 2 ms
Senior Reality Check:
Never rely on nanos for precision. The JVM treats it as a rounding hint, and Linux's default timer frequency (100Hz or 250Hz) means your precise 500μs wait becomes 4ms or 10ms. Profile your actual resolution before shipping.
Key Takeaway
wait(timeout, nanos) is a rounding hint, not a precision tool — the OS scheduler has the final say on wakeup timing.
Conclusion: What You Actually Need to Remember
Wait, notify, and notifyAll are the bare metal of Java thread coordination. You've seen how they work inside the JVM, why spurious wakeups force you to use while() loops, and how lost wakeups silently corrupt your data. The bounded buffer example showed the pattern that works: synchronized on the same object, check condition in a loop, signal after state change.
Here's the production cheat sheet. Never call wait() without a condition loop — the one time you skip it, a spurious wakeup will toast your invariants. Use notifyAll() unless you can prove single-waiter correctness; notify() saves one thread switch but costs you a debugging nightmare. And never hold multiple locks when waiting — that's how deadlocks are born.
You don't need to memorize the JVM internals. Memorize the pattern: synchronized → while(not ready) → wait() → recheck → done. Every senior dev I know has been burned by at least one of these gotchas. Now you won't be.
CorrectPattern.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// io.thecodeforge — java tutorialpublicclassCorrectPattern {
privatefinalObject lock = newObject();
privateboolean condition = false;
publicvoidwaitForCondition() throwsInterruptedException {
synchronized (lock) {
while (!condition) { // Spurious wakeup safe
lock.wait();
}
// condition is true, proceed safely
}
}
publicvoidsignal() {
synchronized (lock) {
condition = true;
lock.notifyAll(); // Safe: wakes all, even if one was wrong
}
}
}
Output
(no output — pattern, not a driver)
Senior Shortcut:
When you see wait() outside a while() loop in code review, block the merge. Every time. No exceptions.
Key Takeaway
The only safe pattern: synchronized → while(not ready) → wait() → recheck → proceed. Forget the loop, and you'll debug intermittent failures for weeks.
Notifier — Who Actually Calls notify() and How It Works
The Notifier is the thread responsible for calling notify() or notifyAll() to wake waiting threads. Without a notifier, all waiting threads deadlock forever. The notifier must hold the same monitor (synchronized block) as the waiting threads when it calls notify(). A common mistake is calling notify() outside the synchronized block, causing IllegalMonitorStateException. In producer-consumer patterns, the producer acts as notifier after adding items, waking consumers. The key insight: notify() only hints the JVM to wake one thread, but the actual handoff happens only when the notifier releases the lock. The awakened thread must reacquire the lock before returning from wait(). Never call notify() on a condition you haven't changed; otherwise, you cause spurious wakeups that waste CPU.
NotifierExample.javaJAVA
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// io.thecodeforge — java tutorial// Notifier must hold the same lock as wait()publicclassNotifierExample {
privatefinalObject lock = newObject();
privateboolean ready = false;
publicvoidwaitForSignal() throwsInterruptedException {
synchronized (lock) {
while (!ready) { lock.wait(); }
}
}
publicvoidsendSignal() {
synchronized (lock) {
ready = true;
lock.notify(); // Must be inside synchronized
}
}
}
Output
Thread calls sendSignal() -> sets ready=true, then notify() -> waiting thread wakes after lock release.
Production Trap:
Calling notify() without changing the condition can cause the awakened thread to immediately re-enter wait(), wasting CPU and risking lost wakeups if the condition never changes.
Key Takeaway
Always change the shared condition before calling notify(), and do both inside the same synchronized block.
WaitNotifyTest — A Concrete Test to Validate Your Coordination
WaitNotifyTest is a simple test harness that validates a working wait/notify coordination under concurrency. It creates one waiting thread and one notifier thread, uses a shared lock object, and a volatile boolean flag to signal readiness. The test ensures the waiting thread blocks until notified, then checks it completed without timeout. Key components: an ExecutorService for thread management, a CountDownLatch to synchronize test start, and assertions that the waiting thread finishes within a deadline. This catches lost wakeup bugs: if the notifier signals before the waiter starts waiting, the waiter blocks forever. Always structure tests to start the waiter first, then the notifier, and use while(condition) loops inside wait() to handle spurious wakeups. Production teams fail without such tests.
Test passes if waiter completes within 2 seconds. Fails if lost wakeup (waiter times out).
Test Pattern:
Always set waiterReady latch before the waiter calls wait(). This guarantees the waiter is waiting before the notifier fires, preventing race conditions in tests.
Key Takeaway
Always test wait/notify with explicit ordering: waiter first, notifier second, and add a timeout to detect deadlocks.
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.