CountDownLatch, Semaphore, CyclicBarrier and Phaser
The coordination primitives in java.util.concurrent, when a latch beats a barrier, using a semaphore as a bulkhead, and the AbstractQueuedSynchronizer that all of them are built on.
On this page
Beyond locks, java.util.concurrent provides coordination primitives — ways for threads to wait for
each other rather than to exclude each other. Most application code should use a higher-level
abstraction, but these appear constantly in tests, in framework code, and in interview questions.
Key Takeaways
CountDownLatchis one-shot and asymmetric: it cannot be reset.CyclicBarrieris reusable and symmetric: everyone waits for everyone, then it resets.Semaphorebounds concurrency — its best real use is a bulkhead around a dependency.Exchangerswaps objects between exactly two threads;Phaseris a barrier with a dynamic party count.- All of them are built on
AbstractQueuedSynchronizer: onevolatile intplus a wait queue.
CountDownLatch
public Report generate(List<String> sectionIds) throws InterruptedException {
CountDownLatch done = new CountDownLatch(sectionIds.size());
Map<String, Section> sections = new ConcurrentHashMap<>();
for (String id : sectionIds) {
pool.execute(() -> {
try {
sections.put(id, build(id));
} catch (Exception e) {
log.error("section {} failed", id, e);
} finally {
done.countDown(); // ALWAYS in finally, or the waiter hangs forever
}
});
}
if (!done.await(30, TimeUnit.SECONDS)) { // always use the timeout overload
throw new TimeoutException("report generation exceeded 30s");
}
return new Report(sections);
}Two rules the code above illustrates. countDown() belongs in a finally block — a task that throws
before counting down leaves every waiter blocked indefinitely. And await() should always carry a
timeout, so a bug becomes a bounded failure rather than a hang.
A latch has a second common use, the starting gate: initialise a latch of one, have all worker
threads await() on it, then countDown() once to release them simultaneously. That is how load
tests create a genuine thundering herd rather than a staggered ramp.
The defining limitation is that a latch is one-shot. Once the count reaches zero it stays there,
await() returns immediately forever, and there is no reset.
CyclicBarrier
int workers = 4;
CyclicBarrier barrier = new CyclicBarrier(workers, () -> {
// The barrier action: runs once per round, on the LAST thread to arrive,
// while all others are still blocked. Safe place to merge results.
mergeRoundResults();
});
for (int i = 0; i < workers; i++) {
pool.execute(() -> {
for (int round = 0; round < 100; round++) {
computePartition(round);
barrier.await(); // wait for the other three, then all continue
}
});
}The barrier resets automatically after each round, which is why it suits iterative algorithms — simulations, matrix computations, anything that proceeds in generations.
CountDownLatch | CyclicBarrier | |
|---|---|---|
| Reusable | No | Yes |
| Who waits | Different threads than those counting | The participants themselves |
| Trigger | Count reaches zero | All parties arrive |
| Action on release | None | Optional barrier action |
| On failure | Waiters hang | BrokenBarrierException for all |
Semaphore, and the bulkhead
Semaphore permits = new Semaphore(10);
permits.acquire(); // blocks until a permit is free
try {
doExpensiveThing();
} finally {
permits.release(); // ALWAYS in finally
}
permits.tryAcquire(100, MILLISECONDS); // give up rather than wait
permits.availablePermits(); // for monitoringThe production-grade use is a bulkhead — one semaphore per downstream dependency, so a slow dependency cannot consume the entire thread pool:
public class Bulkhead {
private final Semaphore permits;
private final String name;
public Bulkhead(String name, int maxConcurrent) {
this.name = name;
this.permits = new Semaphore(maxConcurrent);
}
public <T> T call(Supplier<T> work) {
// Fail fast rather than queue: a caller that cannot get a permit
// should degrade now, not wait behind a dependency that is already slow.
if (!permits.tryAcquire()) {
throw new BulkheadFullException(name + " at capacity");
}
try {
return work.get();
} finally {
permits.release();
}
}
}Without this, a recommendations service that starts taking thirty seconds will occupy every thread in
a shared pool, and an unrelated checkout endpoint stops responding. With it, recommendations fail
fast and checkout is untouched. This is the failure described in
Cascading failure, and Resilience4j's
Bulkhead is precisely this class with metrics attached.
A semaphore is not a lock: permits are not owned by a thread, so one thread may acquire and another
release. That makes it flexible and makes leaks easy — a missing release() permanently reduces
capacity until a restart, which manifests as a service that gets slower every day.
Exchanger and Phaser
Exchanger<List<Record>> exchanger = new Exchanger<>();
// Producer: fills a buffer, swaps it for the consumer's empty one.
List<Record> buffer = new ArrayList<>();
while (running) {
fill(buffer);
buffer = exchanger.exchange(buffer); // blocks until the consumer arrives
}Exchanger swaps objects between exactly two threads, both blocking until both arrive. Its niche is
double-buffering in a pipeline, where it avoids allocating a new buffer per round.
Phaser is a CyclicBarrier whose number of participants can change at runtime, via register()
and arriveAndDeregister(). It suits work that spawns or retires participants between phases — a
crawler where each round discovers a different number of pages. It is more capable and considerably
harder to reason about; for a fixed party count, prefer a barrier.
AbstractQueuedSynchronizer
Almost every synchroniser above shares one foundation. AbstractQueuedSynchronizer holds a single
volatile int state and a FIFO queue of waiting threads (a CLH queue of parked threads), and lets a
subclass define what acquiring and releasing that integer means:
| Class | What state represents |
|---|---|
ReentrantLock | Hold count — 0 means free |
Semaphore | Remaining permits |
CountDownLatch | Remaining count — 0 means open |
ReentrantReadWriteLock | 16 bits of read count, 16 of write count |
Acquiring is a CAS on state; failing to acquire parks the thread with LockSupport.park and
enqueues it. Releasing CASes the state back and unparks the queue head.
For an interview, that paragraph is enough. Knowing that these classes are not independent
implementations but one framework with different state semantics answers "how does ReentrantLock
work internally?" convincingly.
Choosing
Wait for N things to finish, once ........... CountDownLatch
Synchronise repeated rounds ................. CyclicBarrier
Rounds with a changing participant count .... Phaser
Limit concurrent access to a resource ....... Semaphore
Isolate a dependency from the pool .......... Semaphore (bulkhead)
Swap buffers between two threads ............ Exchanger
Wait for async results ...................... CompletableFuture — usually better than a latch
Fan out and join, Java 21 ................... StructuredTaskScopeThe last two lines matter. In modern application code, most latch usage is better expressed as
CompletableFuture.allOf or a StructuredTaskScope, both of which propagate exceptions properly
instead of leaving you to remember the finally. Latches survive mainly in tests, where waiting for
an async callback with a timeout is exactly the right tool.
What gets asked
CountDownLatch versus CyclicBarrier is the standard question, and the complete answer names
reusability, symmetry and the barrier action. Then "what would you use a Semaphore for?" — answer
with the bulkhead, not with a textbook resource pool, because the bulkhead is the version that solves
a real production problem.
Frequently Asked Questions
What is the difference between CountDownLatch and CyclicBarrier?
What is a semaphore used for in a real application?
What is AbstractQueuedSynchronizer?
Related tutorials
- Deadlock, Livelock and StarvationThe four conditions every deadlock needs and how breaking one prevents it, reading a deadlock out of a thread dump, lock ordering and tryLock, and the pool-starvation deadlock with no locks at all.
- Virtual Threads and Structured ConcurrencyHow a virtual thread mounts and unmounts from a carrier, why pinning on synchronized still matters, why pooling virtual threads is wrong, and what StructuredTaskScope adds over raw futures.
- CompletableFuture and Async CompositionComposing async work without blocking: thenApply versus thenCompose, which thread runs each stage, combining with allOf and anyOf, timeouts, and how exceptions propagate through a chain.
- ExecutorService and Thread-Pool SizingHow ThreadPoolExecutor decides whether to queue or grow, why newFixedThreadPool can exhaust the heap, sizing pools from measurements with Little law, and shutting down without losing work.