Advanced
Concurrency: Coordination
Mutual exclusion is only half of concurrency, because threads also have to wait for each other. wait/notify implements guarded waiting on the monitor you already hold, and using if instead of while breaks it. ReentrantLock adds tryLock and timed acquisition, and leaks if unlock isn't in finally. ReadWriteLock lets readers share. CountDownLatch, Semaphore and CyclicBarrier are ready-made rendezvous points, and BlockingQueue gives you producer/consumer for free. Includes a real deadlock, captured with jstack, and its two escapes.
Suggest an editConcurrency: Coordination — Waiting, Multiple Locks & Synchronizers
The last lesson solved exclusion: synchronized keeps two threads out of the same critical section at the same time. But most real concurrent programs need more than that. They need threads that wait for each other.
- A consumer has to wait until a producer has made something.
- A coordinator has to wait until all its workers finish.
- A pool has to make requests wait until a connection is free.
Here's the thesis of this lesson: coordination means waiting until some condition on shared state becomes true. Everything in it (wait/notify, Condition, latches, semaphores, blocking queues) is that one idea, packaged at increasingly higher levels. Learn the raw form first, and the library forms stop being magic. Learn the failure mode of holding two locks — deadlock — and you'll see why the library forms exist.
💡 The core idea.
- Coordination = wait until a condition on shared state becomes true.
wait/notify,Condition, latches, semaphores and blocking queues are all that one idea, packaged at rising levels.- Learn the raw form, and the library forms stop being magic.
- Holding two locks brings deadlock — which is why the library forms exist.
This lesson builds directly on threads, races, and synchronized. It also sets up the next lesson's executors and virtual threads, which are built from these primitives.
Thread scheduling is nondeterministic, so several outputs below vary from run to run. Those are labeled illustrative, and each one shows a single real captured run. The deadlock in §2 never finishes, so its output is a captured run too.
You'll be able to: write a guarded block with while, wait() and notifyAll(), and predict what an if guard does with two consumers; name the four conditions of a deadlock, and break one with lock ordering or a timed tryLock; release a ReentrantLock in finally, and predict what an exception does to a lock released without it; choose a latch, a semaphore, a barrier or a blocking queue for a coordination shape, and predict each one's lifecycle.
📘 How to read the Intuition boxes. Each one is built in three moves:
- The mechanism — what the compiler and the JVM do.
- A concrete bite — a specific, runnable failure (often a real compiler error), shown so the trap is visible.
- The earned rule — the decision heuristic, now justified rather than asserted, plus its cost.
Table of contents
- Waiting for a condition:
wait,notify, and guarded blocks - Deadlock: the price of multiple locks
ReentrantLock: a lock as an object- Synchronizers:
CountDownLatch,Semaphore,CyclicBarrier BlockingQueue: coordination you don't have to write- Mental-model summary
- Gotcha checklist
- Check yourself
- Sources
1. Waiting for a condition: wait, notify, and guarded blocks
Suppose a producer thread puts items into a bounded buffer, and a consumer thread takes them out. The producer must wait while the buffer is full, and the consumer must wait while it's empty.
synchronized alone can't handle this. A thread stuck inside a critical section, waiting for the buffer to change, would hold the lock forever — so no other thread could ever get in to change it.
The monitor from the last lesson has a built-in answer [1]:
- Every object has a wait set, alongside its monitor.
wait()releases the lock and puts the thread to sleep in that wait set.notifyAll()wakes every thread sleeping there, so each one can re-check its condition.
The canonical shape checks the condition in a while loop, and calls wait() as long as it's false. This is called a guarded block.
Output (illustrative — the interleaving varies per run; this is one real run):
put 1 buffer=[1]
put 2 buffer=[1, 2]
producer waits (buffer full: [1, 2])
take 1 buffer=[2]
put 3 buffer=[2, 3]
producer waits (buffer full: [2, 3])
take 2 buffer=[3]
put 4 buffer=[3, 4]
producer waits (buffer full: [3, 4])
take 3 buffer=[4]
put 5 buffer=[4, 5]
take 4 buffer=[5]
take 5 buffer=[]Analysis. The fast producer fills the two-slot buffer, prints "producer waits", and sleeps inside put. Yet the consumer still gets into take, because wait() released the buffer's lock on its way down.
- Each
takecallsnotifyAll(). The producer wakes up, re-checksitems.size() == capacity(now false), and carries on. - The buffer never exceeds its capacity, and no thread spins burning CPU: waiting threads stay parked until they're notified.
- Both methods synchronize on the same object, the buffer. Its lock is both the mutual-exclusion guard and the place where waiters sleep.
Intuition.
Mechanism. Think of every monitor as having two rooms. The entry queue holds threads that are BLOCKED, trying to acquire the lock. The wait set holds threads that held the lock and then called wait().
wait()releases the lock and moves the thread into the wait set, in a single step.notifyAll()moves everyone in the wait set back toward the lock. They re-acquire it one at a time, and — thanks to thewhileloop — re-check the condition before going on.
Concrete bite. There are two classic mistakes. First, calling wait() without holding the monitor fails immediately:
Output (a thrown exception):
Exception in thread "main" java.lang.IllegalMonitorStateException: current thread is not ownerThe second mistake is far worse, because it usually works: guarding with if instead of while. A woken thread re-acquires the lock later, after other threads may have run — and another consumer may have emptied the buffer again in the meantime. The API also allows spurious wakeups: "A thread can wake up without being notified, interrupted, or timing out" [2].
The program below makes the if bug happen on every run. Two consumers wait on an empty queue, and main puts in just one item. A static synchronized method locks the Main.class monitor, so the waiting and notifying both happen on Main.class.
Output:
took 1
a consumer failed: java.util.NoSuchElementException
violation: one item, two woken consumers, and the if did not re-checknotifyAll() woke both consumers. The first one took the item. The second got the lock next, skipped the check, and called removeFirst() on an empty queue. Change if to while, and the second consumer re-checks the condition and goes back to waiting:
Output:
took 1
one consumer took the item; the other went back to waiting
took 2💡 Earned rule. The guarded-block idiom is fixed: synchronized + while (!condition) wait(); + notifyAll() after every state change that could make someone's condition true.
The cost: it's verbose, and easy to get subtly wrong (if vs while, notify vs notifyAll). Every waiter also wakes up to re-check, even when the change doesn't concern it. That's why the rest of this lesson exists.
2. Deadlock: the price of multiple locks
One lock serializes. But two locks held at the same time can deadlock: thread 1 holds lock A and wants B, while thread 2 holds B and wants A. Neither can proceed, and neither will ever let go. The program hangs forever, with no exception and no error.
This program deadlocked on every run we tried. If you press Run, it prints two lines and never finishes, so the sandbox stops it at its time limit.
// expects-hang: a deadlock — each thread waits forever for the lock the other holds
public class Main {
static final Object lockA = new Object();
static final Object lockB = new Object();
public static void main(String[] args) {
new Thread(() -> {
synchronized (lockA) {
System.out.println("t1: holds A, wants B");
pause();
synchronized (lockB) { System.out.println("t1: got both"); }
}
}, "t1").start();
new Thread(() -> {
synchronized (lockB) { // opposite order!
System.out.println("t2: holds B, wants A");
pause();
synchronized (lockA) { System.out.println("t2: got both"); }
}
}, "t2").start();
}
static void pause() { try { Thread.sleep(100); } catch (InterruptedException e) {} }
}Output (real captured run — the program printed two lines and then hung; we killed it after 3 seconds. Neither "got both" line will ever print):
t1: holds A, wants B
t2: holds B, wants AWhile it hung, we pointed the JDK's jstack tool at the process [3]. jstack prints every thread's stack, and it spots the cycle:
Found one Java-level deadlock:
=============================
"t1":
waiting to lock monitor 0x0000007bfcd0e4c0 (object 0x0000007020418048, a java.lang.Object),
which is held by "t2"
"t2":
waiting to lock monitor 0x0000007bfcd0e5a0 (object 0x0000007020418038, a java.lang.Object),
which is held by "t1"Analysis. The diagram captures the whole disease. The "holds" and "wants" edges form a cycle, and a cycle of threads — each waiting for a lock that the next one holds — can never make progress. A deadlock needs four conditions at once [4]:
- Mutual exclusion: a lock has one holder at a time.
- Hold and wait: a thread keeps one lock while it waits for another.
- No preemption: a lock cannot be taken away from its holder.
- Circular wait: the cycle in the diagram.
Break any one of them, and you prevent deadlock. The two practical breaks are order (both threads take A before B, so no cycle can form) and timeout (give up waiting and release what you hold, as §3 shows). And notice what the output doesn't show: no exception, no stack trace. A deadlocked server simply stops answering.
Intuition.
Mechanism. Each synchronized (x) blocks until x's monitor is free, while keeping every monitor it already holds. The scheduler has no insight into your intent: it just parks t1 on B's entry queue, and t2 on A's.
A parked thread releases nothing, so the cycle is permanent. The JVM can detect the cycle after the fact (that's what jstack does), but it won't break it.
Concrete bite. The window for this bug depends on timing. Remove the two pause() calls, and the program usually completes, because one thread grabs both locks before the other even starts. Without the pauses, all 30 runs on JDK 25 completed, even though the bug was still there. With them, 10 out of 10 hung.
That's what makes deadlock the cruelest concurrency bug: it passes tests for months, then freezes production the day the load reshuffles the timing.
💡 Earned rule. Establish a global lock ordering: give every lock a fixed rank, and have all threads, everywhere, acquire locks in rank order. Or never hold two locks at once.
The cost: lock ordering is an invisible convention that no compiler enforces, so you have to document it and defend it in code review. That discipline is why experienced designs let a thread hold at most one lock.
3. ReentrantLock: a lock as an object
synchronized is a statement, so you can't ask it to "try to lock, but give up after 50 ms." java.util.concurrent.locks.ReentrantLock is the same mutual-exclusion idea in the form of an object, with a richer API [5]:
lock()andunlock();tryLock(), with an optional timeout;- a fairness option;
newCondition(), for separate wait sets.
The price of that power is that you must release the lock — always, in finally. Here's §2's deadlock defused, with the same locks, the same opposite order and the same 100 ms overlap. This time, a timed tryLock breaks hold-and-wait:
Output (illustrative — the number of back-offs varies per run; this is one real run):
t1: couldn't get the second lock, backing off
t2: couldn't get the second lock, backing off
t1: couldn't get the second lock, backing off
t2: got both locks, working
t1: got both locks, working
both finished — no deadlockAnalysis. Both threads hit the §2 collision: each holds its first lock and wants the other's. But this time, tryLock(50 ms) fails instead of parking forever.
- Each thread releases what it holds ("backing off"), which breaks hold-and-wait.
- It sleeps for a random beat, so the two don't collide the same way again, and then retries.
- Within a few rounds, one thread gets both locks and finishes, and then the other goes through.
The try/finally shape isn't optional style. synchronized releases its monitor on any exit, whether normal or through an exception [6]. ReentrantLock doesn't.
Intuition.
Mechanism. Reentrant means the thread holding the lock may acquire it again, just as with nested synchronized blocks on one object. tryLock(timeout) parks the thread with a deadline, and returns false when the deadline expires. newCondition() gives you await/signal/signalAll — §1's wait set as a named object. A lock can have several conditions, such as one for "not full" and one for "not empty", so you wake only the threads whose condition changed.
Concrete bite: unlock() outside finally. An exception between lock() and unlock() skips the unlock, and the lock then outlives the thread that took it:
Output:
worker caught: negative: -1
worker state: TERMINATED
lock still held: true
main can lock it: false
violation: the lock outlived the thread that took itThe worker is TERMINATED, yet the lock is still held, so every later lock() would wait forever. Here's the same scenario, using the Lock API's own try/finally shape [7]:
Output:
worker caught: negative: -1
worker state: TERMINATED
lock still held: false
main can lock it: trueEdge: the back-off needs its jitter. Without the random sleep, the back-off loop above can livelock. Two threads moving in perfect step could acquire, fail and release forever: always busy, never making progress. The random delay is load-bearing, not decoration.
Readers can share. Many structures are read far more often than they're written, and two readers can't corrupt each other. A ReadWriteLock holds a pair of locks: "The read lock may be held simultaneously by multiple reader threads, so long as there are no writers. The write lock is exclusive" [8].
Output:
main holds the read lock
another reader gets in: true
a writer gets in: false
after main unlocks, a writer gets in: true💡 Earned rule. Reach for ReentrantLock when you need something synchronized can't do: timed or interruptible acquisition, fairness, or several conditions per lock. Reach for ReentrantReadWriteLock when reads far outnumber writes. Otherwise, prefer synchronized: it's shorter, and it can't leak an unreleased lock.
The cost of an explicit lock is the discipline: every lock() needs a finally { unlock(); }, forever.
4. Synchronizers: CountDownLatch, Semaphore, CyclicBarrier
Most coordination needs boil down to a handful of recurring shapes, and java.util.concurrent ships them pre-built and pre-debugged. A CountDownLatch is a one-shot gate: threads await() until the count reaches zero. Two latches together give you the classic "start together, wait for all to finish" harness:
Output (illustrative — worker order varies per run; the first and last lines are guaranteed):
main: workers created, none has started
worker 1 running
worker 3 running
worker 2 running
main: all workers doneA Semaphore limits how many threads may be inside a region at once. It holds N permits: acquire() takes one, blocking if none are left, and release() returns one. Here, six threads compete for three permits, and the program proves that no more than three were ever inside at once:
Output:
6 threads ran; max inside at once: 3Analysis. Two synchronizers, two shapes:
- The latch demo separates coordination from work. All three workers were created up front, and parked on
start.await(). Nothing ran until main fired the starting gun, and thendone.await()held main until every worker had checked out. - In the semaphore demo, six threads raced for three permits. The high-water mark,
maxSeen, came out at exactly 3 on every run: the bound is enforced, not just probable.
The third synchronizer, CyclicBarrier, is a reusable meeting point for a fixed team. Each of N threads calls await() at the end of a phase. When all N have arrived, an optional barrier action runs once, all N are released together, and the barrier resets [9]. It's a good fit for iterative simulations, where round k+1 may start only once every thread has finished round k:
Output:
all 3 arrived: round 1 done
all 3 arrived: round 2 done
the same barrier served 2 roundsIntuition. Mechanism. All three are §1's pattern (state + lock + wait-until-condition), packaged with the loops and notifications already written for you:
- A latch's condition is
count == 0, and it never resets. - A semaphore's condition is
permits > 0, whichrelease()restores. - A barrier's condition is "all N have arrived", after which it resets.
Concrete bite. Choose by lifecycle, not by surface similarity. Both traps below are documented in the API, and both run without any error:
- A latch is "a one-shot phenomenon -- the count cannot be reset" [10]. Once it hits zero,
await()returns immediately, forever. Reuse it for round two, and round two doesn't wait at all. - A semaphore has no owner: "There is no requirement that a thread that releases a permit must have acquired that permit" [11]. Release on a path that never acquired, and you've just minted an extra permit.
Output:
permits after one acquire and two releases: 4
round 1 passed the latch
round 2 passed at once: the count is 0The semaphore now allows 4 threads, not 3, and it never complained. And if you need a gate to work a second time, that's your signal that you wanted a barrier.
💡 Earned rule. Before you write a guarded block, check the shelf:
- waiting for N completions is a latch;
- bounding concurrent access is a semaphore;
- a fixed team meeting between phases is a barrier.
The cost is a vocabulary to learn, and lifecycle rules to respect (one-shot vs reusable, ownerless permits). That's far cheaper than debugging a hand-rolled wait/notify under load.
5. BlockingQueue: coordination you don't have to write
Look back at §1: forty lines of careful monitor discipline, just for a bounded producer/consumer buffer. That pattern is so central that the library ships it whole. A BlockingQueue is a thread-safe queue where put() blocks while the queue is full, and take() blocks while it's empty [12]. Here's §1, done by the library:
Output (illustrative — the interleaving varies per run; this is one real run):
produced 1
produced 2
produced 3
consumed 1
produced 4
consumed 2
produced 5
consumed 3
consumed 4
consumed 5
consumer: poison pill — shutting downAnalysis. All of §1's machinery (the while/wait guards, the notifyAll calls, the capacity checks) now lives inside put and take.
- The slow consumer applies backpressure through the bounded capacity. At most two items wait in the queue, and then the producer blocks. However fast production gets, memory can't balloon.
- The poison pill is a sentinel value enqueued after the last real item. It travels through the same channel as the data, so the consumer drains everything already produced, then stops. There's no separate "please stop" flag to synchronize.
- One pill stops one
take()loop, so enqueue one pill per consumer.
The diagram is the shape to remember: the queue is the only shared state, and the library owns it.
Intuition.
Mechanism. In OpenJDK 25, ArrayBlockingQueue is a fixed ring buffer guarded by one ReentrantLock with two Conditions, notEmpty and notFull [13]. That's §3's tools arranged into §1's design, waking only the side whose condition changed. Two relatives keep the same contract, with different trade-offs [12]:
LinkedBlockingQueueis optionally bounded. Its source uses separate locks for the head and the tail, so a producer and a consumer don't contend with each other.SynchronousQueuehas no capacity at all: "each insert operation must wait for a corresponding remove operation by another thread". It's a direct hand-off.
Concrete bite. The tempting shortcut is a plain ArrayDeque guarded by synchronized. But then empty and full become your problem again. poll() returns null, and you're back to busy-polling in a sleep loop, or to hand-written wait/notify. The blocking in BlockingQueue isn't a convenience feature — it is the coordination.
💡 Earned rule. Design concurrent programs as stages connected by blocking queues. Each thread owns its own data, and ownership transfers through the queue, instead of threads sharing structures under locks. Reach for raw wait/notify only when no tool on the shelf fits.
The cost: a queue per stage adds hand-off latency, plus a capacity to tune. Too small, and it throttles; too large, and it hides problems and hoards memory. Shutdown also needs a protocol: poison pills, or the interruption that the executors in the next lesson give you.
6. Mental-model summary
| Principle | Consequence |
|---|---|
| Coordination = "wait until a condition on shared state is true" | One idea under wait/notify, Condition, latches, semaphores, queues |
wait() releases the monitor and sleeps; notifyAll() wakes the wait set to re-check |
Guarded block: synchronized + while (!cond) wait(); — always while, never if |
| Two locks + opposite acquisition order = cycle = deadlock | No exception, only silence; prevent with global lock ordering (or don't hold two) |
| A deadlock needs all four Coffman conditions | Break one: order the locks, or time out and release |
ReentrantLock makes the lock an object: tryLock(timeout), interruptible, several Conditions |
Timed acquisition breaks hold-and-wait; the price is manual finally { unlock(); } |
A ReadWriteLock shares the read lock, never the write lock |
Readers run together; a writer waits for all readers to leave |
| Latch = one-shot countdown gate; semaphore = N ownerless permits; barrier = reusable team rendezvous | Check the shelf before writing a guarded block; respect each one's lifecycle |
BlockingQueue packages the §1 pattern: put blocks when full, take when empty |
Stages + queues + transferred ownership beat shared structures + locks |
7. Gotcha checklist
| Symptom | Likely cause | Fix |
|---|---|---|
IllegalMonitorStateException: current thread is not owner |
wait/notify called without holding that object's monitor |
call both inside synchronized on the same object |
A woken thread finds its condition false, e.g. NoSuchElementException |
the guard is an if |
use while: wakeups are shared, late, and occasionally spurious |
A thread waits forever after notify() |
the one woken thread was not the one whose condition changed | use notifyAll() unless every waiter is interchangeable |
| The program stops, with no exception and no output | a deadlock | jstack <pid> shows the cycle; fix the lock ordering |
A ReentrantLock is never released; later lock() calls hang |
a return or exception skipped unlock() |
lock(); try { … } finally { unlock(); } |
| Two threads back off and retry forever | livelock: they retry in step | add a random delay before the retry |
| A latch "stopped working" the second time | latches are one-shot | a CyclicBarrier for a reusable phase gate |
| A semaphore lets in more threads than its permits | release() on a path that never acquired |
release only after a successful acquire() |
| With several consumers, some never shut down | one poison pill stops one consumer | enqueue one pill per consumer, or use executor shutdown |
✅ Check yourself
One check per objective. Answer before you open anything.
The 🧪 box below: if with two consumers; a lucky run of §2; a semaphore released twice.
- With
if, the line that throws isitems.removeFirst()intake. Both consumers wait on an empty buffer; one item arrives, andnotifyAll()wakes both of them. The first to re-acquire the lock takes the item. The second skips the check and callsremoveFirst()on an emptyArrayDeque, which throwsNoSuchElementException, as the §1 anti-pattern shows. - Yes: without the
pause()calls, 28 of 30 runs completed. The luck removes circular wait: one thread took both locks before the other took any, so no cycle formed. maxSeencan reach 4, because a secondrelease()adds a fourth permit. TheSemaphoredoesn't object, since a permit has no owner. The §4 example printspermits after one acquire and two releases: 4.
📚 Sources
- The Java Language Specification, Java SE 25, §17.2 "Wait Sets and Notification" ("Every object, in addition to having an associated monitor, has an associated wait set") — https://docs.oracle.com/javase/specs/jls/se25/html/jls-17.html#jls-17.2
java.lang.Object.wait, Java SE 25 API (spurious wakeups;IllegalMonitorStateException) — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/lang/Object.html#wait(long,int)- The
jstackcommand, JDK 25 documentation — https://docs.oracle.com/en/java/javase/25/docs/specs/man/jstack.html - E. G. Coffman, M. J. Elphick and A. Shoshani, "System Deadlocks", ACM Computing Surveys 3(2), 67–78, 1971 — https://doi.org/10.1145/356586.356588
java.util.concurrent.locks.ReentrantLock, Java SE 25 API — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/locks/ReentrantLock.html- The Java Language Specification, Java SE 25, §14.19 "The
synchronizedStatement" (the monitor is unlocked whether the body completes normally or abruptly) — https://docs.oracle.com/javase/specs/jls/se25/html/jls-14.html#jls-14.19 java.util.concurrent.locks.Lock, Java SE 25 API (thelock(); try { … } finally { unlock(); }idiom) — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/locks/Lock.htmljava.util.concurrent.locks.ReadWriteLock, Java SE 25 API — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/locks/ReadWriteLock.htmljava.util.concurrent.CyclicBarrier, Java SE 25 API ("cyclic because it can be re-used after the waiting threads are released") — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/CyclicBarrier.htmljava.util.concurrent.CountDownLatch, Java SE 25 API ("a one-shot phenomenon") — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/CountDownLatch.htmljava.util.concurrent.Semaphore.release(), Java SE 25 API — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/Semaphore.html#release()java.util.concurrent.BlockingQueue,LinkedBlockingQueueandSynchronousQueue, Java SE 25 API — https://docs.oracle.com/en/java/javase/25/docs/api/java.base/java/util/concurrent/BlockingQueue.html- OpenJDK 25 source,
ArrayBlockingQueue.java(oneReentrantLock,ConditionsnotEmptyandnotFull) — https://github.com/openjdk/jdk25u/blob/master/src/java.base/share/classes/java/util/concurrent/ArrayBlockingQueue.java
🧪 Predict, then check.
- Predict what §1's buffer does if both
wait()calls usedifinstead ofwhile, with two consumer threads. Which line can now throw, and under what wakeup order? - Predict whether §2's program can ever complete normally on a lucky run, and which of the four deadlock conditions the luck removes.
- Predict
maxSeenin the semaphore demo if one thread's code path calledrelease()twice, and whether theSemaphorewould object.
Your Turn
Before you move on, check your understanding with the coach — explain the idea, apply it, weigh the trade-offs, then defend your reasoning.