Skip to content

Wait and Notify in Java: The Rules That Stop the Hangs

wait() and notify() hang or corrupt data when the guard, the lock or the wakeup is wrong. Lost signals, stolen wakeups and notify vs notifyAll, run on JDK 21.

java-concurrency java-threads thread-safety
Gopi Gorantala
Reading Progress

On This Page

if (count == 0) wait();

That's the line. I've seen it in code review more times than I can count, usually in a hand-rolled queue somebody wrote because "we didn't want another dependency." It reads like English: if there's nothing to take, wait. It's also wrong, and the way it's wrong is worth understanding properly, because the same misunderstanding produces three other bugs that look nothing like it.

Here's what two consumers sharing that line do when a producer adds one item and calls notifyAll():

consumer-0 took one, count=0
consumer-1 took one, count=-1
if    guard: final count=-1, states=[TERMINATED, TERMINATED]

Count of minus one. Two consumers took one item. No exception, nothing in the logs, and both threads finished happily. This is the first of four small programs below, and wait and notify in Java is the topic of all of them. Run on OpenJDK 21.0.12.1, 2 vCPU x86_64 Linux. This one is deterministic, not a race: it came out the same every time.

The line in question

I'd been carrying a wrong picture for years. I thought wait() meant "sleep until somebody tells me the condition is true." It doesn't. It means "sleep until somebody tells me something changed, and then compete for the lock again." Whether the condition is true at that point is your problem. Everything else in this article follows from that one sentence.

What the monitor promises, in plain words

Every Java object has a monitor, the thing synchronized locks. Every monitor also has a wait set: a list of threads that called wait() on that object and are parked until somebody wakes them. Three rules, and they're in the Object Javadoc for JDK 21:

  1. You can only call wait(), notify() or notifyAll() while holding that object's monitor. Otherwise you get IllegalMonitorStateException.
  2. wait() releases that monitor (only that one; any other locks you hold stay held) and parks you. When you're woken, you don't run. You queue up for the monitor like anybody else, and only return from wait() once you've got it back.
  3. notify() picks one waiting thread, and the Javadoc says plainly that the choice is arbitrary. notifyAll() wakes all of them.

Notice what isn't in the list: any link between the signal and your condition. A notify is not a message. It carries no data. It's a nudge, and the wait set is shared by everyone who ever waited on that object, for any reason.

Four small programs, three of them hang or lie

One file, Lab.java, modes picked by argument. Pinned environment: OpenJDK 21.0.12.1 (Ubuntu build), 2 vCPU, x86_64, no preview flags. I haven't run any of this on 22+, 25, or ARM. Get the JDK however you like (sdk install java 21.0.12-tem or eclipse-temurin:21-jdk) and check java -version.

import java.util.*;
import java.util.concurrent.*;

public class Lab {
    static final Object lock = new Object();

    public static void main(String[] a) throws Exception {
        switch (a[0]) {
            case "ime" -> ime();
            case "lost" -> lost();
            case "states" -> states();
            case "stolen" -> stolen(a[1].equals("while"));
            case "hang" -> hang(a[1].equals("notifyAll"), Integer.parseInt(a[2]));
            case "vt" -> vt();
        }
    }

    static void ime() throws Exception {
        try { lock.wait(); } catch (IllegalMonitorStateException e) { System.out.println("wait:   " + e); }
        try { lock.notify(); } catch (IllegalMonitorStateException e) { System.out.println("notify: " + e); }
    }

    static boolean ready;

    static void lost() throws Exception {
        Thread waiter = new Thread(() -> {
            try {
                Thread.sleep(300); // waiter is slow to start
                synchronized (lock) { lock.wait(); System.out.println("waiter woke"); }
            } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        }, "waiter");
        waiter.setDaemon(true);
        waiter.start();
        synchronized (lock) { lock.notify(); } // nobody is waiting yet: this signal evaporates
        waiter.join(1500);
        System.out.println("after 1.5s waiter state = " + waiter.getState());
        for (StackTraceElement e : waiter.getStackTrace()) System.out.println("    at " + e);
        // fixed: condition + guard
        ready = false;
        Thread w2 = new Thread(() -> {
            try {
                Thread.sleep(300);
                synchronized (lock) { while (!ready) lock.wait(); System.out.println("w2 woke (ready was already true)"); }
            } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        }, "waiter2");
        w2.start();
        synchronized (lock) { ready = true; lock.notifyAll(); }
        w2.join(1500);
        System.out.println("w2 state = " + w2.getState());
    }

