Skip to main content

CountDownLatch vs CyclicBarrier vs Phaser vs Semaphore in Java

CountDownLatch, CyclicBarrier, and Phaser implement the same three-round pipeline three ways – reuse semantics, dynamic registration, and a timeout that permanently breaks one of them but not the others. Semaphore solves a genuinely different problem.

One worker in a batch pipeline ran slow, its round barely finished before the timeout, and a CyclicBarrier.await(timeout) call threw. Nobody thought much of it – one straggler, one timeout, log it and move on. Except every worker after that one, for the rest of the run, started throwing BrokenBarrierException immediately on arrival – including workers that had nothing to do with the slow one and were nowhere near it in the code. A single timeout doesn’t just fail the thread that hit it; it permanently breaks a CyclicBarrier for every other waiter until something explicitly calls reset(). That’s one of several ways these four classes – genuinely useful, genuinely confusable – behave nothing alike once you push on them, verified below rather than assumed.
Versions. Primary runtime JDK 25.0.4.1+1 (Temurin, LTS), with JDK 21.0.10 (OpenJDK) used only for the virtual-thread pinning comparison near the end. JUnit 5.11.0 for the correctness tests. All on a 2-vCPU x86-64 virtual machine – the same sandbox as the rest of this series.

One pipeline, four classes – and Semaphore isn’t answering the same question

Every demo below except one runs the identical shape: 4 worker threads process 3 rounds of work, and every worker must finish round N before any of them starts round N+1. CountDownLatch, CyclicBarrier, and Phaser all answer that same question – “has everyone reached this point?” – with different trade-offs. Semaphore answers a different question – “how many callers are allowed through at once?” – and gets listed alongside the other three constantly anyway, usually without anyone saying out loud that it isn’t solving the same problem. This article keeps them straight by running Semaphore through the same pipeline in a role that’s actually its own: bounding concurrent access to a shared resource, independent of round boundaries entirely.

CountDownLatch: the starting gate, and why it can’t do rounds twice

CountDownLatch does two things well: releasing a group of threads at once from a single “go” signal, and counting down to a one-time completion event. Neither is naturally “repeat this barrier every round” – a latch counts down to zero exactly once and can never be reset, so a multi-round barrier needs a fresh latch instance for every round, allocated up front:
CountDownLatch startGate = new CountDownLatch(1);
// ... spawn workers, each doing startGate.await() before any real work ...
startGate.countDown(); // releases all of them at once

// One latch PER ROUND - a CountDownLatch cannot be reused once it hits zero
CountDownLatch[] roundLatches = { new CountDownLatch(WORKERS), new CountDownLatch(WORKERS), new CountDownLatch(WORKERS) };
$ java CountDownLatchPipeline

main: all 4 workers created, still waiting at the start gate
main: releasing the start gate
worker-0: finished round 1
worker-1: finished round 1
worker-2: finished round 1
worker-3: finished round 1
worker-0: finished round 2
worker-1: finished round 2
worker-2: finished round 2
worker-3: finished round 2
worker-0: finished round 3
worker-1: finished round 3
worker-2: finished round 3
worker-3: finished round 3
done
Output: 01-countdownlatch-pipeline.txt, source: CountDownLatchPipeline.java. It works, but the pre-allocated latch array is exactly the bookkeeping the next class automates.

Going deeper on this section

CyclicBarrier: the same barrier, reused automatically

One CyclicBarrier instance carries all three rounds, because it resets itself the moment every party arrives – no array, no per-round allocation. It also runs an optional barrier action exactly once per round, on whichever thread happens to arrive last, which is the right place for round-boundary bookkeeping that shouldn’t run once per worker:
CyclicBarrier barrier = new CyclicBarrier(WORKERS, () -> {
    roundCounter++;
    log("barrier action: round " + roundCounter + " complete");
});

// every round, every worker:
barrier.await(); // same instance every time - resets on its own
$ java CyclicBarrierPipeline

worker-0: finished round 1
worker-1: finished round 1
worker-2: finished round 1
worker-3: finished round 1
barrier action: round 1 complete, all 4 workers arrived
worker-0: finished round 2
worker-1: finished round 2
worker-2: finished round 2
worker-3: finished round 2
barrier action: round 2 complete, all 4 workers arrived
worker-0: finished round 3
worker-1: finished round 3
worker-2: finished round 3
worker-3: finished round 3
barrier action: round 3 complete, all 4 workers arrived
done
Output: 02-cyclicbarrier-pipeline.txt, source: CyclicBarrierPipeline.java. The barrier action fires exactly once per round, after all four workers – not four times, and never before the last arrival.

