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

# Java Condition Variables: await/signal vs wait/notify
- URL: https://www.ggorantala.dev/java-lock-condition/
- Published: 2026-10-29T13:17:00.000Z
- Updated: 2026-10-30T13:17:41.000Z
- Description: A Java condition variable gives one lock several wait sets. How await() and signal() work, why one Condition hangs a buffer, and a bounded buffer done right.
- Author: Gopi Gorantala
- Tags: java-locks, java-threads, java-concurrency, thread-safety

One line of code. Everything in this post is about it:

```java
one.signal(); // wakes exactly one waiter. Nothing says it's the kind you needed.

```

I put that line in a bounded buffer with three producers and three consumers, all sharing a single `Condition`, and ran it five times. Every run ended the same way: 0 of 6 threads finished, buffer holding 0 or 1 items, no exception, no CPU.

```log
finished threads = 0 of 6   n=1
  producer-0 WAITING
  producer-1 WAITING
  producer-2 WAITING
  consumer-0 WAITING
  consumer-1 WAITING
  consumer-2 WAITING

```

That's the failure people hit the first time they switch from `wait()/notify()` to a **java condition variable**. They swap the calls one for one, keep the single wait set, and the hang is waiting for them. This post is about what a `Condition` really is, what `await()` does to your lock, and the handful of behaviours I got wrong before I ran them.

## Six threads, one wait set, no progress

This is the short version of the evidence. `Lab.java` mode `B` (below) is the run above. Same JDK 21.0.12.1 build, 2 vCPUs, x86\_64, six out of six runs hung. I had assumed `signal()` was "like `notify()`, only nicer". It is exactly like `notify()` in the way that matters: it picks one waiter, and it doesn't care whether that waiter can use the wake-up.

A producer fills the single slot and signals. The woken thread is another producer. It sees a full buffer, goes back to sleep, and the signal is gone. Now everybody's asleep. The consumers never got told.

## What a Condition actually is

A `Condition` is a wait set that belongs to a lock. You get one from `lock.newCondition()`, and you can ask the same lock for as many as you want. That's the entire pitch.

Three rules cover most of it:

- You must hold the lock to call `await()` or `signal()`. Hold it, or get `IllegalMonitorStateException`.
- `await()` releases the lock completely, parks the thread, and re-acquires the lock before it returns. By the time your code runs again you're holding it again, at the same hold count you had.
- Wake-ups are hints. The Javadoc allows spurious wake-ups, so the wait always sits inside a `while` loop that rechecks the predicate. I never saw one in the lab, and I'm not going to pretend I did.