    static void states() throws Exception {
        Thread w = new Thread(() -> {
            try { synchronized (lock) { lock.wait(); } } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        }, "waiter");
        w.start();
        Thread.sleep(200);
        System.out.println("before notify          : " + w.getState());
        synchronized (lock) {
            lock.notify();
            Thread.sleep(300);
            System.out.println("notified, lock still held: " + w.getState());
        }
        w.join();
        System.out.println("after join             : " + w.getState());
    }

    static int count = 0;
    static void stolen(boolean useWhile) throws Exception {
        count = 0;
        CountDownLatch bothWaiting = new CountDownLatch(2);
        List<Thread> cs = new ArrayList<>();
        for (int i = 0; i < 2; i++) {
            Thread c = new Thread(() -> {
                try {
                    synchronized (lock) {
                        bothWaiting.countDown();
                        if (useWhile) { while (count == 0) lock.wait(); }
                        else { if (count == 0) lock.wait(); }
                        count--;
                        System.out.println(Thread.currentThread().getName() + " took one, count=" + count);
                    }
                } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            }, "consumer-" + i);
            c.setDaemon(true);
            cs.add(c); c.start();
        }
        bothWaiting.await();
        Thread.sleep(200);
        synchronized (lock) { count = 1; lock.notifyAll(); }
        Thread.sleep(500);
        System.out.println((useWhile ? "while" : "if   ") + " guard: final count=" + count + ", states=" +
            cs.stream().map(t -> t.getState().toString()).toList());
    }

    // bounded buffer, capacity 1, one monitor for both sides
    static class Buf {
        int items = 0; final int cap = 1; final boolean all;
        Buf(boolean all) { this.all = all; }
        synchronized void put() throws InterruptedException {
            while (items == cap) wait();
            items++; if (all) notifyAll(); else notify();
        }
        synchronized void take() throws InterruptedException {
            while (items == 0) wait();
            items--; if (all) notifyAll(); else notify();
        }
    }

    static void hang(boolean all, int runs) throws Exception {
        int hung = 0;
        for (int r = 0; r < runs; r++) {
            Buf b = new Buf(all);
            List<Thread> ts = new ArrayList<>();
            int per = 20000;
            for (int i = 0; i < 2; i++) {
                ts.add(new Thread(() -> { try { for (int k = 0; k < per; k++) b.put(); } catch (InterruptedException e) {} }, "producer-" + i));
                ts.add(new Thread(() -> { try { for (int k = 0; k < per; k++) b.take(); } catch (InterruptedException e) {} }, "consumer-" + i));
            }
            for (Thread t : ts) { t.setDaemon(true); t.start(); }
            long end = System.nanoTime() + 5_000_000_000L;
            for (Thread t : ts) t.join(Math.max(1, (end - System.nanoTime()) / 1_000_000));
            boolean stuck = ts.stream().anyMatch(Thread::isAlive);
            if (stuck) {
                hung++;
                if (hung == 1) System.out.println("  first hang, states: " + ts.stream().map(t -> t.getName() + "=" + t.getState()).toList() + " items=" + b.items);
                ts.forEach(Thread::interrupt);
            }
        }
        System.out.println((all ? "notifyAll" : "notify   ") + ": " + hung + " of " + runs + " runs hung");
    }

    static void vt() throws Exception {
        Object m = new Object();
        boolean[] flag = {false};
        Thread a = Thread.ofVirtual().name("vt-waiter").start(() -> {
            try { synchronized (m) { while (!flag[0]) m.wait(); } System.out.println("vt-waiter woke"); }
            catch (InterruptedException e) { Thread.currentThread().interrupt(); }
        });
        Thread.sleep(200);
        Thread b = Thread.ofVirtual().name("vt-notifier").start(() -> {
            synchronized (m) { flag[0] = true; m.notifyAll(); System.out.println("vt-notifier ran"); }
        });
        a.join(2000); b.join(2000);
        System.out.println("waiter alive=" + a.isAlive() + " notifier alive=" + b.isAlive());
    }
}

Step 1: the exception. java Lab.java ime

wait:   java.lang.IllegalMonitorStateException: current thread is not owner
notify: java.lang.IllegalMonitorStateException: current thread is not owner

Boring, and the one everybody meets first. The message says "not owner", meaning of the monitor, not of the object.

Step 2: the lost signal. java Lab.java lost

