One three-round worker pipeline implemented with CountDownLatch (one latch per round), CyclicBarrier (reusable, with a barrier action), and Phaser (reusable, plus dynamic mid-run registration), alongside Semaphore solving the genuinely different problem of bounding concurrent access. Covers timeout behavior (CyclicBarrier permanently breaks on one timeout, Phaser doesn't) and virtual-thread compatibility, including the corrected finding that Object.wait() releases its monitor and never pinned, unlike Thread.sleep() inside synchronized. Co-Authored-By: Claude Sonnet 5 <[email protected]> Claude-Session: https://claude.ai/code/session_01FhzLY5p6okFva3qsnsRyvM
synchronizers
Companion code for the ankurm.com post "CountDownLatch vs CyclicBarrier vs Phaser vs Semaphore
in Java." Fifth module in java-core-examples, the Java-core / concurrency series.
The same three-round worker pipeline, implemented once per class where it's a natural fit
(CountDownLatch, CyclicBarrier, Phaser), plus Semaphore solving the genuinely different
problem it actually solves (bounding concurrent access, not waiting for everyone to arrive) inside
the same pipeline shape. Reuse semantics, timeout behavior, and virtual-thread compatibility are
each demonstrated directly rather than asserted.
Versions this was built and tested against
| Component | Version | Notes |
|---|---|---|
| JDK (primary) | 25.0.4.1+1 (Temurin, LTS) | |
| JDK (comparison) | 21.0.10 (OpenJDK) | For the virtual-thread pinning contrast only. |
| JUnit Jupiter | 5.11.0 | Correctness tests, 20 repeats per barrier test. |
| Maven | 3.9.11 | |
| Hardware | 2 vCPU x86-64 VM | Same sandbox as the rest of this series. |
Quickstart
export JAVA_HOME=/path/to/jdk-25
mvn package
java -cp target/classes com.ankurm.synchronizers.CyclicBarrierPipeline
java -cp target/classes com.ankurm.synchronizers.PhaserPipeline
java -cp target/classes com.ankurm.synchronizers.SemaphoreResourceGuard
scripts/run-all.sh regenerates every file in output/ (needs JDK25_HOME and JDK21_HOME).
What's in here
| File | What it shows |
|---|---|
.../CountDownLatchPipeline.java |
A starting-gate latch, plus one fresh latch per round - the workaround CyclicBarrier exists to remove. |
.../CyclicBarrierPipeline.java |
One reusable barrier across all three rounds, with a barrier action. |
.../PhaserPipeline.java |
Same barrier behavior, plus a fifth worker dynamically registering after round 1. |
.../SemaphoreResourceGuard.java |
The same pipeline, but Semaphore bounding concurrent access to a shared resource - a different concern from the other three. |
.../TimeoutBehaviorDemo.java |
What each class does when the thing it's waiting for never arrives. |
.../VirtualThreadCompatibilityDemo.java |
None of the four pin a virtual thread, even on JDK 21, because none are built on synchronized. |
.../SynchronizedWaitPinsDemo.java |
The contrast case, and a correction: synchronized + Object.wait() does NOT pin (it releases the monitor); synchronized + Thread.sleep() does. |
src/test/.../RoundOrderingCorrectnessTest.java |
Asserts no worker's round N+1 ever starts before every worker's round N finished (CyclicBarrier, Phaser, 20 repeats each), and Semaphore never exceeds its permit count. |
output/01-04 |
Each pipeline's real run. |
output/05 |
Timeout behavior across all four. |
output/06 |
Virtual-thread compatibility, JDK 21, both the four synchronizers and the synchronized+wait/sleep contrast. |
output/07 |
JUnit correctness run. |
Reading the results honestly (2-vCPU sandbox)
Semaphore isn't a fourth way to do what the other three do. CountDownLatch, CyclicBarrier,
and Phaser all answer "has everyone reached this point?" Semaphore answers "how many callers
are allowed through at once?" - a genuinely different question, not a stylistic alternative.
output/04 shows Semaphore bounding a resource to 2 concurrent callers, independent of round
boundaries or worker identity; forcing it into a "wait for the group" role would misrepresent what
it's for.
A timeout doesn't do the same thing on all four (output/05). CountDownLatch.await(timeout)
returns false and stays reusable to check again. Semaphore.tryAcquire(timeout) behaves the same
way. CyclicBarrier.await(timeout) throws TimeoutException and permanently breaks the barrier
for every other waiter until an explicit reset() - one straggler poisons the whole group.
Phaser.awaitAdvanceInterruptibly(phase, timeout, unit) throws TimeoutException too, but the
phaser itself is untouched and a later, fully-arrived round on the same instance succeeds normally
with no reset needed. That difference alone is a reason to reach for Phaser over CyclicBarrier
in anything where a slow round shouldn't take down every other round after it.
Object.wait() doesn't pin a virtual thread - Thread.sleep() inside the same synchronized
block does (output/06), and this repo initially assumed otherwise before checking. wait()
fully releases the monitor for the duration of the wait, so there's nothing held while parked and
nothing to pin; JEP 491 (JDK 24) only needed to fix the case where a blocking call runs while
still holding the monitor. All four java.util.concurrent classes in this module were already
safe on virtual threads on JDK 21, years before JEP 491, because none of them are built on
synchronized in the first place - a fact worth knowing before assuming every pre-JDK-24
concurrency primitive needed the same fix.
License
MIT - see the repo-wide LICENSE.