synchronizers: CountDownLatch vs CyclicBarrier vs Phaser vs Semaphore

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
This commit is contained in:
2026-09-30 06:43:52 +00:00
co-authored by Claude Sonnet 5
parent 0819993c51
commit 55ee850a75
20 changed files with 962 additions and 0 deletions
+80
View File
@@ -0,0 +1,80 @@
# 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
```bash
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](../LICENSE).