> ## Content Index
> Fetch the complete content index at: https://www.ggorantala.dev/llms.txt
> Use this file to discover other available public pages before exploring further.

# Wait and Notify in Java: The Rules That Stop the Hangs
- URL: https://www.ggorantala.dev/java-wait-notify-notifyall/
- Published: 2026-10-30T14:06:16.000Z
- Updated: 2026-10-30T14:06:16.000Z
- Description: 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.
- Author: Gopi Gorantala
- Tags: java-concurrency, java-threads, thread-safety

```java
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()`:

```log
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`.

```java
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`

```log
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`

```log
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`

```log
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`

```log
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`

```log
  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`):

```log
"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:

```log
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](https://ggorantala.dev/java-memory-model-happens-before/?ref=ggorantala.dev). 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:

```log
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`.

```log
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](https://www.ggorantala.dev/java-virtual-thread-pinning-synchronized-jep-491/). 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](https://ggorantala.dev/java-thread-interrupt-interruptedexception/?ref=ggorantala.dev). 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:

```java
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](https://ggorantala.dev/java-lock-condition/?ref=ggorantala.dev) |
| BlockingQueue           | A library                                                         | Almost always, see [BlockingQueue](https://ggorantala.dev/java-blockingqueue/?ref=ggorantala.dev)                |

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](https://ggorantala.dev/java-deadlock/?ref=ggorantala.dev)).

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](https://ggorantala.dev/java-sleep-vs-wait/?ref=ggorantala.dev).

**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)`.