Java / Concurrency deep dive

Why wait() and notify() Must Be Called Inside synchronized

The lost-wakeup race that forces the rule, what the monitor’s entry set and wait set really do, why wait() must live in a while loop, notify vs notifyAll — and how Lock/Condition inherits the same contract.

On this page
  1. The Rule and Its Exception
  2. Anatomy of a Monitor
  3. The Lost Wakeup Race
  4. What wait() Actually Does
  5. Why while, Never if
  6. notify() vs notifyAll()
  7. A Correct Bounded Buffer
  8. Interrupts, Timeouts and sleep()
  9. Lock, Condition and Beyond
  10. Why wait() Doesn't Lock Itself
  11. Interview Questions

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 while loop, and choose correctly between notify() and notifyAll().
  • Write a correct bounded buffer three ways: with intrinsic locks, with Lock + Condition, and with BlockingQueue.
  • 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.

Compiles fine, fails at runtime
Object lock = new Object();

lock.wait();
// java.lang.IllegalMonitorStateException: current thread is not owner

lock.notify();
// same exception: notify() is checked exactly the same way

The 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:

Ownership can come from a caller
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":

Synchronized on the wrong object
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().

Where this fits

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 synchronized block on this object while someone else owns it. Their state is BLOCKED.
  • Owner: at most one thread, the one currently running inside a synchronized block 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 is WAITING (or TIMED_WAITING for wait(timeout)). They hold no claim on the lock at all.
Fig. 1

Anatomy of a Java monitor: entry set, owner, wait set

Anatomy of a Java monitor: entry set, owner, wait setEvery object has one monitor. Threads trying to enter synchronized wait BLOCKED in the entry set; exactly one thread owns the monitor; threads that called wait() sit WAITING in the wait set. notify() moves a waiter back to compete for the lock, and only leaving the synchronized block releases ownership.Monitor of object lockacquirewait()notify() / timeout / interrupt → re-compete for the lockexit block → monitor releasedNew threadsynchronized (lock)Entry setBLOCKED threads1Ownerexactly one threadruns the body2Wait setWAITING threads3wait() releases the lock · notify() does not · exiting synchronized doesT2 · T3T1T4 · T5next owner picked from the entry set
One object, three areas. Threads queue BLOCKED in the entry set, one thread owns the monitor, and threads that called 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:

Hypothetical: check and wait with no lock
// 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) signal

Now play one unlucky but perfectly legal interleaving. The scheduler can preempt a thread between any two operations, and nothing here forbids this one:

StepConsumerProducerQueueWait set
1isEmpty() returns truenot yet runningemptyempty
2preempted before calling wait()scheduledemptyempty
3still preemptedadd(item)1 itemempty
4still preemptednotify(): nobody waiting, no effect1 itemempty
5resumes, calls wait()done, moves on1 itemconsumer
6WAITING forevernever notifies again1 itemconsumer
Fig. 2

The lost wakeup: check-then-wait without a lock

The lost wakeup: check-then-wait without a lockHypothetical unsynchronized code. The consumer checks isEmpty() and sees true, but is preempted before calling wait(). The producer adds an item and calls notify() while nobody is waiting, so the notification is lost. The consumer then calls wait() and sleeps forever with an item sitting in the queue.Race window — no lock held1queue.isEmpty()2true — nothing to take3queue.add(item)4notify() — wait set is empty5wait()ConsumerShared queueProducerConsumer sleeps foreverthe item sits in the queue; nobody will notify againpreempted before wait()signal lost
The signal falls into the gap. The consumer's check (steps 1–2) and its 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.

The guarded-wait idiom
// 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).

Fig. 3

The same race, made impossible by the monitor

