Skip to content
JavaAgentic

Type at least two characters. Try “RAG”, “pgvector” or “tool calling”.

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.

Intermediate6 min readUpdated
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

  • CountDownLatch is one-shot and asymmetric: it cannot be reset.
  • CyclicBarrier is reusable and symmetric: everyone waits for everyone, then it resets.
  • Semaphore bounds concurrency — its best real use is a bulkhead around a dependency.
  • Exchanger swaps objects between exactly two threads; Phaser is a barrier with a dynamic party count.
  • All of them are built on AbstractQueuedSynchronizer: one volatile int plus a wait queue.

CountDownLatch

wait for N events
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

synchronise repeated rounds
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.

CountDownLatchCyclicBarrier
ReusableNoYes
Who waitsDifferent threads than those countingThe participants themselves
TriggerCount reaches zeroAll parties arrive
Action on releaseNoneOptional barrier action
On failureWaiters hangBrokenBarrierException for all

Semaphore, and the bulkhead

bounding concurrent access
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 monitoring

The production-grade use is a bulkhead — one semaphore per downstream dependency, so a slow dependency cannot consume the entire thread pool:

DependencyBulkhead.java
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 — a two-thread rendezvous
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:

ClassWhat state represents
ReentrantLockHold count — 0 means free
SemaphoreRemaining permits
CountDownLatchRemaining count — 0 means open
ReentrantReadWriteLock16 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

a short decision list
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 ................... StructuredTaskScope

The 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?
A CountDownLatch is one-shot and asymmetric — some threads count down, others await, and once the count reaches zero the latch stays open forever. A CyclicBarrier is reusable and symmetric — every participating thread calls await, they all block until the last one arrives, then all proceed and the barrier resets. Use a latch to wait for a set of events; use a barrier to synchronise repeated rounds of work.
What is a semaphore used for in a real application?
Bounding concurrent access to a limited resource. The most valuable production use is a bulkhead: give each downstream dependency a semaphore with a small number of permits, so a slow dependency can only ever occupy that many threads and cannot consume the whole pool. It is also the standard way to rate-limit concurrent file handles, licences or expensive computations.
What is AbstractQueuedSynchronizer?
The framework almost every synchroniser in java.util.concurrent is built on — ReentrantLock, Semaphore, CountDownLatch, ReentrantReadWriteLock and the FutureTask internals. It manages a single volatile int of state plus a CLH queue of waiting threads, and subclasses define what acquiring and releasing that state means. Knowing it exists and that the state is one int is usually enough for an interview.

Related tutorials