after 1.5s waiter state = WAITING
    at java.base/java.lang.Object.wait0(Native Method)
    at java.base/java.lang.Object.wait(Object.java:366)
    at java.base/java.lang.Object.wait(Object.java:339)
    at Lab.lambda$lost$0(Lab.java:29)
    at java.base/java.lang.Thread.run(Thread.java:1583)
waiter woke
w2 woke (ready was already true)
w2 state = TERMINATED

The notifier fired before the waiter got to wait(). Nobody was in the wait set, so the signal evaporated. The waiter then waits forever. Deterministic here only because I put a 300 ms sleep in front of the wait; in real code that gap is a few microseconds and you hit it one run in a few thousand, which is how it ends up in production.

I lost a while to the line waiter woke. It prints after the 1.5 s report, and I first read it as the first waiter waking up on its own. It didn't. The second half of the lab uses the same lock object, and its notifyAll() reached into the shared wait set and woke the first waiter, which had been stuck for two seconds. That's a small demonstration of a point I'll come back to: the wait set doesn't care why you were waiting.

The fix is the second half of the output: keep the fact in a field (ready), set it under the lock, and check it before waiting. w2 arrived late, saw ready == true, never called wait() at all, and finished.

Step 3: the woken thread isn't running yet. java Lab.java states

before notify          : WAITING
notified, lock still held: BLOCKED
after join             : TERMINATED

I expected RUNNABLE in the middle line. It's BLOCKED. The waiter has been woken and is queued for a monitor the notifier still owns. If you read thread dumps, that's the answer to "why are my threads BLOCKED on the monitor I just notified on": you're holding it.

Step 4: the stolen wakeup. java Lab.java stolen if then java Lab.java stolen while

consumer-0 took one, count=0
consumer-1 took one, count=-1
if    guard: final count=-1, states=[TERMINATED, TERMINATED]

consumer-0 took one, count=0
while guard: final count=0, states=[TERMINATED, WAITING]

Two consumers are parked. The producer sets count = 1 and calls notifyAll(). Both wake, both queue for the lock. The first gets it and takes the item. The second gets it next and, with an if, goes straight past the check it already did an eternity ago. With a while, it re-checks, sees zero, and goes back to sleep, which is exactly what you want.

Nothing spurious happened. No weird hardware, no JVM quirk. The wait() returned for a perfectly legitimate reason, and the condition was still false by the time this thread got to run.

Step 5: notify against notifyAll. java Lab.java hang notify 20 then java Lab.java hang notifyAll 20

  first hang, states: [producer-0=WAITING, consumer-0=WAITING, producer-1=WAITING, consumer-1=WAITING] items=0
notify   : 20 of 20 runs hung
notifyAll: 0 of 20 runs hung

A capacity-one buffer, two producers, two consumers, one monitor, each side moving 20,000 items. Every run with notify() hung inside the 5 second limit on this machine. Every run with notifyAll() finished. In the hung case all four threads are WAITING, the buffer is empty, and the stuck dump looks like this (from jcmd <pid> Thread.print):

"producer-0" #19 [755] daemon prio=5 os_prio=0 cpu=0.54ms elapsed=3.75s tid=0x00007f6254a72c90 nid=755 in Object.wait()  [0x00007f6253ffe000]
   java.lang.Thread.State: WAITING (on object monitor)
	at java.lang.Object.wait0(java.base@21.0.12.1/Native Method)
	- waiting on <0x00000000a7934418> (a Lab$Buf)
	at java.lang.Object.wait(java.base@21.0.12.1/Object.java:366)
	at java.lang.Object.wait(java.base@21.0.12.1/Object.java:339)
	at Lab$Buf.put(Lab.java:101)

All four showing the same waiting on <0x...> address is the tell. Same monitor, nobody holding it, nobody left to wake anyone.

How often you'll see this depends on the workload. Capacity one with equal producers and consumers is about the cruellest setup there is, and I picked it on purpose. Bigger buffers hang less often and much more confusingly. A hung notify() isn't the same as "20 of 20" in your system. It's "once a fortnight, on the night of the batch run."

If you see nothing on your machine, widen the window: more items, a smaller buffer, or -XX:ActiveProcessorCount=1 vs 8. And a hang run takes the full 5 seconds before it's declared hung; I tried 20 runs under taskset -c 0 and that blew my tool's time limit, so I have no single-core number.