The same race, made impossible by the monitorThe consumer enters synchronized, checks isEmpty() and calls wait() while still holding the lock; wait() releases the lock and joins the wait set in one atomic step. The producer was BLOCKED the whole time, so its add() and notify() can only happen after the consumer is already waiting. The consumer then reacquires the lock, re-checks, and takes the item.Consumer owns the lock: check + wait are atomic1enter synchronized(lock)2isEmpty() → true3enter synchronized → BLOCKED4wait(): release + join wait set5lock granted6add(item); notify()7exit block → release8reacquire, re-check → itemConsumerMonitor of lockProducerConsumer takes the itemno window existed for the signal to fall into
Same threads, no window. While the consumer owns the monitor (shaded band), the producer is BLOCKED at step 3. The consumer's 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 trap that compiles and runs

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:

Lock held for the wait, not for the check
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):

  1. Ownership check. If n is zero, throw IllegalMonitorStateException. If the thread is already interrupted, throw InterruptedException instead of waiting.
  2. 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 (or TIMED_WAITING).
  3. 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.
  4. 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 enter synchronized.
  5. 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 your catch or finally code 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.

Fig. 4

Thread states and lock ownership across one wait/notify cycle

Thread states and lock ownership across one wait/notify cycleTimeline. The consumer is RUNNABLE and owns the lock, calls wait() and becomes WAITING holding nothing. The producer, which was BLOCKED, gets the lock, calls notify(); the consumer becomes BLOCKED, not RUNNABLE, because the producer still owns the lock until it exits the block. Only then does the consumer reacquire the lock and return from wait().RUNNABLEconsumerBLOCKEDWAITING (wait set)RUNNABLEproducerBLOCKEDRUNNABLEconsumerfreeConsumerProducerLock ownernotified, but still BLOCKEDtime →wait()producer entersnotify()exit block
Notified is not running. The consumer is WAITING and holds nothing after 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.

Nested monitor lockout
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.wait Javadoc 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:

Fig. 5

The stolen wakeup: why the guard must be a while loop

The stolen wakeup: why the guard must be a while loopConsumer A waits on an empty queue. The producer adds one item and calls notify(), moving A to the entry set. Before A reacquires the lock, Consumer B enters, sees the item and takes it. When A finally returns from wait() the queue is empty again: with if it proceeds and fails, with while it re-checks and waits again.Guard written with ifGuard written with while1empty → wait()2add(item); notify()3B enters first, polls item4gets the item5A reacquires lockConsumer AQueue + monitorConsumer BProducerProceeds without re-checkingpoll() returns null → NPE / corrupt stateRe-checks the conditionstill empty → wait() again, safelyA: WAITING → BLOCKEDqueue is empty again
The notification was real, but the item is gone. The producer wakes Consumer A, but A has to reacquire the lock first. Consumer B, which never waited, gets the lock first and takes the item. When A finally returns from wait(), the queue is empty again. An if guard proceeds anyway, while a while guard re-checks and goes back to sleep.
if: trusts the wakeup

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: trusts only the predicate

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:

The canonical 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:

  1. Consumers C1 and C2 call take() on the empty buffer and both wait.
  2. Producer P1 puts an item and calls notify(), which happens to wake C1. C1 is now BLOCKED, waiting for the lock.
  3. 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}.
  4. C1 gets the lock, takes the item (buffer now empty) and calls notify(). The JVM picks C2, another consumer.
  5. C1 loops to take again: empty, so it waits. C2 gets the lock: empty, so it waits.
  6. Wait set: {P1, P2, C1, C2}. Every thread is waiting and nobody is left to notify.
Fig. 6

notify() can wake the wrong kind of waiter

notify() can wake the wrong kind of waiterA bounded buffer of capacity 1 shares one wait set between producers and consumers. Consumer C1 takes the last item and calls notify(). On the left, notify() wakes consumer C2, which finds the buffer empty and waits again; producers P1 and P2 are never woken and every thread ends up waiting. On the right, notifyAll() wakes everyone: a producer refills the buffer and the extra consumer simply waits again.One shared wait setOne shared wait setwakes C2 (arbitrary)wakes allC1: take() → buffer now emptythen calls notify() on the shared monitorP1producerP2producerC2consumerEverybody waitsC2 sees empty → wait() again.P1, P2 never hear about the free slot.The program hangs without a deadlock cycle.C1: take() → buffer now emptythen calls notifyAll() on the shared monitorP1producerP2producerC2consumerProgress guaranteedP1 re-checks: slot free → put().C2 and P2 re-check and wait again.Cost: extra wakeups, never a hang.notify()notifyAll()
A wakeup delivered to the wrong kind of thread. With one shared wait set, 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().

