Synchronizers
Synchronizers
Thread pools and futures let you run work concurrently, but sometimes threads need to coordinate with each other — one thread must wait until others finish, a limited resource must not be overloaded, or a group of threads must all reach the same point before any of them continue. The java.util.concurrent package ships three purpose-built synchronizers for these scenarios: CountDownLatch, Semaphore, and CyclicBarrier. Each solves a distinct coordination problem, and choosing the right one makes concurrent code dramatically simpler and safer than trying to achieve the same effect with raw wait/notify or manual flags.
CountDownLatch — Wait for N Events to Happen
A CountDownLatch is initialised with a count. Any thread can call await() to block until the count reaches zero. Other threads call countDown() to decrement the count. Once the count hits zero the latch opens permanently — all waiting threads are released and subsequent await() calls return immediately.
This classic "race-start" pattern uses two latches: one so the coordinator waits until every worker is standing by, and one so the coordinator can detect when all work is complete. Note that the start latch is initialised with 1 — a single countDown() releases all waiting workers simultaneously.
CountDownLatch cannot be reset. If you need to reuse the same barrier, use CyclicBarrier instead (covered below). For one-shot startup gates, service-readiness checks, and test synchronisation a latch is ideal.
A simpler but equally common use case: wait until N parallel service calls have all completed before proceeding.
countDown() in a finally block. If the worker throws an exception and countDown() is missed, any thread blocked in await() will wait forever. The finally guarantee is the only safe pattern.
Semaphore — Limit Concurrent Access to a Resource
A Semaphore controls access to a limited resource by maintaining a set of permits. A thread calls acquire() to obtain a permit (blocking if none are available) and release() when it is done. Unlike a mutex (which is either locked or unlocked), a semaphore can hold any number of permits — so you can allow, say, exactly 3 threads to access a database connection pool simultaneously.
Run this and you will see at most three "acquired" messages before the first "released" message — the semaphore enforces the cap at runtime.
Fairness: By default, Semaphore uses a non-fair policy — threads waiting to acquire are not served in arrival order. Pass true to the constructor to get a fair (FIFO) semaphore. Fair semaphores have lower throughput but prevent starvation, which matters for long-running, heavily contended resources.
tryAcquire(timeout, unit) — so callers fail fast if the resource is overloaded rather than piling up indefinitely.
A semaphore with one permit behaves like a mutex — but it is not reentrant. Unlike synchronized or ReentrantLock, a different thread can call release() on a semaphore that was acquired by another thread. This makes semaphores useful for producer/consumer signalling, not just mutual exclusion.
CyclicBarrier — Synchronise a Group of Threads at a Common Point
A CyclicBarrier is initialised with a party count. Each participating thread calls await() when it reaches the barrier. The last thread to arrive triggers an optional barrier action (a Runnable run in that thread), then all threads are released. The barrier automatically resets for the next cycle — hence "cyclic".
The key difference from a latch: the barrier resets after each round. After the four threads all call await() in round 1, the barrier resets and the same threads can call await() again in round 2 without creating a new object.
BrokenBarrierException? If any thread is interrupted or times out while waiting at a barrier, the barrier enters a broken state and all threads currently or subsequently waiting at that barrier receive a BrokenBarrierException. This prevents the common deadlock where one thread dies and the rest wait forever. Always handle both InterruptedException and BrokenBarrierException when calling barrier.await().
A typical real-world use is parallel data processing pipelines: a large matrix or dataset is divided into N chunks, each worker processes its chunk in phase 1, all workers synchronise, then each worker processes its chunk in phase 2 using results from phase 1, and so on. The cyclic reset makes this pipeline pattern concise.
Choosing the Right Synchronizer
- CountDownLatch — one-shot wait: a coordinator waits for N events. Think service startup gates, test synchronisation, and join-all patterns where reuse is not needed.
- Semaphore — resource throttling: limit how many threads hold a resource at the same time. Think connection pools, rate limiters, and access guards.
- CyclicBarrier — multi-phase coordination: N threads must all reach each phase boundary together before any of them proceed. Think parallel algorithms with synchronisation points between phases, simulation steps, and bulk data processing rounds.
AtomicInteger and a while loop to wait for a condition, step back — one of these three synchronizers, or a Phaser for more complex multi-phase scenarios, will almost certainly give you the same behaviour with far less code and far fewer bugs.
Summary
CountDownLatch is a one-shot gate: threads wait until an event count reaches zero. Semaphore enforces concurrency limits on a shared resource using acquire/release permits. CyclicBarrier makes a group of threads rendezvous at repeating checkpoints, optionally running a barrier action before releasing them. All three are battle-tested tools from java.util.concurrent that replace brittle, error-prone manual synchronisation. Always release permits and decrement latches in finally blocks, handle BrokenBarrierException at every barrier, and use timed variants to avoid indefinite blocking in production code.