Going deeper on this section

Phaser: everything CyclicBarrier does, plus parties that change mid-run

Phaser covers the same reusable-barrier ground as CyclicBarrier (onAdvance plays the same role as the barrier action), but its party count isn’t fixed at construction. A worker can register() itself into a phaser that’s already mid-run, and the very next phase correctly waits for it – something that needs building an entirely new CyclicBarrier and re-pointing every existing worker at it to do with that class. Here, a fifth worker joins only after round 1 has already completed:
// worker-0, right after finishing round 1:
phaser.register();
log("worker-4 registers after round 1 (phaser now has " + phaser.getRegisteredParties() + " parties)");
new Thread(() -> runWorker(4, phaser, log, /* startRound */ 2)).start();
$ java PhaserPipeline

worker-1: finished round 1
worker-2: finished round 1
worker-0: finished round 1
worker-3: finished round 1
phaser onAdvance: phase 1 complete, 4 registered parties -> advancing
worker-4: registers after round 1 (phaser now has 5 parties)
worker-0: finished round 2
worker-1: finished round 2
worker-2: finished round 2
worker-3: finished round 2
worker-4: finished round 2
phaser onAdvance: phase 2 complete, 5 registered parties -> advancing
worker-0: finished round 3
worker-1: finished round 3
worker-2: finished round 3
worker-3: finished round 3
worker-4: finished round 3
phaser onAdvance: phase 3 complete, 5 registered parties -> advancing
done
Output: 03-phaser-pipeline.txt, source: PhaserPipeline.java. Round 2’s onAdvance reports 5 registered parties, not 4 – worker-4 is genuinely part of the barrier from round 2 on, and none of workers 1 through 3 needed to change anything.

Going deeper on this section

Semaphore: a different question entirely

Same pipeline shape, but the constraint Semaphore enforces here has nothing to do with rounds: only 2 workers may call a simulated expensive shared resource concurrently, at any point, regardless of which round they’re in or which worker they are. A separate CyclicBarrier still handles the round boundaries – the two concerns are independent and composed, not substitutes for each other:
Semaphore resourceGuard = new Semaphore(2, true); // 2 permits, fair

resourceGuard.acquire();
try {
    // ... call the shared resource ...
} finally {
    resourceGuard.release();
}
roundBarrier.await(); // unrelated concern, same as before
$ java SemaphoreResourceGuard

worker-2 round 1: acquired permit, 2 callers concurrently in the resource (availablePermits=0)
worker-0 round 1: acquired permit, 1 callers concurrently in the resource (availablePermits=0)
worker-3 round 1: acquired permit, 2 callers concurrently in the resource (availablePermits=0)
worker-1 round 1: acquired permit, 2 callers concurrently in the resource (availablePermits=0)
...
permits=2 maxObservedConcurrency=2
done
Output: 04-semaphore-resource-guard.txt, source: SemaphoreResourceGuard.java. Across all 12 acquisitions (4 workers × 3 rounds), maxObservedConcurrency never exceeds the 2 permits available – confirmed by an assertion-based test, not just eyeballed from the log, with 30 concurrent callers against 3 permits.

Going deeper on this section

Timeouts: not the same failure mode

Forcing each class to time out – one expected arrival that never comes – shows they don’t fail the same way at all:
$ java TimeoutBehaviorDemo

=== CountDownLatch.await(timeout): expects 2, only 1 ever counts down ===
await returned: false (false = timed out, latch still usable to re-check later)
getCount() after timeout: 1

=== CyclicBarrier.await(timeout): expects 2 parties, only 1 arrives ===
await threw TimeoutException, as expected
barrier.isBroken() after the timeout: true
a second, later await() on the SAME barrier: BrokenBarrierException immediately - one timeout poisons the barrier for everyone until reset()

=== Phaser.awaitAdvanceInterruptibly(phase, timeout): expects 2 parties, only 1 arrives ===
awaitAdvanceInterruptibly threw TimeoutException, as expected
phaser.getPhase() after the timeout: 0 (unchanged - still phase 0, not advanced or broken)
a later round on the SAME phaser, once both parties actually arrive: succeeds normally, now at phase 1