Aspectnotify()notifyAll()
Threads removed from wait setOne, chosen arbitrarilyAll
Releases the lock?No: released when the notifier exits the blockNo: same
Correct with mixed predicates on one monitor?No: can wake the wrong kind of thread and hangYes, provided waiters loop on their predicate
CostOne context switchEvery waiter wakes, contends for the lock and re-checks
Use whenUniform waiters and one-in-one-out, provenDefault 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:

BrokenBuffer.java
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 it

Bugs 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.

BoundedBuffer.java
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.

A small optimisation, if you need it

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.

Swallowed interrupt
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:

Restore the interrupt status
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 throws IllegalArgumentException.
  • wait returns void, 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.
Timed guarded wait 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:

Propertyobj.wait(ms)Thread.sleep(ms)
Declared onObject (instance method)Thread (static method)
Must own a monitor?Yes, the monitor of obj, or IllegalMonitorStateExceptionNo
Releases locks?Yes: releases obj's monitor (all holds)No: keeps every lock it holds
Woken early bynotify, notifyAll, interrupt, spurious wakeupInterrupt only
Thread stateTIMED_WAITING, then BLOCKED while reacquiringTIMED_WAITING
PurposeWait for a condition another thread will make trueWait 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 monitorLock + 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 objectAny 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.

ConditionBuffer.java
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.

Fig. 7

One lock, two conditions: separate wait sets

One lock, two conditions: separate wait setsA ReentrantLock guards the bounded buffer. Producers that find it full await on the notFull condition; consumers that find it empty await on notEmpty. take() signals notFull and put() signals notEmpty, so a signal always reaches a thread that can make progress.notFullnotEmptyguardstake() → notFull.signal()put() → notEmpty.signal()ReentrantLockone lock · one ownerBounded bufferitems[] · countP1notFull.await()P2notFull.await()C1notEmpty.await()C2notEmpty.await()producers wait while fullconsumers wait while emptyeach signal reaches a thread that can actually make progressonly producers hereonly consumers here
Two predicates, two wait sets. Producers park on 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.
Classic mix-up: wait() on a Condition

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:

What you should usually write
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.

MechanismMust hold a lock?Signal before waitWait setsUse it for
wait/notifyYes: that object's monitorLost (no memory)One per objectLegacy code, interviews, simple monitors
ConditionYes: the owning LockLost (no memory)Many per lockCustom synchronizers with several predicates
LockSupportNoRemembered (one permit)None: per-thread permitBuilding synchronizers
BlockingQueue, latches, semaphoresNo (handled internally)State is rememberedInternalAlmost all application code
Virtual threads

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:

Lock taken inside wait()

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.

Lock owned by the caller

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

  1. wait, notify and notifyAll require the caller to own the monitor of the same object. Otherwise the JVM throws IllegalMonitorStateException at runtime.
  2. 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 synchronized block must cover the check, not just the call.
  3. 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.
  4. Always while (!predicate) lock.wait();. Spurious, stolen and broadcast wakeups all mean that waking up proves nothing about the predicate.
  5. Prefer notifyAll(). notify() is only safe with uniform waiters and one-in-one-out state changes. Separate Conditions make single signals safe.
  6. Wait on a private final lock object, propagate or restore interrupts, and use deadline loops for timed waits.
  7. In application code, reach for BlockingQueue, CountDownLatch or Semaphore first. Hand-rolled wait/notify is for libraries and for understanding how those classes work.