CountDownLatch Mechanism
CountDownLatch
A one-shot synchronization barrier. Threads call await() to block until the count reaches zero. Other threads call countDown() to decrement the count. Once zero, all waiting threads are released and the latch cannot be reused.
Document Structure:
- Basic Usage / AQS State — Overview
- await() Deep Dive — Gate check, enqueue, park, wake-up propagation, interrupt, cancellation
- countDown() Deep Dive — CAS decrement, wake-up engine, cascade
- Shared AQS Internals — Node waitStatus, PROPAGATE, prev/next links, memory visibility
- Behavior & Patterns — One-shot, common patterns, pitfalls, comparison
- FAQ — Common questions answered
Basic Usage
CountDownLatch latch = new CountDownLatch(3);
// Worker threads
executor.submit(() -> { doWork(); latch.countDown(); });
executor.submit(() -> { doWork(); latch.countDown(); });
executor.submit(() -> { doWork(); latch.countDown(); });
latch.await(); // blocks until count reaches 0
// All 3 workers done — proceed
// Timed version
if (!latch.await(10, TimeUnit.SECONDS)) {
// timeout — not all workers finished
handleTimeout();
}
AQS State = Remaining Count
CountDownLatch(3):
state = 3 → 3 events remaining
countDown(): CAS(state, 3, 2) → 2 remaining
countDown(): CAS(state, 2, 1) → 1 remaining
countDown(): CAS(state, 1, 0) → 0 → release all waiters!
await(): state != 0? → enqueue → park
state == 0? → return immediately (no blocking)
await() Deep Dive
Everything related to await(): the call flow, gate check, enqueue, parking, and wake-up propagation.
await() Call Flow — Where the Wait Actually Happens
A common misconception is that tryAcquireShared is the “wait.” It’s not — it’s a non-blocking gate check. The actual wait (thread suspension) happens in LockSupport.park() inside doAcquireSharedInterruptibly. Here’s the full flow:
await()
→ acquireSharedInterruptibly(1)
→ tryAcquireShared(1) ← GATE CHECK (non-blocking, just reads state)
state != 0 → returns -1 (closed)
state == 0 → returns 1 (open, skip everything below)
→ doAcquireSharedInterruptibly(1) ← only entered if gate check failed
→ addWaiter(SHARED) ← enqueue node into CLH queue
→ spin loop {
→ tryAcquireShared(1) ← RE-CHECK gate after each wake-up
→ shouldParkAfterFailedAcquire()
→ parkAndCheckInterrupt() ← ★ ACTUAL WAIT — LockSupport.park() suspends thread
}
→ setHeadAndPropagate() ← on success: promote to head, wake next
→ await() returns
→ doWork() ← user code runs here
tryAcquireShared is called twice per waiting thread:
- Before parking — the initial gate check. If state is already 0, the thread never parks.
- After being unparked — the re-check inside the spin loop. If state == 0, proceed; otherwise park again.
The actual thread suspension is LockSupport.park() — that’s where the thread yields its CPU time slice and sleeps until unpark() is called on it.
await() Source Code
// 1. CountDownLatch.await()
public void await() throws InterruptedException {
sync.acquireSharedInterruptibly(1);
}
// 2. AQS.acquireSharedInterruptibly() — entry point
public final void acquireSharedInterruptibly(int arg) {
if (Thread.interrupted()) throw new InterruptedException();
if (tryAcquireShared(arg) < 0) // ← GATE CHECK (non-blocking)
doAcquireSharedInterruptibly(arg); // ← only if gate is closed
}
// 3. AQS.doAcquireSharedInterruptibly() — enqueue, park, retry loop
private void doAcquireSharedInterruptibly(int arg) {
final Node node = addWaiter(Node.SHARED); // enqueue as SHARED node
for (;;) {
final Node p = node.predecessor();
if (p == head) {
int r = tryAcquireShared(arg); // ← GATE RE-CHECK (after wake-up)
if (r >= 0) { // state == 0 → success!
setHeadAndPropagate(node, r); // become head, wake next
return; // await() returns → doWork() runs
}
}
if (shouldParkAfterFailedAcquire(p, node) &&
parkAndCheckInterrupt()) // ★ ACTUAL WAIT — LockSupport.park() ★
throw new InterruptedException();
// woken up → loop back to tryAcquireShared
}
}
tryAcquireShared — The Gate Check
CountDownLatch’s “acquire” is inverted compared to Semaphore. It succeeds only when the count IS zero:
int tryAcquireShared(int acquires) {
return (getState() == 0) ? 1 : -1;
}
- Returns
1(positive) when state == 0 → gate is open, all threads pass through - Returns
-1(negative) when state != 0 → gate is closed, thread will be enqueued and parked
The acquires parameter is ignored — CountDownLatch doesn’t consume permits. It’s a pure gate: closed (state > 0) or open (state == 0).
Key distinction: tryAcquireShared itself never blocks. It’s a fast volatile read of state. The blocking decision is made by the caller (doAcquireSharedInterruptibly) based on the return value.
addWaiter() — Enqueue into the CLH Queue
Called by doAcquireSharedInterruptibly when tryAcquireShared fails (gate is closed). Appends a new node for the current thread to the tail of the CLH queue.
private Node addWaiter(Node mode) {
Node node = new Node(Thread.currentThread(), mode); // mode = Node.SHARED for CountDownLatch
// Fast path: try to append to tail without full enq()
Node pred = tail;
if (pred != null) {
node.prev = pred; // (1) link backward
if (CAS(tail, pred, node)) { // (2) CAS tail pointer
pred.next = node; // (3) link forward
return node;
}
}
enq(node); // Slow path: queue empty or CAS failed
return node;
}
Fast path (queue already initialized, no contention): One CAS to append. Steps (1)→(2)→(3) are the 3-step enqueue.
Slow path — enq() (queue empty or CAS contention):
private Node enq(final Node node) {
for (;;) {
Node t = tail;
if (t == null) { // Queue empty — initialize
if (CAS(head, null, new Node())) // Create dummy sentinel as head
tail = head; // tail = head = sentinel
// Loop again to append the actual node
} else { // Queue exists — append
node.prev = t; // (1) link backward
if (CAS(tail, t, node)) { // (2) CAS tail
t.next = node; // (3) link forward
return t;
}
}
}
}
Two cases in the spin loop:
- Queue empty: Creates a dummy sentinel node as head. This is why the head node never has a thread — it’s always a threadless placeholder. Then loops again to append the actual node.
- CAS contention: Another thread appended first. Retry with the updated tail.
The 3-Step Enqueue Race Window
Steps (1), (2), (3) are not atomic. Between step 2 (CAS tail succeeds) and step 3 (set pred.next), the node is logically in the queue but pred.next is still null:
Before addWaiter:
head(dummy) → [T-1 SIGNAL] ← tail
After step 2, before step 3 (race window):
head(dummy) → [T-1 SIGNAL] → next = null (!)
[T-2 SHARED] ← tail (prev → T-1, but T-1.next not set yet)
After step 3 (complete):
head(dummy) → [T-1 SIGNAL] → [T-2 SHARED, ws=0] ← tail
This race window is why setHeadAndPropagate checks s == null || s.isShared() — s == null doesn’t mean no waiters, it means a node might be mid-enqueue. See Why s == null Still Triggers doReleaseShared().
For CountDownLatch: addWaiter is always called with Node.SHARED mode, which enables the propagation cascade — setHeadAndPropagate checks s.isShared() before calling doReleaseShared() to wake the next waiter.
shouldParkAfterFailedAcquire() — Set SIGNAL and Decide to Park
Called in the spin loop of doAcquireSharedInterruptibly after tryAcquireShared fails. It ensures the predecessor’s waitStatus is SIGNAL before the current thread parks, so the predecessor knows to unpark us when it releases.
private static boolean shouldParkAfterFailedAcquire(Node pred, Node node) {
int ws = pred.waitStatus;
if (ws == Node.SIGNAL) // predecessor already set to SIGNAL
return true; // safe to park — predecessor will unpark us
if (ws > 0) { // predecessor is CANCELLED
do {
node.prev = pred = pred.prev; // skip cancelled nodes
} while (pred.waitStatus > 0);
pred.next = node;
return false; // re-check after cleanup (don't park yet)
}
// ws == 0 or PROPAGATE — set predecessor to SIGNAL
CAS(pred.waitStatus, ws, Node.SIGNAL);
return false; // re-check one more time before parking
}
This method is called twice before a thread actually parks:
- First call: Predecessor’s
waitStatusis 0 (default). CAS it to SIGNAL(-1). Returnsfalse→ loop back, re-trytryAcquireShared. - Second call: Predecessor’s
waitStatusis now SIGNAL. Returnstrue→ proceed toparkAndCheckInterrupt()→LockSupport.park().
The two-pass design is intentional — it gives the thread one more chance to check tryAcquireShared before committing to park. If the gate opened between the first and second call, the thread avoids an unnecessary park/unpark cycle.
T-2 enqueues behind T-1:
[T-1, ws=0] → [T-2, ws=0]
shouldParkAfterFailedAcquire (1st call):
T-1.waitStatus == 0 → CAS(0, SIGNAL) → return false
[T-1, ws=SIGNAL] → [T-2, ws=0]
tryAcquireShared → still fails (state != 0)
shouldParkAfterFailedAcquire (2nd call):
T-1.waitStatus == SIGNAL → return true → park T-2
This is why the wake-up cascade diagrams show [T-1 SIGNAL] — T-2 set T-1’s waitStatus to SIGNAL during its own enqueue, so that when T-1 is released, doReleaseShared() sees SIGNAL and knows to unpark T-2.
parkAndCheckInterrupt() — Suspend and Detect Interrupt
The actual thread suspension point. Called in the spin loop after shouldParkAfterFailedAcquire returns true.
private final boolean parkAndCheckInterrupt() {
LockSupport.park(this); // (1) suspend thread — blocks here
return Thread.interrupted(); // (2) check and CLEAR interrupt flag
}
Step 1 — LockSupport.park(this): Suspends the current thread until one of:
LockSupport.unpark(thread)is called (normal wake-up fromunparkSuccessor)Thread.interrupt()is called (interrupt wake-up)- Spurious wake-up (rare, OS-level)
The this parameter is the AQS instance — used only for diagnostics (shows up in thread dumps as parking to wait for <AQS instance>).
Step 2 — Thread.interrupted(): Checks whether the thread was interrupted AND clears the interrupt flag. Returns true if interrupted, false otherwise.
Why Thread.interrupted() (static, clears flag) instead of isInterrupted() (preserves flag)? Because if the flag isn’t cleared, the next LockSupport.park() call would return immediately (park checks the interrupt flag and returns instantly if set). Clearing ensures the thread can actually park again if it loops back.
Return value drives the caller’s behavior:
// In doAcquireSharedInterruptibly:
if (shouldParkAfterFailedAcquire(p, node) && // should we park? (true on 2nd call)
parkAndCheckInterrupt()) // park → wake → was it interrupt?
throw new InterruptedException(); // YES → cancel → finally → cancelAcquire
// NO → normal wake-up → loop back to tryAcquireShared
| Return | Meaning | What happens next |
|---|---|---|
false |
Woken by unpark() |
Loop back to tryAcquireShared — check if gate is open |
true |
Woken by interrupt | throw InterruptedException → finally → cancelAcquire(node) |
The && short-circuit means: if shouldParkAfterFailedAcquire returns false (first call, just set SIGNAL), parkAndCheckInterrupt is skipped entirely — the thread loops back to re-check the gate without parking.
setHeadAndPropagate() — Promote and Cascade
Called inside doAcquireSharedInterruptibly when tryAcquireShared returns >= 0 (success). It does two things: promote the current node to head, then decide whether to keep waking the next waiter.
private void setHeadAndPropagate(Node node, int propagate) {
Node h = head; // (1) save old head before we overwrite it
setHead(node); // (2) promote current node to head
if (propagate > 0 // (a) tryAcquireShared says resources remain
|| h == null // (b) defensive — shouldn't happen
|| h.waitStatus < 0 // (c) old head has SIGNAL(-1) or PROPAGATE(-3)
|| (h = head) == null // (d) defensive re-read after setHead
|| h.waitStatus < 0) // (e) new head (us) has signal pending
{
Node s = node.next;
if (s == null || s.isShared())
doReleaseShared(); // (3) wake the next waiter
}
}
Step-by-Step Walkthrough
Step 1 — Save old head: h = head captures the current head before we replace it. This old head’s waitStatus is checked later as a fallback signal (conditions c and e).
Step 2 — setHead(node): Promotes our node to head. Internally:
private void setHead(Node node) {
head = node;
node.thread = null; // head node never holds a thread reference
node.prev = null; // detach from predecessor (GC-friendly)
}
After this, the old head is detached and eligible for GC. Our node is now the sentinel (head nodes are always threadless placeholders).
Step 3 — Propagation decision: The five conditions form a layered safety net. They’re evaluated with short-circuit OR — the first true skips the rest:
| Condition | What it checks | When it matters |
|---|---|---|
(a) propagate > 0 |
tryAcquireShared returned > 0 |
Primary check. For CountDownLatch, always 1 — this always fires |
(b) h == null |
Old head was null | Defensive. Shouldn’t happen in practice |
© h.waitStatus < 0 |
Old head had SIGNAL or PROPAGATE | Critical for Semaphore where propagate can be 0 |
(d) (h = head) == null |
New head (us) is null after re-read | Defensive. Note: h is reassigned here |
(e) h.waitStatus < 0 |
New head (us) has signal pending | Catches concurrent release between setHead and this check |
For CountDownLatch, condition (a) always fires because tryAcquireShared returns 1 (never 0). The cascade never needs the fallback conditions. Conditions © and (e) are the safety net for Semaphore where propagate can be 0 and a concurrent release() might have set PROPAGATE(-3) on the head — see The PROPAGATE (-3) Race It Solves.
Step 4 — s == null || s.isShared(): Check if the next node is a shared waiter before calling doReleaseShared(). For CountDownLatch, all nodes are SHARED (enqueued via addWaiter(Node.SHARED)), so s.isShared() is always true. The s == null case handles the mid-enqueue race — see Why s == null Still Triggers doReleaseShared().
Step 5 — doReleaseShared(): Unpark the next waiter, continuing the cascade. See doReleaseShared() — The Wake-Up Engine.
CountDownLatch-Specific Flow
For CountDownLatch, the path through setHeadAndPropagate is always the same — condition (a) fires immediately:
T-1 wakes up:
tryAcquireShared(1) → state == 0 → return 1
setHeadAndPropagate(nodeT1, propagate=1):
h = head(dummy) // save old head (the sentinel)
setHead(nodeT1) // T-1 is now head, dummy detached for GC
propagate=1 > 0 → true // condition (a) fires, skip (b)-(e)
s = nodeT1.next // T-2's node
s.isShared() → true // all CountDownLatch nodes are SHARED
→ doReleaseShared() // unpark T-2
→ return from doAcquireSharedInterruptibly
→ await() returns
→ doWork() // user code runs
Every woken thread follows this exact path. The cascade terminates when the last thread’s node.next is null and doReleaseShared() finds h == tail (empty queue).
Interruption Handling
await() uses acquireSharedInterruptibly, which means it responds to interrupts:
latch.await(); // throws InterruptedException if thread is interrupted while waiting
If a thread is interrupted while parked in the sync queue:
parkAndCheckInterrupt()returnstrue(interrupt detected)doAcquireSharedInterruptiblythrowsInterruptedException- The thread is removed from the sync queue via
cancelAcquire() - The interrupt does NOT affect the latch count — other threads still need to
countDown()
Thread-1: await() → parked
Thread-2: await() → parked
Thread-1: interrupted!
→ throws InterruptedException
→ removed from queue
→ latch count unchanged (still waiting for countDown calls)
Thread-2: still parked, unaffected
The timed version adds TimeoutException-like behavior (returns false on timeout):
if (!latch.await(10, TimeUnit.SECONDS)) {
// timeout — latch count never reached 0
// latch is NOT broken (unlike CyclicBarrier)
// other threads still waiting are unaffected
}
Key difference from CyclicBarrier: a timeout or interrupt in CountDownLatch only affects the individual thread. In CyclicBarrier, it breaks the barrier for everyone.
cancelAcquire() — Removing a Node from the Queue
Called in the finally block of AQS acquire methods when the thread fails to acquire. The pattern is always:
boolean failed = true; // assume failure
try {
for (;;) { /* spin loop */
// ... success path sets failed = false and returns ...
// ... interrupt/timeout exits via throw or return ...
}
} finally {
if (failed) cancelAcquire(node); // clean up if we didn't succeed
}
| Trigger | Method | How it exits | cancelAcquire called? |
|---|---|---|---|
| Interrupt | doAcquireSharedInterruptibly |
throw InterruptedException |
Yes |
| Timeout | doAcquireSharedNanos |
return false (time expired) |
Yes |
| Success | any | failed = false → return |
No |
It removes the node from the CLH queue and ensures the successor isn’t stranded.
private void cancelAcquire(Node node) {
if (node == null) return;
node.thread = null; // (1) clear thread reference
Node pred = node.prev;
while (pred.waitStatus > 0) // (2) walk backward past CANCELLED nodes
node.prev = pred = pred.prev;
Node predNext = pred.next; // (3) save for CAS below
node.waitStatus = CANCELLED; // (4) mark as CANCELLED (plain write, not CAS)
if (node == tail && CAS(tail, node, pred)) { // Case 1: we are the tail
CAS(pred.next, predNext, null);
} else {
int ws;
if (pred != head &&
((ws = pred.waitStatus) == Node.SIGNAL ||
(ws <= 0 && CAS(pred.waitStatus, ws, Node.SIGNAL))) &&
pred.thread != null) {
Node next = node.next;
if (next != null && next.waitStatus <= 0)
CAS(pred.next, predNext, next); // Case 2: splice us out
} else {
unparkSuccessor(node); // Case 3: we are head's successor
}
node.next = node; // (5) self-link for GC
}
}
Three cases based on position:
| Position | Action | Result |
|---|---|---|
| Tail | CAS tail to predecessor, null out pred.next | Simple removal |
| Middle | CAS pred.next to our successor (splice out) | Predecessor links directly to our successor |
| Head’s successor | unparkSuccessor(node) — wake our successor |
Successor wakes, re-links itself via shouldParkAfterFailedAcquire |
Key details:
- Step 4 is a plain write, not CAS. CANCELLED (1) is a terminal state — no thread CAS’s to or from CANCELLED. Unconditional write is safe and faster.
prevlinks are the source of truth.nextlinks are a best-effort optimization. All critical traversals go backward viaprev(seeunparkSuccessor,shouldParkAfterFailedAcquire). A stalenextlink is harmless.- For CountDownLatch:
cancelAcquireis triggered by interrupt duringawait(). The latch count is unaffected — other threads still need to callcountDown().
unparkSuccessor() — Wake the Next Non-Cancelled Thread
Called from doReleaseShared() (cascade wake-up) and cancelAcquire() (wake successor of cancelled node). Wakes the first non-cancelled thread after the given node.
private void unparkSuccessor(Node node) {
int ws = node.waitStatus;
if (ws < 0)
CAS(node.waitStatus, ws, 0); // (1) best-effort clear — failure is OK
Node s = node.next; // (2) try forward link (fast path)
if (s == null || s.waitStatus > 0) { // (3) null or CANCELLED → slow path
s = null;
for (Node t = tail; t != null && t != node; t = t.prev) // (4) walk backward from tail
if (t.waitStatus <= 0)
s = t; // (5) closest non-cancelled successor
}
if (s != null)
LockSupport.unpark(s.thread); // (6) wake it
}
Fast path (step 2-3): node.next is non-null and not CANCELLED → unpark it. O(1).
Slow path (step 4-5): Walk backward from tail via prev links to find the closest non-cancelled successor. Needed when:
node.nextis null (mid-enqueue race — node CAS’d tail but hasn’t setpred.nextyet)node.nextis CANCELLED (immediate successor gave up)
Why backward? prev links are always reliable (set atomically during enqueue). next links can be stale or null. Walking backward from tail is guaranteed to find all live nodes.
Step 1 — CAS to 0: The if (ws < 0) guard catches any negative value (SIGNAL=-1, PROPAGATE=-3, CONDITION=-2). In practice:
| Caller | Node’s ws when called | CAS effect |
|---|---|---|
doReleaseShared() |
0 (already CAS’d SIGNAL→0) | ws < 0 is false → CAS skipped |
cancelAcquire() (Case 3) |
CANCELLED (1) | ws < 0 is false → CAS skipped |
Exclusive-mode release() |
SIGNAL (-1) | CAS(-1, 0) → clears it |
When called from doReleaseShared(), this CAS is redundant — doReleaseShared() already CAS’d SIGNAL→0 before calling unparkSuccessor. The real duplicate-unpark prevention is the SIGNAL→0 CAS in doReleaseShared(). The CAS here exists for other callers (like exclusive-mode release) where the caller didn’t already clear waitStatus.
unpark() is idempotent: Calling it multiple times before the thread parks is harmless. The permit is a boolean, not a counter. And unpark() before park() is also safe — the park() returns immediately.
FIFO Ordering — Only Head’s Successor Tries to Acquire
In doAcquireSharedInterruptibly, only the head’s immediate successor calls tryAcquireShared:
for (;;) {
final Node p = node.predecessor();
if (p == head) { // ← only head's successor gets to try
int r = tryAcquireShared(arg);
// ...
}
// everyone else parks
}
If a thread is the tail with other nodes in front, it skips tryAcquireShared entirely and parks — even if the gate is open. It waits for the cascade to reach it. This enforces FIFO ordering, which matters for Semaphore (prevents permit stealing) and is harmless for CountDownLatch (the gate stays open).
countDown() Deep Dive
Everything related to countDown(): the CAS decrement, wake-up engine, exit condition analysis, and the full cascade walkthrough.
countDown() Call Flow — Decrement and Conditional Wake
countDown() is non-blocking — it never parks the calling thread. It’s a CAS decrement with a conditional wake-up trigger on the 1→0 transition.
countDown()
→ sync.releaseShared(1)
→ tryReleaseShared(1) ← CAS DECREMENT (non-blocking)
c > 0 → CAS(state, c, c-1)
c == 0 → return false (no-op, already open)
c-1 == 0 → return true ← ONLY on the 1→0 transition
c-1 > 0 → return false ← not zero yet, no wake
→ if tryReleaseShared returned true:
doReleaseShared() ← WAKE first waiter, start cascade
→ countDown() returns ← caller never enters the queue
Key behaviors:
- Non-blocking: The calling thread does a CAS and returns. It never enters the CLH queue.
- Concurrent-safe: Multiple threads can call
countDown()simultaneously. The CAS loop retries on contention — no decrements are lost. - Single trigger: Only the thread that performs the 1→0 transition calls
doReleaseShared(). All other decrements (3→2, 2→1) return silently. - Idempotent past zero: Calling
countDown()when state is already 0 is a no-op (CAS loop seesc == 0, returnsfalse). - Memory visibility: The CAS is a volatile write, establishing a happens-before edge to any subsequent
await()return (volatile read). See Memory Visibility.
tryReleaseShared — The CAS Decrement
boolean tryReleaseShared(int releases) {
for (;;) {
int c = getState();
if (c == 0)
return false; // already zero — nothing to do
int nextc = c - 1;
if (CAS(state, c, nextc))
return nextc == 0; // true ONLY when count hits 0
}
}
The releases parameter is ignored — countDown() always decrements by 1.
Return value drives AQS behavior:
false→ AQS does nothing (count still > 0, or already 0)true→ AQS callsdoReleaseShared()to wake all waiting threads
Concurrent countDown() — CAS Retry
Thread-A: countDown() → CAS(state, 3, 2) → succeeds → state = 2, return false
Thread-B: countDown() → CAS(state, 3, 2) → FAILS (state is now 2)
→ retry → CAS(state, 2, 1) → succeeds → state = 1, return false
Thread-C: countDown() → CAS(state, 1, 0) → succeeds → state = 0, return true
→ doReleaseShared() → unpark T-1 → cascade begins
Only Thread-C (the 1→0 transition) triggers the wake-up. The CAS loop guarantees no decrements are lost regardless of concurrency.
doReleaseShared() — The Wake-Up Engine
Called from two places: releaseShared() (when countDown() hits zero) and setHeadAndPropagate() (when a woken thread propagates).
Important: doReleaseShared() only unparks one thread per invocation — the head’s immediate successor. It does NOT wake all threads in a single call. The “wake all” effect is a relay: each woken thread calls setHeadAndPropagate() → doReleaseShared(), which unparks the next one.
doReleaseShared() [countDown caller] → unpark T-1 only → exit
T-1 wakes → setHeadAndPropagate → doReleaseShared() → unpark T-2 only → exit
T-2 wakes → setHeadAndPropagate → doReleaseShared() → unpark T-3 only → exit
...
On multi-core machines there’s some parallelism — the countDown() caller’s doReleaseShared() might still be spinning when T-1 wakes on another core and helps unpark T-2. But the CAS ensures only one thread succeeds per node.
private void doReleaseShared() {
for (;;) {
Node h = head;
if (h != null && h != tail) { // queue has waiters
int ws = h.waitStatus;
if (ws == Node.SIGNAL) { // -1: successor needs unparking
if (!CAS(h.waitStatus, SIGNAL, 0))
continue; // CAS failed, retry
unparkSuccessor(h); // wake up next thread
}
else if (ws == 0 && // no signal needed yet
!CAS(h.waitStatus, 0, PROPAGATE))
continue; // CAS failed, retry
}
if (h == head) // head unchanged → done
break; // head changed → loop again
}
}
Three scenarios:
| Head waitStatus | Action | Why |
|---|---|---|
| SIGNAL (-1) | CAS → 0, then unparkSuccessor(h) |
Successor is parked, wake it up |
| 0 | CAS → PROPAGATE (-3) | Leave a breadcrumb for future threads so they know to keep propagating |
| head changed | Loop again | Another thread became head concurrently, process the new head |
The h == head break condition is the termination check. If the head changed during this iteration, a new thread acquired the resource and became head — loop back to check if that new head also needs propagation.
Why the spin loop matters: multiple threads can call doReleaseShared() concurrently (the countDown() caller + woken threads propagating). The CAS + loop ensures exactly one thread performs each unpark, and the h == head check ensures no wake-ups are missed when the queue is changing rapidly.
The ws == 0 Branch — No Unpark, Just a Breadcrumb
When head.waitStatus == 0, doReleaseShared() does NOT call unparkSuccessor. It only plants the PROPAGATE(-3) breadcrumb and exits. No thread is woken in this branch.
When does head.waitStatus == 0 happen? In a narrow timing window where the head’s successor hasn’t called shouldParkAfterFailedAcquire yet (which would set SIGNAL):
T-1 (just became head via setHeadAndPropagate): T-2 (enqueuing behind T-1):
setHead(nodeT1) → head.ws = 0 addWaiter(SHARED) → enqueued
doReleaseShared(): (hasn't called shouldParkAfterFailedAcquire yet)
ws == 0 → CAS(0, PROPAGATE)
→ h == head → break (no unpark!)
shouldParkAfterFailedAcquire:
T-1.ws == PROPAGATE(-3), which is <= 0
→ CAS(PROPAGATE, SIGNAL) → return false
tryAcquireShared → re-check gate
shouldParkAfterFailedAcquire:
T-1.ws == SIGNAL → return true → park
T-2 is NOT stranded. The PROPAGATE breadcrumb gets converted to SIGNAL by T-2’s shouldParkAfterFailedAcquire (the ws <= 0 branch CAS’s any non-positive value to SIGNAL). Once SIGNAL is set, the next doReleaseShared() call will see it and unpark T-2.
The lifecycle: doReleaseShared() sets PROPAGATE → shouldParkAfterFailedAcquire converts PROPAGATE to SIGNAL → future doReleaseShared() sees SIGNAL → unparks.
For CountDownLatch, this race is rare — by the time doReleaseShared() runs during the cascade, waiting threads are already parked with SIGNAL set on their predecessors. The ws == 0 branch mostly fires when a new thread is mid-enqueue during the cascade.
Deep Dive: The h == head Exit Condition
The only way to exit doReleaseShared() is h == head — the head didn’t change during this iteration. This has subtle implications.
What “stable head” means
h == head does NOT mean the queue is drained. It means: between reading h = head at the top and checking h == head at the bottom, no thread called setHead(). Three scenarios:
Case 1: Queue is empty (h == tail at the top)
→ Skipped the inner if block entirely
→ h == head → break
→ Queue is truly empty
Case 2: Unparked successor, but it hasn't run setHead() yet
→ Queue still has threads in it
→ But the unparked thread will handle propagation when it wakes
→ Safe to exit — responsibility is handed off
Case 3: Set PROPAGATE on ws==0 head
→ Breadcrumb planted for future threads
→ h == head → break
→ Queue may still have threads
Case 2 is the common case and the key insight: h == head is a handoff, not a drain check.
Why the unparked thread hasn’t called setHead() yet
unparkSuccessor calls LockSupport.unpark(thread), which returns instantly. It only marks the thread as “runnable” in the OS scheduler — it doesn’t mean the thread actually gets a CPU time slice immediately:
countDown() caller (running on CPU core 0):
├── CAS(SIGNAL → 0) ~5ns
├── LockSupport.unpark(T-1) ~50ns (syscall, marks T-1 runnable)
├── if (h == head) → true ~5ns ← T-1 hasn't run yet!
└── break → return → doWork()
... OS context switch ... (~1-10μs)
T-1 (now scheduled on CPU core 1):
├── returns from LockSupport.park()
├── tryAcquireShared() → 1
├── setHead(nodeT1) ← head changes HERE
└── doReleaseShared() → unpark T-2 → doWork()
The gap between unpark() returning and the target thread actually running is typically 1-10 microseconds (OS scheduling latency). The h == head check takes ~5 nanoseconds. So in the common case, the caller exits doReleaseShared() long before the unparked thread runs.
When h != head — the multi-core race
On a multi-core machine, the unparked thread can run on another core in parallel. If it calls setHead() before the caller reaches h == head:
Core 0 (countDown caller): Core 1 (T-1, just unparked):
unpark(T-1) park() returns
tryAcquireShared() → 1
setHead(nodeT1) ← head changed!
h == head? → NO
→ loop again
h = head(nodeT1)
h.waitStatus == SIGNAL → unpark T-2
h == head? → YES → break
In this case, the caller does an extra iteration and helps propagate. Both outcomes (exit immediately vs. help propagate) are correct — the design handles both races.
Concurrent doReleaseShared() callers
Every woken thread calls doReleaseShared() via setHeadAndPropagate (because propagate is always 1 for CountDownLatch). This means multiple threads spin in doReleaseShared() simultaneously:
countDown() caller: doReleaseShared() → unpark T-1 → h==head → break → return
T-1 wakes: doReleaseShared() → unpark T-2 → h==head → break → return
T-2 wakes: doReleaseShared() → unpark T-3 → h==head → break → return
T-3 wakes: doReleaseShared() → h==tail (empty) → h==head → break → return
If the countDown() caller’s doReleaseShared() is still spinning when T-1 wakes (multi-core race), they both try to unpark T-2. The CAS ensures only one succeeds — the loser’s CAS fails, it loops, and exits when h == head.
This is intentionally redundant for correctness: better to have multiple threads trying to propagate than to risk missing a wake-up. The overhead is just a few extra CAS operations (nanoseconds).
Does doReleaseShared() block threads from doing work?
No. doReleaseShared() is non-blocking in the sense that it never parks or waits for an external event. The spin loop only retries on CAS failure or head change — both resolve in nanoseconds. A thread typically exits in 1-2 iterations.
The flow for each woken thread is:
T-1 wakes → tryAcquireShared → setHeadAndPropagate → doReleaseShared()
→ unpark T-2 (or no-op) → h == head → break
→ await() returns
→ doWork() ← starts immediately after doReleaseShared() returns
All threads proceed to their work nearly concurrently. The propagation overhead per thread is just one doReleaseShared() call (~100ns including CAS + unpark).
The Wake-Up Cascade — Full Walkthrough
Detailed step-by-step showing queue state transitions:
Initial state — CountDownLatch(3), 3 threads called await():
AQS state = 3 (latch count)
Queue: head(dummy) → [T-1 SIGNAL] → [T-2 SIGNAL] → [T-3, 0] ← tail
waitStatus=-1 waitStatus=-1 waitStatus=0
─── countDown() × 3 ───
countDown(): CAS(state, 3, 2) → state=2, return false (no wake)
countDown(): CAS(state, 2, 1) → state=1, return false (no wake)
countDown(): CAS(state, 1, 0) → state=0, return true → doReleaseShared()
─── doReleaseShared() [called by countDown() caller] ───
h = head(dummy), h.waitStatus == SIGNAL(-1)
CAS(waitStatus, SIGNAL, 0) → success
unparkSuccessor(head) → unpark T-1
─── T-1 wakes up ───
tryAcquireShared(1) → state == 0 → return 1
setHeadAndPropagate(nodeT1, propagate=1):
h = head(dummy) // save old head
setHead(nodeT1) // T-1 becomes new head
propagate=1 > 0 → enter if block
s = nodeT1.next = nodeT2 // T-2 is next
s.isShared() → true
→ doReleaseShared()
h = head(nodeT1), h.waitStatus == SIGNAL(-1) // set by T-2 during enqueue
CAS(SIGNAL, 0) → unparkSuccessor(nodeT1) → unpark T-2
─── T-2 wakes up ───
tryAcquireShared(1) → state == 0 → return 1
setHeadAndPropagate(nodeT2, propagate=1):
h = head(nodeT1) // save old head
setHead(nodeT2) // T-2 becomes new head
propagate=1 > 0 → enter if block
s = nodeT2.next = nodeT3 // T-3 is next
s.isShared() → true
→ doReleaseShared()
h = head(nodeT2), h.waitStatus == 0 // T-3 is last, didn't set SIGNAL
ws == 0 → CAS(0, PROPAGATE) → breadcrumb set
h == head → break (no unpark needed, but T-3 will check)
─── T-3 wakes up (unparked by T-2's unparkSuccessor or the PROPAGATE path) ───
tryAcquireShared(1) → state == 0 → return 1
setHeadAndPropagate(nodeT3, propagate=1):
setHead(nodeT3) // T-3 becomes new head
propagate=1 > 0 → enter if block
s = nodeT3.next = null // no more nodes
s == null → doReleaseShared()
h = head(nodeT3), h == tail → skip (no waiters)
h == head → break → done
All 3 threads released ✓
Queue: head(nodeT3) ← tail (empty, only sentinel remains)
AQS state = 0 (permanently open)
Note on T-3’s wake-up: T-3’s predecessor (T-2’s node) had waitStatus=SIGNAL(-1) which was set by T-3 during its own enqueue via shouldParkAfterFailedAcquire. When T-2 called doReleaseShared(), it CAS’d T-2’s waitStatus to 0 and called unparkSuccessor which wakes T-3. The PROPAGATE breadcrumb on T-2’s node is a safety net for the race where T-3 hasn’t been unparked yet but T-2’s doReleaseShared() already ran.
Shared AQS Internals
Reference material shared by both await() and countDown() paths.
Node waitStatus Values Reference
| Value | Constant | Meaning |
|---|---|---|
| 0 | (default) | Initial state, no special status |
| -1 | SIGNAL | Successor thread needs to be unparked when this node releases |
| -2 | CONDITION | Node is on a condition queue (not used by CountDownLatch) |
| -3 | PROPAGATE | Shared release should propagate to subsequent nodes |
| 1 | CANCELLED | Thread cancelled waiting (timeout or interrupt) |
Why s == null Still Triggers doReleaseShared()
s == null (node.next is null) does NOT mean there are no more waiters. It means a new node might be mid-enqueue due to a race in the CLH queue’s addWaiter()/enq() method:
Thread A (current) Thread B (new waiter)
───────────────── ─────────────────────
setHead(nodeA)
Node s = node.next → null! addWaiter / enq(nodeB):
1. nodeB.prev = tail ✓
2. CAS(tail, nodeB) ✓ ← logically in queue
3. oldTail.next = nodeB ← NOT YET (next link gap)
The CLH enqueue is a 3-step process. Between steps 2 and 3, the new node is logically in the queue (reachable via tail and prev links) but node.next is still null because step 3 hasn’t executed yet.
Calling doReleaseShared() anyway ensures Thread B won’t be stranded — by the time doReleaseShared() re-checks the queue, step 3 has likely completed and the next link is visible.
Conservative approach: better to do a potentially unnecessary doReleaseShared() call than to miss waking a thread that’s already queued but not yet linked.
propagate Parameter vs PROPAGATE waitStatus (-3)
These are different things with confusingly similar names:
propagate (parameter) |
PROPAGATE (waitStatus = -3) |
|
|---|---|---|
| What | Return value of tryAcquireShared() |
A node’s waitStatus field value |
| Where | Parameter to setHeadAndPropagate() |
Stored in Node.waitStatus |
| Meaning | “How many resources remain after my acquire” | “A concurrent release happened, keep propagating” |
| Set by | tryAcquireShared() |
doReleaseShared() (CAS 0 → -3) |
For CountDownLatch, tryAcquireShared always returns 1 (propagate > 0), so condition (a) in setHeadAndPropagate always fires. The PROPAGATE(-3) breadcrumb mechanism is never exercised.
The PROPAGATE (-3) Race — Deferred to Semaphore
PROPAGATE solves a lost-wake-up race that only manifests when tryAcquireShared can return 0 (meaning “I acquired, but no resources remain”). This happens in Semaphore but never in CountDownLatch (where the return is always 1 or -1).
The short version: when propagate == 0, setHeadAndPropagate checks h.waitStatus < 0 as a fallback. If a concurrent doReleaseShared() set PROPAGATE(-3) on the head, this check catches it and continues the cascade. Without this, a release signal could be lost.
Full race scenario and analysis will be covered in semaphore.md.
prev Links vs next Links — Source of Truth
The CLH queue has two link directions with different reliability guarantees:
prev links |
next links |
waitStatus |
|
|---|---|---|---|
| Reliability | Always accurate | Best-effort optimization | CAS-protected |
| Set during | Step 1 of enqueue (before CAS) | Step 3 of enqueue (after CAS) | Various (shouldPark, doRelease) |
| Can be stale? | No | Yes (mid-enqueue race, cancelled nodes) | No (CAS ensures consistency) |
| Who writes | Only the node’s own thread | Any thread (predecessor’s successor) | Multiple threads (CAS) |
| Needs CAS? | No (single-writer) | Sometimes (cancelAcquire) |
Yes |
The Single-Writer Invariant on prev
Only a node’s own thread ever writes node.prev. This is the key design invariant that makes prev links reliable without CAS:
Where prev is written |
Who writes | Writing whose prev? |
|---|---|---|
addWaiter/enq: node.prev = pred |
Enqueueing thread | Its OWN node |
shouldParkAfterFailedAcquire: node.prev = pred = pred.prev |
Node’s own thread (skipping cancelled predecessors) | Its OWN node |
cancelAcquire: node.prev = pred = pred.prev |
Cancelling thread | Its OWN node |
setHead: node.prev = null |
Acquiring thread | Its OWN node (now head) |
No other thread ever writes node.prev. This is why backward traversal via prev is safe without locks or CAS — there’s no concurrent write race.
Contrast with next: In addWaiter, the enqueueing thread writes pred.next = node — that’s another node’s field. In cancelAcquire, CAS(pred.next, predNext, next) also modifies another node’s next. Multiple threads can race to write the same node’s next, which is why next needs CAS and is considered unreliable.
Why prev is Authoritative
node.prev = pred is set before the CAS on tail. If the CAS succeeds, prev is guaranteed correct. If it fails, the node isn’t in the queue yet and retries.
Why next Can Be Stale
pred.next = node is set after the CAS. Between the CAS and this write, pred.next is null (the 3-step enqueue race). Also, cancelled nodes may leave stale next pointers.
Consequence
All safety-critical code walks backward via prev:
unparkSuccessorfalls back to backward traversal whennextis null or CANCELLEDshouldParkAfterFailedAcquirewalks backward to skip cancelled predecessorscancelAcquirewalks backward to find a valid predecessor
Why pred.next = node in shouldParkAfterFailedAcquire doesn’t need CAS: Because next links are advisory. If the write races with another thread, the worst case is a stale next pointer — backward traversal via prev will still find the right node. The method returns false and retries anyway.
cancelAcquire Case 3 — Why Head’s Successor Can’t Splice Itself Out
When the cancelled node is head’s immediate successor (pred == head), cancelAcquire calls unparkSuccessor(node) instead of splicing. Why not just CAS(head.next, us, our_successor)?
Because head can change concurrently — another thread might be calling setHead() right now. A CAS on head.next could race with setHead() and corrupt the queue. Instead, we wake our successor and let it fix the links itself:
Before: head(dummy) → [T-1 CANCELLED] → [T-2] → [T-3] ← tail
cancelAcquire(T-1): pred == head → Case 3 → unparkSuccessor(T-1) → unpark T-2
T-2 wakes → shouldParkAfterFailedAcquire(pred=T-1, node=T-2):
T-1.ws == CANCELLED > 0 → walk backward → T-2.prev = head(dummy)
head.next = T-2 (re-link)
After: head(dummy) → [T-2] → [T-3] ← tail (T-1 orphaned, GC'd)
T-2’s shouldParkAfterFailedAcquire skips the cancelled T-1 via the backward walk, re-links itself as head’s direct successor, and proceeds.
Memory Visibility — Happens-Before Guarantee
countDown() is a volatile write (via tryReleaseShared → CAS on state). await() returning is a volatile read that succeeds. This establishes a happens-before edge:
Thread A: Thread B:
result = computeExpensiveThing(); // (1)
sharedMap.put("key", result); // (2)
latch.countDown(); // (3) volatile write (release)
latch.await(); // (4) volatile read (acquire)
sharedMap.get("key"); // (5) guaranteed to see (2)
System.out.println(result); // (6) guaranteed to see (1)
Everything before countDown() in Thread A is visible to Thread B after await() returns. This is the same happens-before guarantee as volatile writes/reads and synchronized block exits/entries.
This means you can safely publish results through shared data structures before calling countDown() — the waiting thread will see all those writes.
Happens-before chain (→hb = "happens-before", JLS §17.4.5):
(1) →hb (2) →hb (3) [program order within Thread A]
(3) →hb (4) [countDown() → await() via AQS volatile state]
(4) →hb (5) →hb (6) [program order within Thread B]
Therefore: (1) →hb (6) and (2) →hb (5) [transitivity]
"A →hb B" means all memory writes from A are guaranteed visible to B.
This is the JMM's formal guarantee — without it, Thread B could see
stale/default values even though Thread A wrote them "first" in wall-clock time.
Concurrency Safety of shouldParkAfterFailedAcquire
shouldParkAfterFailedAcquire has no concurrency issues because of how the CLH queue is structured:
Each node’s predecessor is unique to that node. When T-2 calls shouldParkAfterFailedAcquire(pred=T-1's node, node=T-2's node), it’s CAS’ing T-1.waitStatus from 0 to SIGNAL. The only thread that would CAS this specific node’s waitStatus for the 0→SIGNAL transition is T-2 (the immediate successor).
| Case | What happens | Why it’s safe |
|---|---|---|
ws == SIGNAL |
Pure read, returns true |
No mutation, no race |
ws > 0 (CANCELLED) |
Walk backward via prev to skip cancelled nodes |
prev links are set atomically; CANCELLED is terminal; only modifies our own node’s links |
ws == 0 or PROPAGATE |
CAS to SIGNAL | If CAS fails (concurrent doReleaseShared mutated it), returns false → outer loop retries |
The CAS failure is the concurrency protection. If someone else mutated the predecessor’s waitStatus between our read and CAS, we just retry. The spin loop in doAcquireSharedInterruptibly handles this naturally — any CAS failure means “try again,” and the outer loop guarantees progress.
Behavior & Patterns
One-Shot — Cannot Reset
Once the count reaches 0, the latch is permanently open. await() returns immediately forever after:
CountDownLatch latch = new CountDownLatch(1);
latch.countDown(); // count = 0
latch.await(); // returns immediately (count is already 0)
latch.await(); // returns immediately again
latch.await(); // always returns immediately
There is no reset() method. If you need a reusable barrier, use CyclicBarrier (see cyclic-barrier.md).
Common Patterns
Wait for N Tasks to Complete
int taskCount = 5;
CountDownLatch latch = new CountDownLatch(taskCount);
for (int i = 0; i < taskCount; i++) {
executor.submit(() -> {
try {
processTask();
} finally {
latch.countDown(); // always count down, even on failure
}
});
}
latch.await();
System.out.println("All tasks complete");
Starting Gun — Release N Threads Simultaneously
CountDownLatch startGun = new CountDownLatch(1);
for (int i = 0; i < 10; i++) {
executor.submit(() -> {
startGun.await(); // all threads wait here
doWork(); // all start simultaneously
});
}
// Setup complete — fire!
startGun.countDown(); // unparks T-1, which cascades to T-2, T-3... (nearly simultaneous)
Two-Phase: Ready + Go
CountDownLatch ready = new CountDownLatch(3); // workers signal ready
CountDownLatch go = new CountDownLatch(1); // coordinator signals go
for (int i = 0; i < 3; i++) {
executor.submit(() -> {
initialize();
ready.countDown(); // "I'm ready"
go.await(); // wait for coordinator
doWork();
});
}
ready.await(); // wait for all workers to be ready
// All initialized — start!
go.countDown();
Service Health Check
CountDownLatch healthCheck = new CountDownLatch(3);
executor.submit(() -> { checkDatabase(); healthCheck.countDown(); });
executor.submit(() -> { checkCache(); healthCheck.countDown(); });
executor.submit(() -> { checkMessaging(); healthCheck.countDown(); });
if (healthCheck.await(30, TimeUnit.SECONDS)) {
startAcceptingTraffic();
} else {
log.error("Health check timeout — some services not ready");
shutdown();
}
Pitfalls
1. Forgetting countDown() in finally
If a worker throws before countDown(), the latch never reaches zero and await() blocks forever:
// BUG — exception skips countDown
executor.submit(() -> {
processTask(); // throws RuntimeException
latch.countDown(); // never reached!
});
// FIX — always countDown in finally
executor.submit(() -> {
try {
processTask();
} finally {
latch.countDown(); // runs even on exception
}
});
2. Count Mismatch
If the count doesn’t match the number of countDown() calls, await() blocks forever:
CountDownLatch latch = new CountDownLatch(5);
// BUG — only 3 workers, but count is 5
for (int i = 0; i < 3; i++) {
executor.submit(() -> { doWork(); latch.countDown(); });
}
latch.await(); // blocks forever — count stuck at 2
// FIX — always use timed await as a safety net
if (!latch.await(30, TimeUnit.SECONDS)) {
log.error("Timed out — {} tasks didn't complete", latch.getCount());
}
3. Using CountDownLatch for Repeated Phases
// BAD — creating a new latch each iteration is wasteful
for (int phase = 0; phase < 100; phase++) {
CountDownLatch latch = new CountDownLatch(workers);
// ... submit work, await ...
}
// GOOD — CyclicBarrier resets automatically
CyclicBarrier barrier = new CyclicBarrier(workers);
for (int phase = 0; phase < 100; phase++) {
barrier.await();
}
4. Calling await() Without Timeout in Production
In production code, always prefer the timed version. A missing countDown() (due to a bug, crash, or thread pool exhaustion) will hang the calling thread indefinitely:
// RISKY in production
latch.await();
// SAFER
if (!latch.await(timeout, TimeUnit.SECONDS)) {
handleTimeout();
}
CountDownLatch vs CyclicBarrier
| Feature | CountDownLatch | CyclicBarrier |
|---|---|---|
| Reusable | No (one-shot) | Yes (resets after each phase) |
| Direction | N→1 (workers signal coordinator) | N↔N (threads wait for each other) |
| Who counts down | Any thread (even non-waiting) | Only waiting threads |
| Barrier action | No | Yes (runs when all arrive) |
| AQS-based | Yes (shared mode) | No (ReentrantLock + Condition) |
| Reset | Not possible | Automatic or manual |
FAQ / Deep Dive Clarifications
Q: In the wake-up diagram, is [T-1 SIGNAL] related to countDown()?
No. The queue diagram and the countDown() calls are two separate things:
[T-1 SIGNAL]means T-1’s node haswaitStatus = SIGNAL (-1). This was set by T-2 when it enqueued behind T-1 — T-2 sets its predecessor’s waitStatus to SIGNAL so that when the predecessor is released, it knows to unpark T-2. See shouldParkAfterFailedAcquire() for the full mechanism.- The
countDown()lines are about the latch state (the count), not the node waitStatus.
countDown() → return false means tryReleaseShared tells AQS “don’t wake anyone yet.” Only when it returns true (count hit 0) does AQS call doReleaseShared(), which then looks at the head node’s waitStatus (SIGNAL) and unparks T-1.
Q: Which threads are in the sync queue?
Only threads that called await(). The threads calling countDown() never enter the queue — they just CAS-decrement the state and return. The final countDown() caller (state 1→0) executes doReleaseShared() on its own call stack to wake T-1, but it’s never enqueued itself.
Q: Can multiple threads call countDown() at the same time?
Yes. See Concurrent countDown() — CAS Retry for the detailed race scenario. The CAS loop in tryReleaseShared handles concurrent decrements safely — only one thread performs the 1→0 transition and triggers doReleaseShared(). No decrements are lost.
Q: Does startGun.countDown() release all threads at once or one by one?
One by one, in a cascade. The countDown() caller only unparks T-1 directly. T-1 wakes, succeeds tryAcquireShared, and propagates by unparking T-2. T-2 unparks T-3, and so on. It’s a chain reaction via setHeadAndPropagate → doReleaseShared.
In practice this cascade happens in microseconds, and there’s some parallelism (the original doReleaseShared() and woken threads can race to unpark subsequent nodes). So while mechanically sequential, the wall-clock effect is nearly simultaneous.
Q: Is tryAcquireShared called before or after doWork()?
Before. tryAcquireShared is the non-blocking gate check inside await(), not the wait itself. See await() Call Flow for the full breakdown. The short version:
await()
→ tryAcquireShared(1) → state != 0 → fail → enqueue → park ← ACTUAL WAIT
... sleeping ...
→ unparked → tryAcquireShared(1) → state == 0 → success ← gate re-check
→ await() returns
→ doWork() ← only runs after tryAcquireShared succeeds
tryAcquireShared is called twice: once on entry (fails, gets parked), once after being woken (succeeds, proceeds). The wait is LockSupport.park(), not tryAcquireShared.
AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。
更多推荐



所有评论(0)