Cleanup: these are daemon threads, so they die with the JVM. Run jps and kill anything left over by PID.

Who holds the lock while you wait

Walk the hang. Capacity one. A consumer takes the last item, so items == 0, and calls notify(). The wait set at that moment holds a mix: maybe producer-1 and consumer-1. The JVM picks one, and the Javadoc is clear it may pick either. Say it picks consumer-1. consumer-1 wakes, gets the lock, checks items == 0, and sits down again. The producer that could have used the signal is still asleep, and there won't be a second signal, because the thread that would send it is the one that's waiting.

Thread by thread:

state: items=0, wait set = {producer-1, consumer-1}
consumer-0: take() succeeds, items 1 -> 0, notify()
JVM wakes consumer-1 (arbitrary choice)
consumer-1: reacquires lock, while (items == 0) -> true, wait() again
state: items=0, wait set = {producer-1, consumer-1}, nobody running

That's the whole bug. A single wait set holding two kinds of waiter, and a signal that can only reach one of them. notifyAll() fixes it by waking everyone and letting each thread check whether it's their turn, which costs wake-ups but never loses a signal.

The memory side is boring on purpose. wait() releases the monitor and later reacquires it, and the JLS describes both in terms of ordinary monitor unlock and lock actions, which is why anything one thread wrote before releasing is visible to the thread that gets the lock next. The Object Javadoc itself says nothing about happens-before. If you want the rules that make that work, they're in the memory model article. In practice: change the shared state and call notify inside the same synchronized block, and you don't have a visibility problem.

Object.wait in the JDK, and where virtual threads change the story

The stack in the dump above is the whole public story: Object.wait(long) delegates to Object.wait0, a native method, and the interesting machinery lives in HotSpot, not in Java code. That's the boundary for this series. If you want to know how the JVM represents the wait set and where it keeps the lock state, that belongs with the JVM internals material, not here.

What does matter to Java developers is the virtual-thread interaction, and I checked it on 21. Lab.java vt starts a virtual thread that calls wait() and a second one that notifies. With default settings:

vt-notifier ran
vt-waiter woke
waiter alive=false notifier alive=false

Fine. Now cut the scheduler down to a single carrier thread: java -Djdk.virtualThreadScheduler.parallelism=1 -Djdk.virtualThreadScheduler.maxPoolSize=1 Lab.java vt.

waiter alive=true notifier alive=true

The notifier never even printed. On 21, a virtual thread waiting inside Object.wait() while it holds a monitor stays mounted on its carrier. With only one carrier available, the notifier had nowhere to run, so the waiter blocked the one thing that could wake it. That's pinning, and it's the same pinning story as synchronized, covered in the virtual thread pinning article. JEP 491 is the change aimed at it. I haven't run this on JDK 24 or 25, so retest this exact lab after an upgrade before you rely on it.

Version history that matters here is short: the semantics of wait/notify haven't changed since Java 5 formalised the memory model, and java.util.concurrent has been available since then as a better option. I don't have a version-by-version table because nothing moves, except the pinning behaviour.

Where I'd flag it in review

An if around wait(). Stolen wakeup, shown above. The Javadoc also mentions spurious wakeups, where a thread returns from wait() with nobody having notified. The Javadoc says it "will rarely occur in practice". I didn't try to reproduce one here, and I wouldn't spend an afternoon trying: stolen wakeups are common, reproducible and enough to justify the loop.

notify() on a monitor with two kinds of waiter. Producers and consumers, readers and writers, anything with two predicates. It works for weeks and then hangs. If every waiter on the monitor waits for the same condition, and any one of them can do the next step, notify() is allowed. Otherwise, use notifyAll().

Notify before the condition is stored. Signal then set the flag, or set the flag outside the lock. The waiter may check, see the old value, and sleep after the notify has gone past.

No state variable at all. Using wait/notify as an event with no field behind it, like the lost-signal lab. A notify is not remembered.

catch (InterruptedException e) {} around wait. Swallowing the interrupt turns a cancellable wait into one that loops forever, since the while re-enters wait(). Restore the flag or rethrow; see the interrupt article. And note that wait() doesn't throw until it has got the lock back, per the Javadoc.

Waiting on a lock object that changes. synchronized (this.lock) where lock gets reassigned, or wait() on a boxed Integer or a string literal. Different threads end up with different monitors. A notify then reaches nobody.

Long work under the monitor. Not a wait/notify bug, but you notify and then spend 50 ms holding the lock. Everyone you woke is BLOCKED until you leave, as step 3 showed.

