A CountDownLatch from java.util.concurrent answers one question: have N things happened yet? It starts with a count, threads that call await() block until the count reaches zero, and other threads drive the count down with countDown(). Once it reaches zero it stays there, every waiting thread is released, and every later await() returns immediately. It cannot be reset.

That small contract covers three jobs that come up constantly: waiting for a fixed set of tasks to finish, releasing a group of threads at the same instant, and handing results from workers to a coordinator with a memory-visibility guarantee. This article covers the API precisely, how it is built on AbstractQueuedSynchronizer, both gate patterns with code that compiles, timeouts and interruption, a worked startup example, how it compares with CyclicBarrier, Phaser, CompletableFuture and structured concurrency, and the bugs that make a latch hang.

Completion gate: N workers drive the count to zero, then await() returnsCoordinatornew CountDownLatch(3)worker AcountDown() t=40msworker BcountDown() t=95msworker Cthrows, finally countDown()AQS state (the count)3 start2 after A (CAS 3 to 2)1 after B (CAS 2 to 1)0 after C: release all waitersawait()parked in AQS queueuntil state == 0await() returnsreads results safelyhappens-before edgeparksunparkcountDown() at zero is a no-op; the count never goes back up, so the latch is single-use.await(timeout) returns false instead of waiting forever if a worker never counts down.
Each countDown() CAS-decrements the AQS state; the 1 to 0 transition unparks every waiter, and await() returning gives a happens-before edge.

The API, precisely

The whole class is five methods and a constructor, and each has a detail worth knowing.

MemberBehaviour
CountDownLatch(int count)throws IllegalArgumentException if count is negative; a count of 0 creates an already-open latch
void await()blocks until the count is zero; throws InterruptedException if the waiting thread is interrupted
boolean await(long, TimeUnit)returns true if the count reached zero, false if the timeout elapsed first; also interruptible
void countDown()decrements; at zero releases all waiters; when already zero it does nothing
long getCount()current count, for logging and diagnostics only
String toString()includes the count, e.g. [Count = 2]

Note what is missing: there is no way to increment, no reset, and no record of which party counted down. A thread may call countDown() several times, and nothing stops one buggy worker from counting down twice and releasing the gate while another worker is still running. The latch counts events, not threads.

The API, precisely

The whole class is five methods and a constructor, and each has a detail worth knowing.

MemberBehaviour
CountDownLatch(int count)throws IllegalArgumentException if count is negative; a count of 0 creates an already-open latch
void await()blocks until the count is zero; throws InterruptedException if the waiting thread is interrupted
boolean await(long, TimeUnit)returns true if the count reached zero, false if the timeout elapsed first; also interruptible
void countDown()decrements; at zero releases all waiters; when already zero it does nothing
long getCount()current count, for logging and diagnostics only
String toString()includes the count, e.g. [Count = 2]

Note what is missing: there is no way to increment, no reset, and no record of which party counted down. A thread may call countDown() several times, and nothing stops one buggy worker from counting down twice and releasing the gate while another worker is still running. The latch counts events, not threads.

Inside: a shared-mode AQS synchroniser

Internally the latch is a thin wrapper over AbstractQueuedSynchronizer (AQS) in shared mode, the same framework behind ReentrantLock, Semaphore and FutureTask. The AQS integer state holds the count. A simplified version of the JDK's inner class:

private static final class Sync extends AbstractQueuedSynchronizer {
    Sync(int count) { setState(count); }

    // await(): succeed if the count is already zero, otherwise queue and park
    protected int tryAcquireShared(int acquires) {
        return (getState() == 0) ? 1 : -1;
    }

    // countDown(): CAS-decrement; report "release waiters" only on the 1 -> 0 transition
    protected boolean tryReleaseShared(int releases) {
        for (;;) {
            int c = getState();
            if (c == 0) return false;          // already open: no-op
            int next = c - 1;
            if (compareAndSetState(c, next)) return next == 0;
        }
    }
}

Three consequences follow. First, countDown() is lock-free: a compare-and-set loop on a volatile int, cheap even with many workers. Second, waiting threads park through LockSupport.park, so a virtual thread blocked in await() unmounts from its carrier instead of pinning it. Third, because the state is read and written with volatile semantics, AQS supplies the memory-visibility edge described below at no extra cost.

Pattern 1: the completion gate

