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

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:

  1. Before parking — the initial gate check. If state is already 0, the thread never parks.
  2. 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 pathenq() (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:

  1. First call: Predecessor’s waitStatus is 0 (default). CAS it to SIGNAL(-1). Returns false → loop back, re-try tryAcquireShared.
  2. Second call: Predecessor’s waitStatus is now SIGNAL. Returns true → proceed to parkAndCheckInterrupt()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 from unparkSuccessor)
  • 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 InterruptedExceptionfinallycancelAcquire(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:

  1. parkAndCheckInterrupt() returns true (interrupt detected)
  2. doAcquireSharedInterruptibly throws InterruptedException
  3. The thread is removed from the sync queue via cancelAcquire()
  4. 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 = falsereturn 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.
  • prev links are the source of truth. next links are a best-effort optimization. All critical traversals go backward via prev (see unparkSuccessor, shouldParkAfterFailedAcquire). A stale next link is harmless.
  • For CountDownLatch: cancelAcquire is triggered by interrupt during await(). The latch count is unaffected — other threads still need to call countDown().

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.next is null (mid-enqueue race — node CAS’d tail but hasn’t set pred.next yet)
  • node.next is 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 sees c == 0, returns false).
  • 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 calls doReleaseShared() 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:

  • unparkSuccessor falls back to backward traversal when next is null or CANCELLED
  • shouldParkAfterFailedAcquire walks backward to skip cancelled predecessors
  • cancelAcquire walks 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 has waitStatus = 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.


Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