Writing it so it can't lose a signal, and when to stop using it

The shape I use, always:

synchronized (lock) {
    while (!conditionHolds()) {   // state lives in a field, checked under the lock
        lock.wait();              // InterruptedException: propagate, don't swallow
    }
    consume();
}

synchronized (lock) {
    changeState();
    lock.notifyAll();             // state first, signal second, same lock
}

Why the loop: the signal says "something changed", and the loop is the only place you find out whether it changed for you. Why the field: a notify is not stored, the field is. Why notifyAll() as the default: it's correct regardless of how many kinds of waiter are on the monitor, and the price is some extra wake-ups.

When is notify() safe? Only when every waiter is waiting on the same condition, and one thread consuming that signal is always enough. A worker pool where every worker just wants "any task" is the textbook case. Even then, my honest preference is to not hand-roll it.

What each choice costs:

Choice Cost When
notifyAll() Wakes every waiter; they all contend for the lock Default
notify() Cheaper, loses signals if waiters differ One predicate, interchangeable waiters
wait(timeout) Returns on timeout without telling you which; check the condition Anything that must give up eventually
Condition per predicate More code, separate wait sets Producers/consumers, see the Condition article
BlockingQueue A library Almost always, see BlockingQueue

For most new code I'd skip wait/notify entirely. A BlockingQueue hands you the bounded buffer, a CountDownLatch hands you the one-shot event, and a Condition gives you two wait sets on the same lock so the producer/consumer mix-up above can't happen. It's still worth knowing the old API cold, because everything in java.util.concurrent that parks a thread has the same lost-signal and stolen-wakeup problems to solve, and because you'll meet hand-written monitors in code you inherit.

This took me well over a day, mostly in reading the Javadoc, the stacks and my own lab output until each result had an explanation I could defend (the stray waiter woke line included). It's written up so you can skip that day.

Reading the dump when it's already stuck

Take three thread dumps a few seconds apart with jcmd <pid> Thread.print. For a wait/notify hang you're looking for:

  • Threads in WAITING (on object monitor) all with the same waiting on <0x...> address. That's the shared wait set.
  • No thread RUNNABLE in your code and nobody BLOCKED on that address. If someone is BLOCKED on it, someone holds the lock, and you have contention rather than a lost signal.
  • BLOCKED threads whose lock is held by a notifier doing something slow: step 3.

ThreadMXBean.findDeadlockedThreads() will not report any of this. A lost notify isn't a cycle of locks, it's a thread waiting for an event that's never coming (more on that in the deadlock article).

Tests that would have caught it: a stress test with a start gate and a hard timeout on every join, like hang above. The timeout is the test. And change the order of start-up with a sleep to prove a notify-before-wait case, the way lost does, then remove the sleep and know why it still can happen.

Across upgrades: the semantics are stable. Retest virtual-thread code that waits on monitors when you move to 24 or later, since that's where the pinning behaviour changed.

Leave with these

  • A wait without a while loop and a field behind it is a bug waiting for a busy day.
  • notify() is a promise that every waiter wants the same thing. Most monitors can't make it.
  • A woken thread isn't running yet. It's BLOCKED until you let go of the lock.
  • If you're about to write this by hand, check whether a BlockingQueue already does it.
  • On 21, wait() inside a virtual thread pins its carrier. Retest on newer JDKs.

Short answers to the usual questions

Why must wait() be called in a loop? Because returning from wait() only means the thread was woken and has the lock again. Another thread may have changed the state in between (a stolen wakeup, shown above), or the JVM may have woken it spuriously. Re-checking the condition covers both.

What's the difference between notify() and notifyAll()? notify() wakes one arbitrary thread from the wait set, notifyAll() wakes all of them. If your waiters wait on different conditions, notify() can wake the wrong one and the signal is lost.

Does wait() release the lock? It releases the monitor of the object you called it on, and only that one. Other locks you hold stay held. For the comparison with Thread.sleep, see sleep vs wait.

Why do I get IllegalMonitorStateException? You called wait, notify or notifyAll on an object whose monitor the current thread doesn't hold. Wrap the call in synchronized (thatObject).

java-concurrencyjava-threadsthread-safety

Staff engineer architecting Kafka and Flink platforms for banking, where a dropped event is a reconciliation break, not a retry. Through javahandbook.com, I share production-tested guidance on building reliable event-driven systems.

Comments