The completion gate is the common case: a coordinator starts N tasks and waits for all of them. The rule that prevents hangs is that countDown() goes in a finally block, so a task that throws still counts down. Record the failure separately, because the latch itself cannot tell success from failure.

List<String> shards = List.of("eu", "us", "apac");
CountDownLatch done = new CountDownLatch(shards.size());
Map<String, Integer> counts = new ConcurrentHashMap<>();
Queue<Throwable> errors = new ConcurrentLinkedQueue<>();

for (String shard : shards) {
    executor.execute(() -> {
        try {
            counts.put(shard, countRows(shard));
        } catch (Throwable t) {
            errors.add(t);
        } finally {
            done.countDown();                       // always, even on failure
        }
    });
}

if (!done.await(30, TimeUnit.SECONDS)) {
    throw new TimeoutException(done.getCount() + " shards still running");
}
if (!errors.isEmpty()) throw new IllegalStateException("shard failed", errors.peek());
int total = counts.values().stream().mapToInt(Integer::intValue).sum();

Use execute rather than submit here, or check the returned futures: an exception thrown inside a task passed to submit is captured in its Future and never printed, which hides failures if you forget the catch block.

Pattern 2: the start gate, done correctly

A latch with a count of 1 is a starting gun: many threads await it and one countDown() releases them together. Pairing it with a ready latch and a done latch lets a test measure N tasks running genuinely in parallel rather than staggered by thread start-up. The version often seen in tutorials calls start.await() directly inside a Runnable lambda, which does not compile because await() throws the checked InterruptedException. Handle it explicitly:

int n = 8;
CountDownLatch ready = new CountDownLatch(n);   // every thread is parked at the gate
CountDownLatch start = new CountDownLatch(1);   // the starting gun
CountDownLatch done  = new CountDownLatch(n);   // every thread has finished

for (int i = 0; i < n; i++) {
    Thread.ofPlatform().start(() -> {
        ready.countDown();
        try {
            start.await();
            hitEndpoint();
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();    // restore the flag and stop
        } finally {
            done.countDown();
        }
    });
}

ready.await();                 // all n threads are waiting at the gate
long t0 = System.nanoTime();
start.countDown();             // release them at once
done.await();
System.out.printf("%d calls in %d ms%n", n, (System.nanoTime() - t0) / 1_000_000);

Without the ready latch, the clock starts while some threads are still being created, and the measurement includes thread start-up rather than contention on the endpoint.

Timeouts and interruption

A plain await() in production code is a bet that every party will count down. Prefer the timed form, and decide in advance what a timeout means: fail the operation, proceed with partial results, or retry. The boolean result is the only signal you get, so do not ignore it; a common bug is calling done.await(5, SECONDS) and then reading results as if all workers had finished.

Interruption deserves the same care. await() throws InterruptedException when the waiting thread is interrupted, which is how executors cancel tasks during shutdown. Either propagate it or restore the flag with Thread.currentThread().interrupt(); swallowing it makes the thread impossible to stop cleanly. The workers are unaffected by an interrupted waiter: the latch keeps counting, so if the coordinator gives up, cancel the workers too, or they keep consuming resources for a result nobody will read.

Memory visibility

The Javadoc states the guarantee: actions in a thread prior to calling countDown() happen-before actions following a successful return from a corresponding await() in another thread. In the completion example, the coordinator can read whatever the workers wrote before counting down, even into plain fields or a plain array, without further synchronisation. See the Java memory model for what happens-before means precisely.

Two limits apply. The edge only covers writes made before countDown(), so a worker that keeps mutating a shared object afterwards is racing with the reader. And a timed await that returned false gives no guarantee about workers that have not counted down yet; treat their slots as unwritten.

Worked example: parallel cache warm-up before readiness

A service must load four caches (prices, catalogue, feature flags, tax rules) before its readiness probe reports healthy. Loading them sequentially takes 4 + 6 + 1 + 3 = 14 seconds; in parallel it takes about 6. The orchestrator gives the pod 20 seconds before restarting it.

CountDownLatch loaded = new CountDownLatch(loaders.size());
Map<String, Throwable> failures = new ConcurrentHashMap<>();