=== Semaphore.tryAcquire(timeout): 0 permits available ===
tryAcquire returned: false (false = timed out, no exception, no broken state)
a later tryAcquire after a release: true - fully independent of the earlier timeout
Output: 05-timeout-behavior.txt, source: TimeoutBehaviorDemo.java.
The gap that matters in practice. CountDownLatch and Semaphore just report the timeout and stay usable. CyclicBarrier transitions into a permanently broken state – confirmed above by a second, unrelated await() throwing BrokenBarrierException immediately, with no timeout of its own – and stays broken until something calls reset(). Phaser throws the same TimeoutException for the phase that missed it, but the phaser itself is untouched: a later round that actually gets everyone to arrive completes normally, no reset required. If any round in a multi-round barrier might legitimately be slow sometimes, that difference alone is a reason to reach for Phaser over CyclicBarrier.

Going deeper on this section

Virtual threads: all four were already safe

None of these four classes are built on synchronized – they’re AQS-based (or, for Phaser, a comparable compare-and-swap state machine), parking through LockSupport. That means none of them ever pinned a virtual thread’s carrier, on any JDK version, including JDK 21 – years before JEP 491 fixed the synchronized case. Run on JDK 21 with -Djdk.tracePinnedThreads=full, blocking a virtual thread inside each one in turn:
$ java -Djdk.tracePinnedThreads=full VirtualThreadCompatibilityDemo   (pre-JDK-24 runtime)

CountDownLatch.await(): completed, no trace above this line for it
CyclicBarrier.await(): completed, no trace above this line for it
Phaser.arriveAndAwaitAdvance(): completed, no trace above this line for it
Semaphore.acquire(): completed, no trace above this line for it
done - no pinning trace printed above for any of the four
Output: 06-virtual-thread-compatibility.txt. This repo started with a different assumption for the contrast case – that hand-rolling the same wait with synchronized + Object.wait() would pin, the way synchronized + a blocking call did before JEP 491. It doesn’t:
$ java -Djdk.tracePinnedThreads=full SynchronizedWaitPinsDemo   (pre-JDK-24 runtime, contrast case)

synchronized + Object.wait(): completed, no pinning trace above this line for it
VirtualThread[#21]/runnable@ForkJoinPool-1-worker-1 reason:MONITOR
    ...
    com.ankurm.synchronizers.SynchronizedWaitPinsDemo.lambda$main$1(SynchronizedWaitPinsDemo.java:48) <== monitors:1
synchronized + Thread.sleep(): completed, pinning trace (if any) appears above this line for it
Reported honestly, not smoothed over. Object.wait() fully releases the monitor for the duration of the wait – there’s nothing held while parked, so there’s nothing to pin. What actually pinned pre-JDK-24 was a blocking call made while still holding the monitor, like Thread.sleep() inside synchronized – which the second half of this same transcript still reproduces, on the identical JDK 21 runtime, right below the wait() case that doesn’t. Getting this wrong first and then checking it is a better argument for verifying than getting it right would have been.

Going deeper on this section

Decision table

QuestionCountDownLatchCyclicBarrierPhaserSemaphore
What does it answer?Has this one-time event happened?Have all parties reached this point?Have all (possibly changing) parties reached this point?How many callers are in here right now?
Reusable across rounds?No – one fresh instance per roundYes, automaticallyYes, automaticallyN/A – not round-based at all
Party count can change mid-run?NoNo – fixed at constructionYes – register()/arriveAndDeregister()Yes – permits can be released or acquired by anyone
Per-round hook?NoYes – barrier actionYes – onAdvanceNo
Effect of one timeoutLocal – just returns falseGlobal – breaks the barrier for everyone until reset()Local to that phase – later phases unaffectedLocal – just returns false
Pinned virtual threads pre-JDK-24?Never – not synchronized-basedNever – not synchronized-basedNever – not synchronized-basedNever – not synchronized-based
Reach for it whenA one-time “go” signal, or you’re fine hand-rolling per-round instancesA fixed group of workers repeatedly synchronizing at round boundariesThe same, but the group’s membership can grow or shrinkBounding concurrent access to something scarce – not a barrier substitute

Further reading

No Comments yet!

Leave a Reply

This site uses Akismet to reduce spam. Learn how your comment data is processed.