On this page
Every Java developer learns the rule early: call wait() and notify() only inside a synchronized block, or the JVM throws IllegalMonitorStateException. Far fewer can explain why the rule exists. It is not bureaucracy. Without that lock, a perfectly reasonable-looking consumer can go to sleep forever while the item it is waiting for sits right there in the queue. This guide takes the rule apart: the monitor it protects, the race it prevents, and the idioms and modern replacements that follow from it.
After reading this article you will be able to
- Explain what a monitor is, and what its entry set, owner and wait set each hold.
- Walk through the lost-wakeup race step by step and show how owning the monitor rules it out.
- Describe exactly what
wait()does: releasing the lock, joining the wait set, waking up and reacquiring the lock. - Explain why the guard must be a
whileloop, and choose correctly betweennotify()andnotifyAll(). - Write a correct bounded buffer three ways: with intrinsic locks, with
Lock+Condition, and withBlockingQueue. - Handle interrupts and timeouts correctly, and answer the interview questions that come up around this topic.
01The Rule and Its Exception
wait(), notify() and notifyAll() are final methods on java.lang.Object, so you can call them on any object. The compiler accepts them anywhere, and the language allows them anywhere. The JVM, however, checks one thing at runtime every time they are called: does the current thread own the monitor of the object the method is called on? If it does not, the call fails immediately.
Object lock = new Object();
lock.wait();
// java.lang.IllegalMonitorStateException: current thread is not owner
lock.notify();
// same exception: notify() is checked exactly the same wayThe exception message differs between JDK versions (older releases print no message at all), but the cause is always the same. This is a runtime check, not a compile-time one. The compiler cannot verify ownership, because the lock may have been taken by a caller several frames up the stack:
private final Object lock = new Object();
private boolean ready;
void startAndAwait() throws InterruptedException {
synchronized (lock) {
awaitReady(); // lock owned here...
}
}
private void awaitReady() throws InterruptedException {
// ...so this is legal, even though no synchronized is visible here
assert Thread.holdsLock(lock) : "caller must hold lock";
while (!ready) {
lock.wait();
}
}Thread.holdsLock(obj) is the standard way to document and assert that precondition in helper methods.
It must be the same object's monitor
Holding a lock is not enough. You must hold the monitor of the exact object you call wait() or notify() on. This is the most common way the exception shows up in code that "is synchronized":
Object a = new Object();
Object b = new Object();
synchronized (a) {
b.notify(); // IllegalMonitorStateException: we own a, not b
}
public synchronized void consume() throws InterruptedException {
while (queue.isEmpty()) {
queue.wait(); // owns 'this' (synchronized method), not 'queue'
}
}A synchronized instance method locks this, so a bare wait() inside it (which means this.wait()) is fine. A static synchronized method locks the Class object, so a bare wait() there does not even compile: there is no this, and wait() is an instance method. You would have to call MyClass.class.wait(). The rule is always the same: synchronize on X, then call X.wait() / X.notify().
This article zooms in on one rule from the broader Java Concurrency & Multithreading guide. Read that first if thread states, monitors and happens-before are new to you.
02Anatomy of a Monitor
To see why the check exists, you need to know what the JVM is protecting. Every Java object can act as a monitor. The JVM does not allocate the full structure until it is needed: an uncontended lock lives in a few bits of the object header, and the JVM "inflates" it to a full monitor when there is contention or when somebody calls wait(). Conceptually, though, each object has one.
Monitor Definition
A per-object synchronization construct that combines a mutual-exclusion lock (only one thread may own it at a time, reentrantly) with a wait set (threads parked until another thread signals them). Java's synchronized acquires and releases the lock; wait, notify and notifyAll operate on the wait set. The JLS covers them in §17.1 (Synchronization) and §17.2 (Wait Sets and Notification).
A monitor has three areas a thread can be in:
- Entry set: threads that want to enter a
synchronizedblock on this object while someone else owns it. Their state isBLOCKED. - Owner: at most one thread, the one currently running inside a
synchronizedblock on this object. It may hold the lock several times (reentrancy). The JVM tracks a hold count. - Wait set: threads that called
wait()on this object. Their state isWAITING(orTIMED_WAITINGforwait(timeout)). They hold no claim on the lock at all.
Anatomy of a Java monitor: entry set, owner, wait set
wait() park in the wait set. Notice the dashed path back: a notified thread goes back to competing for the lock. It does not take over ownership directly.Wait set Definition
The set of threads that have called wait() on an object and have not yet been removed by notify(), notifyAll(), a timeout, an interrupt or a spurious wakeup. JLS §17.2: "Every object, in addition to having an associated monitor, has an associated wait set." The wait set is manipulated only through those methods, and only by the monitor's owner.
That last sentence is the whole rule seen from the JVM's side. The entry set and the wait set are shared data structures. The monitor's own lock is what keeps them consistent, so only the owner may add itself to the wait set or move threads out of it.
03The Lost Wakeup Race
The JVM's internal bookkeeping explains part of the requirement. The more important reason is in your code. Every use of wait() has the same shape: check a condition, and if it does not hold, wait until someone changes it. The waker has the matching shape: change the state, then notify. These two sequences have to be atomic with respect to each other. If they are not, a notification can arrive in the gap between the check and the wait and be lost.
Condition predicate Definition
The boolean expression over shared state that a waiting thread is waiting for, such as !queue.isEmpty() or count < capacity. wait() and notify() carry no information themselves. All the meaning lives in the predicate, and the predicate reads shared mutable state.
Lost wakeup Definition
A failure where a notification is sent while the intended waiter is not yet in the wait set. notify() on an empty wait set is a no-op with no memory, so the waiter then blocks indefinitely even though the condition it is waiting for is already true.
Imagine Java let you call wait() without the lock. A natural-looking consumer and producer would look like this:
// Consumer thread
if (queue.isEmpty()) { // (1) check
queue.wait(); // (5) wait -- in real Java this throws IMSE
}
Item item = queue.remove();
// Producer thread
queue.add(item); // (3) change state
queue.notify(); // (4) signalNow play one unlucky but perfectly legal interleaving. The scheduler can preempt a thread between any two operations, and nothing here forbids this one:
| Step | Consumer | Producer | Queue | Wait set |
|---|---|---|---|---|
| 1 | isEmpty() returns true | not yet running | empty | empty |
| 2 | preempted before calling wait() | scheduled | empty | empty |
| 3 | still preempted | add(item) | 1 item | empty |
| 4 | still preempted | notify(): nobody waiting, no effect | 1 item | empty |
| 5 | resumes, calls wait() | done, moves on | 1 item | consumer |
| 6 | WAITING forever | never notifies again | 1 item | consumer |
The lost wakeup: check-then-wait without a lock
wait() (step 5) are separate actions, so the producer's add and notify (steps 3–4) can run between them. notify() on an empty wait set does nothing, so the consumer's later wait() has nothing to wake it.The consumer made a decision ("the queue is empty, so I should sleep") based on a fact that stopped being true before it acted on it. This is a classic check-then-act race. Here the "act" is going to sleep, and the cost is a thread that never wakes up, which in production looks like a hang with no exception, no CPU usage and no log line.
The fix: one lock around check, wait and signal
Now require both sides to hold the same monitor. The consumer's check and its call to wait() happen while it owns the lock, and the producer cannot add or notify without owning that same lock. The producer is therefore physically unable to run steps 3–4 until the consumer has either found an item or entered the wait set.
// Consumer
synchronized (queue) {
while (queue.isEmpty()) { // check under the lock
queue.wait(); // release lock + enter wait set, atomically
}
item = queue.remove(); // still under the lock
}
// Producer
synchronized (queue) {
queue.add(item); // change state under the lock
queue.notifyAll(); // signal under the lock
}The crucial piece is that wait() releases the lock and joins the wait set as one atomic step. There is no moment where the consumer has let go of the lock but is not yet waiting. From the producer's point of view, the consumer is either still checking (so the producer is BLOCKED) or already in the wait set (so the producer's notification will find it).
The same race, made impossible by the monitor
wait() releases the lock and joins the wait set together, so by the time the producer gets in and calls notify(), there is guaranteed to be a waiter to receive it.The lock also makes the state visible
There is a second, quieter reason the lock matters: memory visibility. An unlock of a monitor happens-before every subsequent lock of that same monitor (JLS §17.4.5). When the producer adds an item and exits the block, and the consumer reacquires the lock on its way out of wait(), the consumer is guaranteed to see the producer's write to the queue. Without the lock, even a "correct" interleaving could leave the consumer reading a stale, cached view of the queue. The monitor gives you mutual exclusion and visibility together, and the wait/notify protocol depends on both.
The runtime check only verifies that you own the lock when you call wait(). It cannot verify that the lock also covered your check. This code passes the check and still has the lost-wakeup bug:
if (queue.isEmpty()) { // check: no lock held -- race window
synchronized (queue) {
queue.wait(); // wait: lock held -- no exception
}
}The synchronized block has to enclose the whole check-and-wait loop, not just the call. Keep this example in mind. It is also the answer to "why didn't the JVM designers just make wait() grab the lock itself?", which we come back to at the end.
04What wait() Actually Does
JLS §17.2.1 specifies wait() precisely. Let t be the calling thread, m the object, and n the number of times t currently holds m's lock (more than one if synchronized blocks on m are nested):
- Ownership check. If n is zero, throw
IllegalMonitorStateException. If the thread is already interrupted, throwInterruptedExceptioninstead of waiting. - Join the wait set and release fully. t is added to m's wait set and performs n unlock actions, releasing all reentrant holds, not just one. The thread becomes
WAITING(orTIMED_WAITING). - Stay dormant until it is removed from the wait set by one of five events:
notify()picks it,notifyAll(),interrupt(), the timeout elapses, or an implementation-specific spurious wakeup. - Reacquire. t performs n lock actions to restore its exact previous hold count. While it competes for the lock it is
BLOCKED, just like any thread trying to entersynchronized. - Return, or if it was woken by an interrupt, clear the interrupt status and throw
InterruptedException. Even then, the lock has already been reacquired, so yourcatchorfinallycode runs while holding it.
Two consequences surprise people. First, notify() does not release the lock. The notifier keeps ownership until it leaves its own synchronized block. The woken thread moves from WAITING to BLOCKED and must wait for that exit. Second, a woken thread gets no priority. It competes with every other thread in the entry set, including brand-new arrivals, and the JLS makes no fairness promise about who wins.
Thread states and lock ownership across one wait/notify cycle
wait(). After notify() it becomes BLOCKED, not RUNNABLE, because the producer still owns the lock until it exits the block. Only after that exit does the consumer reacquire the lock and return from wait().notify() picks an arbitrary thread
JLS §17.2.2: "There is no guarantee about which thread in the wait set is selected." It is not FIFO, not priority-based, and not something you can rely on even if one JVM version happens to behave in order. notifyAll() removes every thread from the wait set. They all move to BLOCKED and acquire the lock one at a time, each returning from wait() in turn.
wait() releases only one monitor
wait() releases the monitor of the object it is called on, and nothing else. If the thread also holds other locks (an outer synchronized on a different object, or a ReentrantLock), it keeps them while it sleeps. If the thread that would notify it needs one of those locks first, both threads stop forever. This is sometimes called nested monitor lockout. Avoid calling wait() while holding any lock other than the one you are waiting on.
synchronized (outer) {
synchronized (inner) {
while (!ready) {
inner.wait(); // releases inner, still holds outer
}
}
}
// Notifier: blocks forever on 'outer', so it never reaches notify()
synchronized (outer) {
synchronized (inner) {
ready = true;
inner.notifyAll();
}
}05Why while, Never if
Returning from wait() tells you only one thing: you were removed from the wait set and you own the lock again. It does not tell you that your condition predicate is true. There are three separate reasons it might be false:
- Spurious wakeups. The
Object.waitJavadoc and JLS §17.2.1 explicitly allow a thread to wake with no notify, interrupt or timeout. That makes it simpler for the JVM to implement waiting efficiently on top of operating-system primitives that have the same allowance. - Stolen wakeups. Between the
notify()and the moment the woken thread reacquires the lock, other threads can get the lock first and consume the state the notification was about. - Broadcasts.
notifyAll()wakes every waiter, but often only one of them can proceed. And if one wait set serves several predicates (for example "not full" and "not empty"), a wakeup may have been meant for someone else entirely.
Spurious wakeup Definition
A return from wait() that was not caused by notify, notifyAll, an interrupt or a timeout. It is permitted by the specification and is rare in practice, but correct code must tolerate it. The Object.wait Javadoc's own advice is that waits should always occur in loops.
The stolen wakeup is the most instructive of the three, because it needs no unusual JVM behaviour, only ordinary scheduling:
The stolen wakeup: why the guard must be a while loop
wait(), the queue is empty again. An if guard proceeds anyway, while a while guard re-checks and goes back to sleep.if (queue.isEmpty()) queue.wait(); checks once. After any spurious, stolen or broadcast wakeup it continues with a false predicate. queue.remove() then throws NoSuchElementException, poll() returns null, or worse, the code silently corrupts an invariant such as a count going negative.
while (queue.isEmpty()) queue.wait(); re-evaluates the predicate every time it owns the lock. A wakeup is treated as a hint to look again, not as a promise. When the loop exits, the predicate is true and the lock is held, which is exactly the precondition the next line needs.
Put together, every correct use of wait() follows this template:
synchronized (lock) {
while (!conditionPredicate()) {
lock.wait();
}
// predicate is true AND we hold the lock: act on the state
}06notify() vs notifyAll()
notify() wakes one arbitrary waiter. notifyAll() wakes all of them. notify() looks like the efficient choice, and in a narrow set of cases it is. Outside those cases it can quietly stop your program.
Consider a bounded buffer with capacity 1. Producers and consumers synchronize on the same object, so they share one wait set, but they wait for opposite predicates: producers for "not full", consumers for "not empty". With notify(), this sequence is legal:
- Consumers C1 and C2 call
take()on the empty buffer and both wait. - Producer P1 puts an item and calls
notify(), which happens to wake C1. C1 is now BLOCKED, waiting for the lock. - Before C1 runs, P1 tries to put again: buffer full, so it waits. P2 also tries to put: full, so it waits. Wait set: {C2, P1, P2}.
- C1 gets the lock, takes the item (buffer now empty) and calls
notify(). The JVM picks C2, another consumer. - C1 loops to take again: empty, so it waits. C2 gets the lock: empty, so it waits.
- Wait set: {P1, P2, C1, C2}. Every thread is waiting and nobody is left to notify.
notify() can wake the wrong kind of waiter
notify() may wake a consumer when only a producer can make progress (left). The woken consumer re-checks, waits again, and the one signal that mattered is used up. notifyAll() (right) wakes everyone, so whoever can proceed does. The cost is extra wakeups, but the program never hangs.There is no cycle of threads holding locks the others need, so a classic deadlock detector will not report it. Thread dumps show every thread in WAITING (on object monitor). Because the outcome depends on which thread notify() happens to pick, the bug may appear only under load.
When notify() is safe
Use single notification only when both of these hold:
- Uniform waiters: every thread in the wait set waits on the same condition predicate and does the same thing when it wakes, so it does not matter which one is chosen.
- One in, one out: each state change enables at most one waiter to proceed, so waking one is enough.
A pool of identical worker threads waiting for "a task is available", where each submit() adds exactly one task, meets both conditions. A bounded buffer whose producers and consumers share one monitor does not. The safe default is notifyAll(). If profiling later shows the extra wakeups matter, the better fix is usually to give each predicate its own wait set using Condition objects (covered below), rather than gambling on notify().
| Aspect | notify() | notifyAll() |
|---|---|---|
| Threads removed from wait set | One, chosen arbitrarily | All |
| Releases the lock? | No: released when the notifier exits the block | No: same |
| Correct with mixed predicates on one monitor? | No: can wake the wrong kind of thread and hang | Yes, provided waiters loop on their predicate |
| Cost | One context switch | Every waiter wakes, contends for the lock and re-checks |
| Use when | Uniform waiters and one-in-one-out, proven | Default choice |
07A Correct Bounded Buffer
Here is a version that combines the mistakes covered so far. It looks reasonable, it is synchronized, and it never throws IllegalMonitorStateException:
public class BrokenBuffer<T> {
private final Queue<T> items = new ArrayDeque<>();
private final int capacity;
public BrokenBuffer(int capacity) { this.capacity = capacity; }
public synchronized void put(T item) throws InterruptedException {
if (items.size() == capacity) { // BUG 1: if, not while
wait();
}
items.add(item);
notify(); // BUG 2: may wake a producer
}
public synchronized T take() throws InterruptedException {
if (items.isEmpty()) { // BUG 1 again
wait();
}
T item = items.remove(); // NoSuchElementException
notify(); // BUG 2 again
return item;
}
}
// BUG 3: the monitor is 'this' -- any caller can lock it or wait on itBugs 1 and 2 are covered above. Bug 3 is easier to miss. Synchronized methods lock this, and this is public. Any code holding a reference to the buffer can write synchronized (buffer) { ... } and hold it indefinitely, or call buffer.wait() and join your wait set, where it may receive one of your notify() calls and use up that signal. A private lock object makes the wait set an implementation detail that nobody outside the class can reach.
public final class BoundedBuffer<T> {
private final Object lock = new Object(); // private monitor
private final Deque<T> items = new ArrayDeque<>();
private final int capacity;
public BoundedBuffer(int capacity) {
if (capacity <= 0) throw new IllegalArgumentException("capacity");
this.capacity = capacity;
}
public void put(T item) throws InterruptedException {
Objects.requireNonNull(item);
synchronized (lock) {
while (items.size() == capacity) { // guard: not full
lock.wait();
}
items.addLast(item);
lock.notifyAll(); // wake consumers
}
}
public T take() throws InterruptedException {
synchronized (lock) {
while (items.isEmpty()) { // guard: not empty
lock.wait();
}
T item = items.removeFirst();
lock.notifyAll(); // wake producers
return item;
}
}
}Every access to items happens while holding lock, every wait is in a loop over its predicate, every state change is followed by notifyAll() on the same monitor, and nobody outside the class can touch the monitor. Whether notifyAll() is placed before or after the state change inside the block does not affect correctness, because no woken thread can run until the block exits. Placing it after the change is simply easier to read.
You can notify only on the transitions that matter: in put(), only when the buffer goes from empty to non-empty, and in take(), only when it goes from full to not-full. This conditional notification is correct but easy to get wrong when the code changes later. Measure first. In most code the simpler version above is fast enough.
08Interrupts, Timeouts and sleep()
Do not swallow InterruptedException
Interruption is Java's cooperative cancellation signal. When a waiting thread is interrupted, it leaves the wait set, reacquires the lock, clears its interrupt status and throws InterruptedException. An empty catch discards the only record that someone asked this thread to stop.
synchronized (lock) {
while (!ready) {
try {
lock.wait();
} catch (InterruptedException e) {
// ignored: cancellation request lost, loop waits again
}
}
}There are two correct options. The first and simplest is to propagate: declare throws InterruptedException, as put/take above do. If you cannot (for example inside Runnable.run()), restore the flag and stop what you are doing, so code further up the stack can still see the request:
public void run() {
synchronized (lock) {
while (!ready) {
try {
lock.wait();
} catch (InterruptedException e) {
Thread.currentThread().interrupt(); // re-assert
return; // and stop
}
}
doWork();
}
}The JLS also guarantees that notifications are not lost because of interrupts (§17.2.4). If a thread is both notified and interrupted, it either returns normally with its interrupt still pending, or throws InterruptedException. More generally, after a notify() either at least one thread from the wait set returns normally from wait(), or every one of them exits by throwing InterruptedException. An interrupt cannot silently use up the signal.
Timed waits: wait(long)
wait(timeoutMillis) puts the thread in TIMED_WAITING and adds "the timeout elapsed" as a fifth way to leave the wait set. A few details matter:
wait(0)means wait forever, not "don't wait". A negative timeout throwsIllegalArgumentException.waitreturnsvoid, so it does not tell you whether it returned because of a timeout or a notification. You have to work out the remaining time yourself.- After the timeout, the thread still has to reacquire the lock, so
wait(100)can return well after 100 ms on a contended monitor. - Spurious wakeups still apply, so a timed wait also needs a loop, this time with a deadline.
public T poll(long timeout, TimeUnit unit) throws InterruptedException {
long remaining = unit.toNanos(timeout);
final long deadline = System.nanoTime() + remaining;
synchronized (lock) {
while (items.isEmpty()) {
if (remaining <= 0) {
return null; // timed out
}
TimeUnit.NANOSECONDS.timedWait(lock, remaining);
remaining = deadline - System.nanoTime(); // recompute
}
T item = items.removeFirst();
lock.notifyAll();
return item;
}
}TimeUnit.timedWait(obj, timeout) is a convenience wrapper over obj.wait(ms, nanos). Use System.nanoTime() rather than currentTimeMillis() for deadlines: it is monotonic and does not jump when the wall clock is adjusted.
wait() vs Thread.sleep()
Both pause the current thread, so they are often confused, but they do opposite things with locks:
| Property | obj.wait(ms) | Thread.sleep(ms) |
|---|---|---|
| Declared on | Object (instance method) | Thread (static method) |
| Must own a monitor? | Yes, the monitor of obj, or IllegalMonitorStateException | No |
| Releases locks? | Yes: releases obj's monitor (all holds) | No: keeps every lock it holds |
| Woken early by | notify, notifyAll, interrupt, spurious wakeup | Interrupt only |
| Thread state | TIMED_WAITING, then BLOCKED while reacquiring | TIMED_WAITING |
| Purpose | Wait for a condition another thread will make true | Wait for a duration |
Calling Thread.sleep() inside a synchronized block makes every other thread that needs the lock wait for the whole sleep. If you see sleep-polling a flag in a loop, that is usually a wait()/notifyAll() or a CountDownLatch waiting to be written.
09Lock, Condition and Beyond
Since Java 5, java.util.concurrent.locks offers an explicit version of the same idea. A Lock plays the role of the monitor's lock, and a Condition plays the role of a wait set. The difference is that one lock can have as many conditions as you need.
| Intrinsic monitor | Lock + Condition |
|---|---|
synchronized (lock) { ... } | lock.lock(); try { ... } finally { lock.unlock(); } |
lock.wait() | cond.await() |
lock.wait(ms) | cond.await(time, unit) / cond.awaitNanos(n) (returns time remaining) |
lock.notify() | cond.signal() |
lock.notifyAll() | cond.signalAll() |
| One wait set per object | Any number of conditions per lock |
The rule from this article carries over. With ReentrantLock, calling await(), signal() or signalAll() without holding the lock throws IllegalMonitorStateException, for the same lost-wakeup reasons. The Condition interface allows implementations to impose this requirement, and ReentrantLock's conditions do. Spurious wakeups are allowed too, so the while loop stays.
public final class ConditionBuffer<T> {
private final ReentrantLock lock = new ReentrantLock();
private final Condition notFull = lock.newCondition();
private final Condition notEmpty = lock.newCondition();
private final Deque<T> items = new ArrayDeque<>();
private final int capacity;
public ConditionBuffer(int capacity) { this.capacity = capacity; }
public void put(T item) throws InterruptedException {
lock.lock();
try {
while (items.size() == capacity) {
notFull.await(); // producers wait here
}
items.addLast(item);
notEmpty.signal(); // wake exactly one consumer
} finally {
lock.unlock();
}
}
public T take() throws InterruptedException {
lock.lock();
try {
while (items.isEmpty()) {
notEmpty.await(); // consumers wait here
}
T item = items.removeFirst();
notFull.signal(); // wake exactly one producer
return item;
} finally {
lock.unlock();
}
}
}Separate conditions fix the "wrong kind of waiter" problem structurally. Only producers ever wait on notFull and only consumers on notEmpty, so each wait set is uniform, and each put or take enables exactly one thread of the other kind. That meets both conditions for single notification, so signal() is safe here where notify() was not. ArrayBlockingQueue in the JDK uses this same two-condition design.
One lock, two conditions: separate wait sets
notFull and consumers on notEmpty, all guarded by one ReentrantLock. A take() signals only producers and a put() signals only consumers, so a signal can never be wasted on a thread that cannot proceed.A Condition is also an ordinary Object, so notFull.wait() and notEmpty.notify() compile. They use the condition object's intrinsic monitor, which you do not own, so they throw IllegalMonitorStateException. Even inside a synchronized (notFull) block they would bypass the Lock entirely. The methods you want are await and signal.
In practice: use java.util.concurrent
Hand-written wait/notify code is mostly found in libraries, older codebases and interviews. For application code, the JDK already ships well-tested versions of the patterns above, and they handle all of the issues covered here internally:
BlockingQueue<Order> queue = new ArrayBlockingQueue<>(100);
queue.put(order); // blocks while full
Order next = queue.take(); // blocks while empty
Order maybe = queue.poll(2, TimeUnit.SECONDS); // timed
CountDownLatch ready = new CountDownLatch(3);
ready.countDown(); // each worker, when initialised
ready.await(); // coordinator: wait for all three
Semaphore permits = new Semaphore(10);
permits.acquire(); // at most 10 concurrent holders
try { callDownstream(); } finally { permits.release(); }LockSupport: the primitive underneath
LockSupport.park() and unpark(thread) are the low-level building blocks that ReentrantLock, Condition and most of java.util.concurrent are built on. They do not need a lock. Instead of a wait set they use a per-thread permit: unpark makes the permit available, and park consumes it, returning immediately if it is already available. So an unpark that arrives before the park is not lost, which is exactly the lost-wakeup case that notify() gets wrong. The permit holds at most one, though: two unparks before a park leave a single permit. park may also return spuriously. Code using it still loops on a predicate kept in volatile or atomic state, and it is rarely the right tool outside library code.
| Mechanism | Must hold a lock? | Signal before wait | Wait sets | Use it for |
|---|---|---|---|---|
wait/notify | Yes: that object's monitor | Lost (no memory) | One per object | Legacy code, interviews, simple monitors |
Condition | Yes: the owning Lock | Lost (no memory) | Many per lock | Custom synchronizers with several predicates |
LockSupport | No | Remembered (one permit) | None: per-thread permit | Building synchronizers |
BlockingQueue, latches, semaphores | No (handled internally) | State is remembered | Internal | Almost all application code |
On JDK 21–23, a virtual thread that blocks inside synchronized, including in Object.wait(), pins its carrier platform thread. That was a common reason to prefer ReentrantLock in code using virtual threads. JDK 24 (JEP 491) removed most of this pinning for synchronized and Object.wait(). The rules in this article are the same either way.
10Why wait() Doesn't Lock Itself
A reasonable question after all this: if wait() needs the lock, why does the JVM throw an exception instead of quietly acquiring the lock inside wait()? Because that would not help. The lock has to protect your condition check, and the check is in your code, before the call:
If wait() locked internally, the earlier code would effectively become if (queue.isEmpty()) { /* gap */ lockAndWait(); }. That is exactly the "trap that compiles" from the lost-wakeup section: the producer can add and notify inside the gap. The check would be unprotected, and the hang would still happen.
Requiring the caller to own the monitor means the critical section can cover the predicate check, the decision to wait, the atomic release-and-enqueue, the re-check after waking, and the action that follows, as one unit. Only the caller knows where that unit begins, so only the caller can take the lock.
The same reasoning applies on the notify side. A notification only means something if it is tied to a state change that waiters can observe. Requiring the notifier to own the monitor forces the state change and the signal into a critical section that cannot interleave with a waiter's check-and-wait, and the lock's happens-before edge makes the change visible to the waiter that wakes up.
So the IllegalMonitorStateException is a cheap, partial check. The JVM cannot prove that your predicate was checked under the lock (that is your job), but it can at least refuse a wait or notify that clearly is not part of any critical section on that object. Monitors in other languages follow the same pattern: POSIX pthread_cond_wait takes the mutex as an argument and requires the caller to have locked it, for the same reason.
11Interview Questions
Why must wait() and notify() be called inside a synchronized block?
Because the condition check and the decision to wait must be atomic with respect to the state change and the notification. Without a shared lock, a producer can change state and notify in the gap between a consumer's "is it empty?" check and its wait(). Since notify() on an empty wait set is a no-op, the consumer then sleeps forever (a lost wakeup). The lock also provides the happens-before edge that makes the producer's writes visible. The JVM enforces ownership at runtime with IllegalMonitorStateException.
Does notify() release the lock? When does the woken thread run?
No. notify() only moves a thread out of the wait set. The notifier keeps the monitor until it exits its synchronized block (or itself calls wait()). The woken thread goes from WAITING to BLOCKED, competes for the lock with everyone else, and returns from wait() only after it has reacquired the lock with its original hold count.
What is the difference between BLOCKED and WAITING?
WAITING means the thread is in a wait set (via wait(), join() or park()) and is waiting for a signal. It holds no claim on the monitor and will not run until it is removed from the wait set. BLOCKED means the thread is waiting to acquire a monitor, either to enter synchronized or to re-enter it after wait(). A notified thread always passes through BLOCKED before it is RUNNABLE again.
Why must the condition be checked in a while loop?
Returning from wait() does not mean the predicate is true. Spurious wakeups are allowed by the spec. Another thread can consume the state between the notification and the reacquisition (a stolen wakeup). And notifyAll() or a shared wait set can wake threads whose condition is still false. A while loop re-checks under the lock every time, so the code after the loop can rely on the predicate.
When is notify() safe to use instead of notifyAll()?
Only when all waiters are waiting on the same predicate and behave the same when woken (uniform waiters), and each notification can enable at most one of them (one in, one out). If producers and consumers share a monitor, notify() can wake the wrong kind of thread, use up the only useful signal, and leave every thread waiting. Default to notifyAll(), or use separate Conditions so each wait set is uniform.
Why are wait() and notify() on Object and not on Thread?
Because what you wait on is a shared object's state, not a particular thread. Every object has a monitor (lock plus wait set), and the wait set belongs to the object being coordinated on, which is the same object whose lock guards the state. The waiter does not need to know which thread will make the condition true, only which object's monitor to wait on.
Can a notification be lost if the waiting thread is interrupted?
No. The JLS (§17.2.4) guarantees that if threads are waiting and one is notified, either at least one returns normally from wait(), or all of them exit by throwing InterruptedException. A thread that is both notified and interrupted either returns normally with its interrupt still pending, or throws, in which case the notification goes to another waiter. What can go wrong is application code that swallows the InterruptedException.
What happens to other locks a thread holds when it calls wait()?
It keeps them. wait() releases only the monitor of the object it is called on, including all reentrant holds on that monitor. Any other monitors or Locks stay held while the thread sleeps. If the thread that would notify it needs one of those locks, neither can make progress (nested monitor lockout). Avoid waiting while holding unrelated locks.
Key takeaways
wait,notifyandnotifyAllrequire the caller to own the monitor of the same object. Otherwise the JVM throwsIllegalMonitorStateExceptionat runtime.- The real reason is the lost wakeup: the predicate check and the wait must be atomic with respect to the state change and the notify. The
synchronizedblock must cover the check, not just the call. wait()atomically releases all holds on the monitor and joins the wait set. After waking it must reacquire the lock (BLOCKED) before returning.notify()never releases the lock.- Always
while (!predicate) lock.wait();. Spurious, stolen and broadcast wakeups all mean that waking up proves nothing about the predicate. - Prefer
notifyAll().notify()is only safe with uniform waiters and one-in-one-out state changes. SeparateConditions make single signals safe. - Wait on a private final lock object, propagate or restore interrupts, and use deadline loops for timed waits.
- In application code, reach for
BlockingQueue,CountDownLatchorSemaphorefirst. Hand-rolled wait/notify is for libraries and for understanding how those classes work.