try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
    for (CacheLoader l : loaders) {
        pool.execute(() -> {
            try { l.load(); }
            catch (Throwable t) { failures.put(l.name(), t); }
            finally { loaded.countDown(); }
        });
    }
    boolean allDone = loaded.await(15, TimeUnit.SECONDS);   // under the 20 s probe budget
    if (!allDone || !failures.isEmpty()) {
        log.error("startup incomplete: pending={} failed={}", loaded.getCount(), failures.keySet());
        System.exit(1);                                        // let the orchestrator restart us
    }
}
readiness.markReady();

The 15 second timeout is shorter than the probe budget, so the service fails with a log line naming the slow loader rather than being killed silently. One subtlety: the try-with-resources close() waits for submitted tasks, so in the success path the latch is redundant with the executor's own wait. The latch earns its place because it gives a bounded wait and a pending count, which close() does not.

CountDownLatch versus the alternatives

Choose the latch when the number of events is known up front and the gate opens once. Otherwise:

ToolUse when
CyclicBarriera fixed group of threads must meet repeatedly, round after round; resets automatically
Phaserparties join and leave dynamically, or you need numbered phases
CompletableFuture.allOfthe work is already async and you want results, errors and composition
ExecutorService.invokeAllyou have a list of Callables and want their Futures when all are done
StructuredTaskScopesubtasks share a lifetime and failures should cancel siblings; still a preview API in recent JDKs
Semaphoreyou want to limit concurrency, not wait for completion

In new code that already uses futures, CompletableFuture or the futures from an ExecutorService usually read better, because they carry results and exceptions; the latch remains the simplest tool when the thing you wait for is an event rather than a value, such as a callback firing or a listener receiving its first message.

Testing asynchronous code with a latch

Latches are the standard way to make asynchronous code testable without sleeps. Count down inside the callback under test, then await with a timeout generous enough for a slow CI machine but short enough to fail fast when the callback never fires:

@Test
void publishesEventToSubscriber() throws Exception {
    CountDownLatch received = new CountDownLatch(1);
    AtomicReference<OrderEvent> seen = new AtomicReference<>();

    bus.subscribe(OrderEvent.class, e -> { seen.set(e); received.countDown(); });
    orders.place(new Order("o-1", 3));

    assertTrue(received.await(5, TimeUnit.SECONDS), "subscriber never called");
    assertEquals("o-1", seen.get().orderId());
}

The assertion on the boolean turns a hang into a clear failure message, and the happens-before edge means the test thread sees the event the subscriber stored. To check that something does not happen, await a short timeout and assert false, accepting that such negative tests can only ever be probabilistic.

Failure modes

  • Hang because a worker skipped countDown(). The exception path missed it. Put it in finally, and use a timed await.
  • Count too high. The latch was sized from a list that later got filtered, so N-1 tasks run. Size it from exactly the collection you iterate.
  • Gate opens early. One task counts down twice, for example in both a catch and a finally. Count down in exactly one place.
  • Reuse attempt. A spent latch stored in a field makes every later await return immediately. Create a new latch per round, or use CyclicBarrier.
  • Ignored timeout result. Partial results are read as complete. Branch on the boolean.
  • Thread starvation. The coordinator awaits on a thread from the same bounded pool the workers need, and with a small pool the workers never get a thread. Await from outside that pool, or use virtual threads.
  • Tests that sleep. Thread.sleep(500) to wait for a callback is flaky; count down in the callback and await with a timeout.

What to do next

  1. Search your codebase for await() without a timeout and decide what each should do on timeout.
  2. Check that every countDown() sits in a finally block and appears exactly once per party.
  3. Record failures in a concurrent collection next to the latch; the latch only says that work ended.
  4. Restore the interrupt flag wherever you catch InterruptedException around await.
  5. Replace sleep-based waits in tests with a latch counted down by the callback.
  6. Where you wait for values rather than events, consider CompletableFuture.allOf or invokeAll instead.
  7. Read the JDK source of CountDownLatch; it is short and shows how AQS-based synchronisers are built.
Key takeaway: CountDownLatch is a single-use gate built on a CAS-decremented AQS counter: await() blocks until the count is zero, countDown() at zero does nothing, and nothing resets it. Count down in finally, record failures separately, await with a timeout and act on its result, and restore interrupts. A successful await gives a happens-before edge, so workers' results are safely visible. For repeated rounds, dynamic parties or value-returning work, use CyclicBarrier, Phaser or futures instead.