`wait()` and `notify()` do the same job with one wait set per object. If producers and consumers share a monitor, a `notify()` can land on the wrong kind of thread, which is why the old advice is "use `notifyAll()` and accept the herd". I wrote that up in [wait(), notify() and notifyAll() in Java, Done Right](https://ggorantala.dev/java-wait-notify-notifyall/?ref=ggorantala.dev). Conditions let you stop paying for the herd by splitting waiters by *predicate*: `notFull` for the producers, `notEmpty` for the consumers.

Whether that actually pays off is where my first assumption died. More below.

## Eight small experiments, one file

Everything below runs from one file. Pin: OpenJDK 21.0.12.1 (the Ubuntu 24.04 build, any JDK 21 will do; `eclipse-temurin:21-jdk` works as a container), 2 vCPUs, x86\_64\. I didn't run any of this on JDK 22 or later, or on ARM.

```log
$ java -version
openjdk version "21.0.12.1" 2026-08-18
OpenJDK 64-Bit Server VM (build 21.0.12.1+1-1-24.04.4-Ubuntu, mixed mode, sharing)

```

Save this as `Lab.java`, run `javac Lab.java`, then `java Lab <mode>`.

```java
import java.util.*;
import java.util.concurrent.*;
import java.util.concurrent.atomic.*;
import java.util.concurrent.locks.*;

public class Lab {
    // ---------- A: wakeups, one monitor vs two Conditions ----------
    interface Buf { void put(int x) throws InterruptedException; int take() throws InterruptedException; long futile(); }

    static class MonitorBuf implements Buf {
        final int[] a; int n, head, tail; final AtomicLong futile = new AtomicLong();
        MonitorBuf(int cap) { a = new int[cap]; }
        public synchronized void put(int x) throws InterruptedException {
            while (n == a.length) { wait(); if (n == a.length) futile.incrementAndGet(); }
            a[tail] = x; tail = (tail + 1) % a.length; n++; notifyAll();
        }
        public synchronized int take() throws InterruptedException {
            while (n == 0) { wait(); if (n == 0) futile.incrementAndGet(); }
            int x = a[head]; head = (head + 1) % a.length; n--; notifyAll(); return x;
        }
        public long futile() { return futile.get(); }
    }
    static class CondBuf implements Buf {
        final int[] a; int n, head, tail; final AtomicLong futile = new AtomicLong();
        final ReentrantLock lock;
        final Condition notFull, notEmpty;
        CondBuf(int cap, boolean fair) { a = new int[cap]; lock = new ReentrantLock(fair); notFull = lock.newCondition(); notEmpty = lock.newCondition(); }
        public void put(int x) throws InterruptedException {
            lock.lock();
            try {
                while (n == a.length) { notFull.await(); if (n == a.length) futile.incrementAndGet(); }
                a[tail] = x; tail = (tail + 1) % a.length; n++; notEmpty.signal();
            } finally { lock.unlock(); }
        }
        public int take() throws InterruptedException {
            lock.lock();
            try {
                while (n == 0) { notEmpty.await(); if (n == 0) futile.incrementAndGet(); }
                int x = a[head]; head = (head + 1) % a.length; n--; notFull.signal(); return x;
            } finally { lock.unlock(); }
        }
        public long futile() { return futile.get(); }
    }
    static void modeA() throws Exception {
        int cap = Integer.getInteger("cap", 4);
        for (String kind : new String[]{"monitor+notifyAll", "lock+2 conditions", "fair lock+2 conds"}) {
            Buf b = kind.startsWith("monitor") ? new MonitorBuf(cap) : new CondBuf(cap, kind.startsWith("fair"));
            int P = 8, C = 8, per = 20_000;
            CountDownLatch go = new CountDownLatch(1);
            List<Thread> ts = new ArrayList<>();
            for (int i = 0; i < P; i++) ts.add(new Thread(() -> { try { go.await(); for (int k = 0; k < per; k++) b.put(k); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }));
            for (int i = 0; i < C; i++) ts.add(new Thread(() -> { try { go.await(); for (int k = 0; k < per; k++) b.take(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }));
            ts.forEach(Thread::start);
            long t0 = System.nanoTime(); go.countDown();
            for (Thread t : ts) t.join();
            long ms = (System.nanoTime() - t0) / 1_000_000;
            long items = (long) P * per;
            System.out.printf("%-20s items=%d  time=%d ms  futile wakeups=%d (%.2f per item)%n", kind, items, ms, b.futile(), (double) b.futile() / items);
        }
    }

    // ---------- B: one Condition + signal() = stall ----------
    static void modeB() throws Exception {
        final ReentrantLock lock = new ReentrantLock();
        final Condition one = lock.newCondition();
        final int[] n = {0}; final int cap = 1;
        final AtomicInteger done = new AtomicInteger();
        Runnable prod = () -> { try { for (int i = 0; i < 1000; i++) { lock.lock(); try { while (n[0] == cap) one.await(); n[0]++; one.signal(); } finally { lock.unlock(); } } done.incrementAndGet(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } };
        Runnable cons = () -> { try { for (int i = 0; i < 1000; i++) { lock.lock(); try { while (n[0] == 0) one.await(); n[0]--; one.signal(); } finally { lock.unlock(); } } done.incrementAndGet(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } };
        List<Thread> ts = new ArrayList<>();
        for (int i = 0; i < 3; i++) { Thread t = new Thread(prod, "producer-" + i); ts.add(t); }
        for (int i = 0; i < 3; i++) { Thread t = new Thread(cons, "consumer-" + i); ts.add(t); }
        ts.forEach(Thread::start);
        long end = System.currentTimeMillis() + 5000;
        for (Thread t : ts) { t.join(Math.max(1, end - System.currentTimeMillis())); }
        System.out.println("finished threads = " + done.get() + " of 6   n=" + n[0]);
        for (Thread t : ts) System.out.println("  " + t.getName() + " " + t.getState());
        if (System.getProperty("keep") != null) { System.out.println("PID " + ProcessHandle.current().pid()); Thread.sleep(60000); }
        System.exit(0);
    }

    // ---------- C: hold count through await ----------
    static void modeC() throws Exception {
        ReentrantLock lock = new ReentrantLock(); Condition c = lock.newCondition();
        CountDownLatch waiting = new CountDownLatch(1);
        Thread w = new Thread(() -> {
            lock.lock(); lock.lock();
            try {
                System.out.println("waiter: holdCount before await = " + lock.getHoldCount());
                waiting.countDown();
                try { c.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
                System.out.println("waiter: holdCount after await  = " + lock.getHoldCount());
            } finally { lock.unlock(); lock.unlock(); }
        }, "waiter");
        w.start(); waiting.await(); Thread.sleep(100);
        boolean got = lock.tryLock();
        System.out.println("main: tryLock while waiter is in await (hold count was 2) = " + got + "  isLocked=" + lock.isLocked());
        if (got) { System.out.println("main: hasWaiters=" + lock.hasWaiters(c) + " waitQueueLength=" + lock.getWaitQueueLength(c)); c.signal(); lock.unlock(); }
        w.join();
    }

    // ---------- D: lock not held ----------
    static void modeD() throws Exception {
        ReentrantLock lock = new ReentrantLock(); Condition c = lock.newCondition();
        try { c.await(); } catch (Exception e) { System.out.println("await  without lock: " + e); }
        try { c.signal(); } catch (Exception e) { System.out.println("signal without lock: " + e); }
        Object m = new Object();
        try { m.notify(); } catch (Exception e) { System.out.println("notify without monitor: " + e); }
        lock.lock();
        try { c.signalAll(); System.out.println("signalAll with lock held, no waiters: fine"); } finally { lock.unlock(); }
    }

    // ---------- E: interrupt before / after signal ----------
    static void modeE() throws Exception {
        for (String when : new String[]{"interrupt BEFORE signal", "signal THEN interrupt (both under the lock)"}) {
            ReentrantLock lock = new ReentrantLock(); Condition c = lock.newCondition();
            CountDownLatch in = new CountDownLatch(1);
            AtomicReference<String> res = new AtomicReference<>();
            Thread w = new Thread(() -> {
                lock.lock();
                try { in.countDown(); c.await(); res.set("await returned normally, interrupt flag = " + Thread.currentThread().isInterrupted()); }
                catch (InterruptedException e) { res.set("InterruptedException, flag now = " + Thread.currentThread().isInterrupted()); }
                finally { lock.unlock(); }
            }, "waiter");
            w.start(); in.await(); Thread.sleep(100);
            lock.lock();
            try {
                if (when.startsWith("interrupt")) { w.interrupt(); }
                else { c.signal(); w.interrupt(); }
            } finally { lock.unlock(); }
            w.join();
            System.out.printf("%-45s -> %s%n", when, res.get());
        }
    }

    // ---------- F: timed waits ----------
    static void modeF() throws Exception {
        ReentrantLock lock = new ReentrantLock(); Condition c = lock.newCondition();
        lock.lock();
        try {
            long t0 = System.nanoTime();
            boolean r = c.await(200, TimeUnit.MILLISECONDS);
            System.out.printf("await(200ms) with no signal returned %s after %d ms%n", r, (System.nanoTime() - t0) / 1_000_000);
            long left = c.awaitNanos(TimeUnit.MILLISECONDS.toNanos(200));
            System.out.println("awaitNanos(200ms) with no signal returned " + left + " (<= 0 means timed out)");
        } finally { lock.unlock(); }
        // signalled early
        CountDownLatch in = new CountDownLatch(1);
        AtomicLong rem = new AtomicLong();
        Thread w = new Thread(() -> { lock.lock(); try { in.countDown(); rem.set(c.awaitNanos(TimeUnit.SECONDS.toNanos(5))); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { lock.unlock(); } });
        w.start(); in.await(); Thread.sleep(150); lock.lock(); try { c.signal(); } finally { lock.unlock(); } w.join();
        System.out.printf("awaitNanos(5s) signalled after ~150 ms returned ~%d ms remaining%n", rem.get() / 1_000_000);
    }

    // ---------- G: signal order ----------
    static void modeG() throws Exception {
        ReentrantLock lock = new ReentrantLock(); Condition c = lock.newCondition();
        List<String> order = Collections.synchronizedList(new ArrayList<>());
        List<Thread> ts = new ArrayList<>();
        for (int i = 0; i < 6; i++) {
            final int id = i; CountDownLatch in = new CountDownLatch(1);
            Thread t = new Thread(() -> { lock.lock(); try { in.countDown(); c.await(); order.add("w" + id); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { lock.unlock(); } });
            t.start(); in.await(); while (true) { lock.lock(); try { if (lock.getWaitQueueLength(c) == id + 1) break; } finally { lock.unlock(); } Thread.onSpinWait(); }
            ts.add(t);
        }
        for (int i = 0; i < 6; i++) { lock.lock(); try { c.signal(); } finally { lock.unlock(); } Thread.sleep(20); }
        for (Thread t : ts) t.join();
        System.out.println("await order w0..w5, wake order: " + order);
    }

    // ---------- H: dump of a waiter ----------
    static void modeH() throws Exception {
        ReentrantLock lock = new ReentrantLock(); Condition c = lock.newCondition();
        Thread w = new Thread(() -> { lock.lock(); try { c.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { lock.unlock(); } }, "cond-waiter");
        w.start(); Thread.sleep(200);
        System.out.println("PID " + ProcessHandle.current().pid() + " state=" + w.getState());
        ThreadInfo(w);
        Thread.sleep(Long.parseLong(System.getProperty("hold", "0")));
        System.exit(0);
    }
    static void ThreadInfo(Thread w) {
        var mx = java.lang.management.ManagementFactory.getThreadMXBean();
        var ti = mx.getThreadInfo(w.threadId(), 5);
        System.out.println("lockName=" + ti.getLockName() + " lockOwner=" + ti.getLockOwnerName());
    }

    // ---------- I: virtual threads, one carrier ----------
    static void modeI() throws Exception {
        ReentrantLock lock = new ReentrantLock(); Condition c = lock.newCondition();
        CountDownLatch waiting = new CountDownLatch(4);
        List<Thread> ts = new ArrayList<>();
        for (int i = 0; i < 4; i++) ts.add(Thread.ofVirtual().start(() -> { lock.lock(); try { waiting.countDown(); c.await(); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { lock.unlock(); } }));
        long t0 = System.nanoTime();
        boolean ok = waiting.await(3, TimeUnit.SECONDS);
        System.out.printf("4 virtual threads all inside await() on parallelism=%s: %s in %d ms%n", System.getProperty("jdk.virtualThreadScheduler.parallelism"), ok, (System.nanoTime() - t0) / 1_000_000);
        lock.lock(); try { c.signalAll(); } finally { lock.unlock(); }
        for (Thread t : ts) t.join();
        System.out.println("all released");
    }

    public static void main(String[] a) throws Exception {
        switch (a[0]) {
            case "A" -> modeA(); case "B" -> modeB(); case "C" -> modeC(); case "D" -> modeD();
            case "E" -> modeE(); case "F" -> modeF(); case "G" -> modeG(); case "H" -> modeH(); case "I" -> modeI();
        }
    }
}

```

**Step 1: `java Lab D`.** The lock-not-held errors.

```log
await  without lock: java.lang.IllegalMonitorStateException
signal without lock: java.lang.IllegalMonitorStateException
notify without monitor: java.lang.IllegalMonitorStateException: current thread is not owner
signalAll with lock held, no waiters: fine

```

Note the message. `Object.notify()` tells you "current thread is not owner". The `Condition` versions print a bare exception with no message at all. Don't grep logs for the wording.

**Step 2: `java Lab C`.** What `await()` does to a re-entrant hold.

```log
waiter: holdCount before await = 2
main: tryLock while waiter is in await (hold count was 2) = true  isLocked=true
main: hasWaiters=true waitQueueLength=1
waiter: holdCount after await  = 2

```

The waiter held the lock twice. A different thread acquired it the moment the waiter started waiting. So `await()` releases *all* holds, not one. After it wakes, the count is back to 2.

**Step 3: `java Lab E`.** Interrupt before and after a signal.

```log
interrupt BEFORE signal                       -> InterruptedException, flag now = false
signal THEN interrupt (both under the lock)   -> await returned normally, interrupt flag = true

```

Both cases are deterministic here: the signaller holds the lock the whole time, so the waiter can't run until it's done. Interrupt first and you get the exception. Signal first and `await()` returns *normally*, with the interrupt flag still set for you to deal with. The signal wasn't lost. That's the correct behaviour (the Javadoc lets an implementation do either, as long as it redirects the signal) and it matters if you have a catch block that assumes "I was woken, so I wasn't interrupted".

**Step 4: `java Lab F`.** Timed waits.

```log
await(200ms) with no signal returned false after 200 ms
awaitNanos(200ms) with no signal returned -252200 (<= 0 means timed out)
awaitNanos(5s) signalled after ~150 ms returned ~4849 ms remaining

```

`awaitNanos` returns what's left of your budget. That's why the Javadoc loop re-passes the return value on each trip. Use `await(long, TimeUnit)` when a boolean is enough and `awaitNanos` when you loop.

**Step 5: `java Lab G`.** Who gets woken first.

```log
await order w0..w5, wake order: [w0, w1, w2, w3, w4, w5]

```

First in, first out, six for six, on this JDK. But the Javadoc for `signal()` says one waiter "is selected". It doesn't promise order. Don't build correctness on it.

**Step 6: `java Lab H`, then a dump.**

```bash
$ java -Dhold=4000 Lab H &
$ jcmd <pid> Thread.print | grep -A8 '"cond-waiter"'

```

```log
"cond-waiter" #18 prio=5 os_prio=0 cpu=1.07ms elapsed=1.94s ... waiting on condition
   java.lang.Thread.State: WAITING (parking)
	at jdk.internal.misc.Unsafe.park(java.base@21.0.12.1/Native Method)
	- parking to wait for  <0x00000000a857afa8> (a java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject)
	at java.util.concurrent.locks.LockSupport.park(...LockSupport.java:371)
	at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionNode.block(...AbstractQueuedSynchronizer.java:519)
	at java.util.concurrent.ForkJoinPool.unmanagedBlock(...ForkJoinPool.java:3780)
	at java.util.concurrent.ForkJoinPool.managedBlock(...ForkJoinPool.java:3725)
	at java.util.concurrent.locks.AbstractQueuedSynchronizer$ConditionObject.await(...AbstractQueuedSynchronizer.java:1746)

```

`WAITING (parking)` and a `ConditionObject` address. No monitor, no lock owner. Compare that with `wait()`, which shows `WAITING (on object monitor)` and `Object.wait`. There's a `ForkJoinPool.managedBlock` frame in the middle. That surprised me and I'll come back to it.

**Step 7: `java Lab B`.** The hang from the top of the post. Six runs, 0 of 6 finished each time. Takes five seconds, because the lab gives up and prints states.

**Step 8: `java -Dcap=4 Lab A`.** The experiment I was most sure about, and the one I got wrong. Next section.

Confirmation signals: Step 7 shows six `WAITING` threads. Step 2 shows `tryLock = true` while the waiter is inside `await()`. If your Step 3 second line says `InterruptedException`, you're not on a standard JDK 21.

If Step 7 *doesn't* hang, check you're running three producers and three consumers and a capacity of one. With a bigger buffer a stall needs luck, not only logic.

Cleanup: `jps`, then `kill` anything still named `Lab`.

## Where the thread goes when it awaits

Here's the lifecycle of one `await()`, from the lab and the source:

```log
 Thread T holds the lock (hold count 2)
   |
   | cond.await()
   v
 1. node added to the Condition's own queue
 2. saved state = 2; lock fully released (anyone may take it now)
 3. T parks            <-- dump shows WAITING (parking) on the ConditionObject
   ...
 another thread, holding the lock:  cond.signal()
 4. T's node moves from the Condition queue to the lock's queue.
    T is NOT running yet. It is waiting for the lock like anyone else.
   ...
 signaller calls unlock()
 5. T unparked, re-acquires the lock, hold count restored to 2
 6. await() returns. Your while loop rechecks the predicate.

```

Step 4 is the one people get wrong in their heads. `signal()` doesn't hand over the lock and doesn't run the waiter. It moves it into the queue for the lock. If a thread that was never asleep reaches the lock first, it wins, and by the time your waiter gets in, the thing it was waiting for may be gone again.

Which is exactly what Step 8 shows.

```log
cap=4, 8 producers, 8 consumers, 160,000 items, 2 vCPU x86_64, JDK 21
monitor + notifyAll      1805-1958 ms   0.20-0.30 futile wake-ups per item
lock + 2 Conditions      2786-3913 ms   0.93-1.16
fair lock + 2 Conditions 4607-5974 ms   0.21-0.27

cap=64
monitor + notifyAll       168-202 ms    0.04
lock + 2 Conditions       349-385 ms    0.11-0.14
fair lock + 2 Conditions  4068-4637 ms  0.00

```

("Futile wake-up" = `await()` returned and the predicate was still false.) I'd written the lab expecting the two-Condition version to win by a mile: no herd, no wasted wake-ups. It lost, on time and on futile wake-ups, in every run. Single-machine, 2 vCPUs, one JVM per run, not JMH, so read the shape and not the digits.

My best explanation, not profiled: the default `ReentrantLock` is non-fair. A thread that never slept can grab the lock ahead of the one `signal()` just moved into the queue, and take the item it was woken for. Making the lock fair cuts the futile wake-ups to near zero and costs roughly 2x to 13x in throughput. If you want to know why, [the barging experiment in the ReentrantLock post](https://ggorantala.dev/java-reentrantlock/?ref=ggorantala.dev) is the same mechanism.

So the honest case for Conditions isn't "they're faster". It's that each wait set has *one meaning*, which makes the code easier to get right, and that you can wake one waiter without waking a crowd. I'll take correctness over a 2x on a micro-benchmark, but I wanted the number on the page because the internet will tell you it's faster.

## What AbstractQueuedSynchronizer does with it

`Condition` is an interface. `ReentrantLock.newCondition()` returns an `AbstractQueuedSynchronizer.ConditionObject`. I read the JDK 21 source (tag `jdk-21-ga`) for the pieces that matter, with the Javadoc open next to it. Nothing below comes from JDK 17 or earlier; I didn't diff them.

- `await()` puts a node in the condition queue, remembers the lock's `state` (the hold count), and calls `release(savedState)`: a full release. On the way back out it calls `acquire` with the saved state. That's the hold-count round trip from Step 2.
- `signal()` checks `isHeldExclusively()` first, which is where the `IllegalMonitorStateException` in Step 1 comes from. Then it unlinks the first live waiter and appends it to the lock's own queue. It does not unpark it, in the normal path. The unpark happens when the lock is released.
- Whether a wait ended by signal or by interrupt is decided by an atomic bit on the node (`COND`). First one to clear it wins. If the signal got there first, `await()` returns normally and re-asserts the interrupt. That's Step 3.
- `ConditionNode` implements `ForkJoinPool.ManagedBlocker`, and `await()` goes through `managedBlock`. That's the extra frame in the dump. When the caller is a ForkJoinPool worker, the pool can add a compensating thread. For ordinary threads it parks.

Virtual threads: with the scheduler pinned to one carrier (`-Djdk.virtualThreadScheduler.parallelism=1 -Djdk.virtualThreadScheduler.maxPoolSize=1`), four virtual threads all got *into* `await()` in 1 ms.

```text
4 virtual threads all inside await() on parallelism=1: true in 1 ms
all released

```

If `await()` pinned the carrier, only one of them could have got in. So on 21, `Condition.await()` doesn't pin. That's the same reason [ReentrantLock was the standard advice for virtual threads before JEP 491 fixed synchronized](https://ggorantala.dev/java-virtual-thread-pinning-synchronized-jep-491/?ref=ggorantala.dev). I haven't re-run it on JDK 24 or 25.

```log
Java 5    Condition and ReentrantLock arrive (JSR 166)
JDK 21    AQS ConditionObject uses ConditionNode + a COND status bit
JDK 21    Javadoc doesn't mention virtual threads; lab says no pinning
JDK 24    synchronized stops pinning virtual threads (JEP 491; not run
          here), so that reason to prefer Condition goes away, the others stay

```

## Where Condition code goes wrong

It took me longer than a day to stop being surprised by this one. The honest scope: a day to build the lab and be wrong about Step 8, most of another to read `ConditionObject` and work out that `signal()` doesn't hand over the lock. The rest of this section is the part that would have saved me the most.

**One Condition, `signal()`.** The hang at the top. You've got two kinds of waiters and one wait set, so a signal can wake the wrong kind. Two fixes: `signalAll()` (correct, herd-y) or one `Condition` per predicate (correct, no herd). Do the second.

**`if` instead of `while`.** Spurious wake-ups are allowed, but barging makes this bug common without them. See Step 8: about one wake-up in four on a fair lock, and about one per item on the default one, found the predicate false again. An `if` there is a bug you will meet.

**Signalling the wrong Condition.** `notFull.signal()` in `put()` where you meant `notEmpty.signal()`. It compiles. It hangs when load is low. I'd put this one on a code-review checklist, and a [producer-consumer test with a capacity of one](https://ggorantala.dev/java-producer-consumer/?ref=ggorantala.dev) catches it quickly.

**`lock()` inside the `try`.** If `lock()` throws, `finally` calls `unlock()` on a lock you don't hold. `lock()` goes *before* the `try`.

**Catching `InterruptedException` and carrying on.** Step 3 shows `await()` can return normally with the flag set. Then the *next* blocking call throws immediately. If you swallow it, you've lost the only message the thread got. The rules are in [the interrupt post](https://ggorantala.dev/java-thread-interrupt-interruptedexception/?ref=ggorantala.dev).

**`Thread.sleep` loops "instead of" a Condition.** [Polling costs latency](https://ggorantala.dev/java-sleep-vs-wait/?ref=ggorantala.dev), and holds nothing back. That's the whole argument.

**Signalling without changing state.** `signal()` doesn't carry data. If the predicate state wasn't updated under the lock first, the woken thread rechecks, finds nothing, and goes back to sleep. The state is the truth. The signal is a nudge.

## How I write the bounded buffer now

One lock, one Condition per predicate, `while` loops, `signal()` on the *other* side's Condition, `lock()` before `try`:

```java
import java.util.concurrent.locks.*;

final class BoundedBuffer<T> {
    private final Object[] items;
    private int head, tail, count;
    private final ReentrantLock lock = new ReentrantLock();
    private final Condition notFull = lock.newCondition();
    private final Condition notEmpty = lock.newCondition();

    BoundedBuffer(int capacity) { items = new Object[capacity]; }

    void put(T x) throws InterruptedException {
        lock.lockInterruptibly();
        try {
            while (count == items.length) notFull.await();
            items[tail] = x; tail = (tail + 1) % items.length; count++;
            notEmpty.signal(); // one new item: one consumer can use it
        } finally { lock.unlock(); }
    }

    @SuppressWarnings("unchecked")
    T take() throws InterruptedException {
        lock.lockInterruptibly();
        try {
            while (count == 0) notEmpty.await();
            T x = (T) items[head]; items[head] = null; head = (head + 1) % items.length; count--;
            notFull.signal();
            return x;
        } finally { lock.unlock(); }
    }
}

```

Why `signal()` is safe here: each Condition has one kind of waiter, and one item or one free slot satisfies exactly one of them. The moment one operation can satisfy *several* waiters (a `putAll`, a resize, a shutdown flag), switch that call to `signalAll()`.

A table of what each choice costs, measured on that 2 vCPU box:

| Choice                                    | Gives you                                  | Costs you                                      |
| ----------------------------------------- | ------------------------------------------ | ---------------------------------------------- |
| synchronized \+ notifyAll()               | simplest, fastest in my run                | herd of wake-ups, one wait set                 |
| Lock + one Condition \+ signalAll()       | explicit lock, timed/interruptible acquire | same herd, no extra benefit                    |
| Lock + Condition per predicate + signal() | targeted wake-ups, readable                | futile wake-ups from barging, \~2x slower here |
| Fair lock + Condition per predicate       | almost no futile wake-ups                  | 2x to 13x lower throughput in my runs          |

If you just need a bounded buffer, don't write this. `ArrayBlockingQueue` is this, tested (more in [the BlockingQueue post](https://ggorantala.dev/java-blockingqueue/?ref=ggorantala.dev)). Write it yourself when you need something it can't do: two predicates on one lock, a "closed" state, a timed wait that returns the remaining budget.

## What a stuck waiter looks like, and what to retest

In a dump, a `Condition` waiter is `WAITING (parking)` on an `AbstractQueuedSynchronizer$ConditionObject`, with *no* lock owner shown next to it. A hung system will show a pile of those. Take three dumps a few seconds apart. If the same threads sit on the same `ConditionObject` address each time, and the only thread that could signal it is also parked, you've got a lost-signal bug.

In code: `lock.getWaitQueueLength(cond)` and `lock.hasWaiters(cond)` give you a number to export as a gauge. A waiter count that only climbs is the hang, seen early. Both need the lock held.

A test that would have caught the top-of-post hang: capacity 1, three of each side, a start gate, one thousand items each, a five second timeout. Fail on timeout and print the thread states.

Version safety. What I ran: JDK 21.0.12.1 only. What I'd retest on a move to 25: Step 3 (interrupt after signal), Step 5 (wake order, which isn't guaranteed), Step 6 (the dump format and the `managedBlock` frame), and the virtual-thread run. I'd expect the same, and haven't checked.

## Worth screenshotting

- `await()` releases the *whole* hold count, parks, and takes it all back before returning. A signalled thread is queued for the lock. It isn't running yet.
- One `Condition` per predicate. Sharing one and calling `signal()` hung my buffer 6 runs out of 6.
- Always `while`, never `if`. On a non-fair lock, barging means the thing you were woken for is often gone.
- Conditions weren't faster in my numbers. They're clearer, and they let you wake one waiter without waking everyone.
- `await()` doesn't pin virtual threads on 21.

## Things people search for

**What's the difference between await/signal and wait/notify?**  
`wait/notify` give each object one wait set and need `synchronized`. `Condition` gives a `Lock` as many wait sets as you ask for, with timed, interruptible and uninterruptible waits. Semantics are the same shape: release, park, re-acquire, recheck. See [the wait/notify post](https://ggorantala.dev/java-wait-notify-notifyall/?ref=ggorantala.dev).

**Does Condition.await() release the lock?**  
Yes, completely. A thread holding a `ReentrantLock` three times is back to holding it three times after `await()` returns, and any other thread can take it in between (Lab mode C).

**Can I call signal() without holding the lock?**  
No. You get `IllegalMonitorStateException` (Lab mode D). Call it while holding the lock, after the state change that the waiter is waiting for.

**Are spurious wake-ups real for Condition?**  
The Javadoc allows them, so you must loop. I never produced one in this lab. What I produced constantly was a *legitimate* wake-up where the predicate was false again, because another thread got there first.