Skip to content

How to Use CountDownLatch, CyclicBarrier and Semaphore

Build a stock-sync job in plain Java 21 and use CountDownLatch, CyclicBarrier and Semaphore each where it fits, with runnable checks and the traps I forced.

how-to-tutorials java-tutorials java-collections
Gopi Gorantala
Reading Progress

On This Page

The nightly sync you'll end up with

Here's what the finished job prints when four warehouse feeds load in parallel and the report waits for all of them:

== all loaders count down ==
finished=true rows=3400 remaining=0 (~200 ms)

That one line is CountDownLatch doing its job. By the end you'll have used all three classes, CountDownLatch, CyclicBarrier and Semaphore, in one small inventory-service job, each for the thing it's actually good at, and you'll know which one to reach for when a colleague asks "how do I make thread A wait for thread B?"

The short version of the choice: a latch is a one-shot "wait until N things are done", a barrier is a reusable "everyone meets here before the next phase", and a semaphore is a "no more than N at once" limit. If you want the memory-model and AQS internals behind them, the concurrency series covers each one on its own: CountDownLatch, CyclicBarrier and Semaphore; this article is the hands-on recipe. If you haven't set up a thread pool yet, how to configure a thread pool with ExecutorService covers that part.

Versions I used

Tool Version I used Check it with
JDK OpenJDK 21.0.12.1 java -version
Build none, plain javac javac -version
IDE any; IntelliJ IDEA or Eclipse both work (docs-only, not executed) Help → About

You need Java 21 because I used Thread.ofPlatform(). No Maven, no Gradle, no container. Everything is five files you can paste into a folder. Total build time for me was about forty minutes, and nearly all of it went into making the failure cases fail the way I wanted.

Create the project

Make a folder and the package layout:

mkdir -p stock-sync/src && cd stock-sync
stock-sync/
└── src/
    ├── Warehouse.java
    ├── LoadAllLatch.java
    ├── CountPhases.java
    ├── SupplierCalls.java
    └── Checks.java

All files use package com.example.stock;. In IntelliJ IDEA, use File → New → Project, pick Java with no build system, JDK 21, and mark src as the sources root. In Eclipse, File → New → Java Project, set the execution environment to JavaSE-21 and put the files in src. Both are written from the vendors' docs; I ran everything from the command line.

How to use CountDownLatch, CyclicBarrier and Semaphore, step by step

Step 1: Describe the data

Goal: four warehouses with different load times so the waiting is visible.

src/Warehouse.java

package com.example.stock;

import java.util.List;

/** One warehouse's stock feed: a name and how many SKU rows it holds. */
public record Warehouse(String name, int rows, long loadMillis) {
    public static List<Warehouse> samples() {
        return List.of(
                new Warehouse("Rotterdam", 1200, 120),
                new Warehouse("Leeds", 800, 60),
                new Warehouse("Gdansk", 450, 200),
                new Warehouse("Lyon", 950, 90));
    }
}

A record is enough here. If records are new to you, see how to use Java records.

Checkpoint: javac -d out src/Warehouse.java prints nothing.

Step 2: Wait for every loader with CountDownLatch

Goal: start four loader threads and block the main thread until all have finished.

src/LoadAllLatch.java

package com.example.stock;

import java.util.List;
import java.util.concurrent.CountDownLatch;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

/** Step 1: CountDownLatch. Wait for every warehouse loader before building the report. */
public class LoadAllLatch {

    public static int loadAll(List<Warehouse> warehouses, boolean failOneWithoutCountDown) throws InterruptedException {
        CountDownLatch done = new CountDownLatch(warehouses.size());
        AtomicInteger total = new AtomicInteger();
        long start = System.nanoTime();

        for (Warehouse w : warehouses) {
            Thread.ofPlatform().name("loader-" + w.name()).start(() -> {
                try {
                    if (failOneWithoutCountDown && w.name().equals("Gdansk")) {
                        throw new IllegalStateException("feed unreachable");
                    }
                    Thread.sleep(w.loadMillis());
                    total.addAndGet(w.rows());
                } catch (Exception e) {
                    System.out.println(Thread.currentThread().getName() + " failed: " + e.getMessage());
                } finally {
                    if (!(failOneWithoutCountDown && w.name().equals("Gdansk"))) {
                        done.countDown(); // finally, so a failure can't leave the latch stuck
                    }
                }
            });
        }

        boolean finished = done.await(1, TimeUnit.SECONDS);
        long ms = (System.nanoTime() - start) / 1_000_000;
        System.out.println("finished=" + finished + " rows=" + total.get()
                + " remaining=" + done.getCount() + " (~" + (ms / 100 * 100) + " ms)");
        return total.get();
    }

    public static void main(String[] args) throws Exception {
        System.out.println("== all loaders count down ==");
        loadAll(Warehouse.samples(), false);
        System.out.println("== Gdansk never counts down ==");
        loadAll(Warehouse.samples(), true);
    }
}

The latch starts at 4. Each loader calls countDown(), and await(1, TimeUnit.SECONDS) returns true once the count hits zero or false if a second passes first. Two things I'd insist on. Call countDown() in a finally, otherwise one exception strands everyone. And always use the timed await; a bare await() on a latch that never reaches zero hangs the job forever. The second run in main deliberately skips the countdown for Gdansk (I wrote that flag only to demonstrate this).

Compile and run:

mkdir -p out && javac -Xlint:all -d out src/*.java
java -cp out com.example.stock.LoadAllLatch
== all loaders count down ==
finished=true rows=3400 remaining=0 (~200 ms)
== Gdansk never counts down ==
loader-Gdansk failed: feed unreachable
finished=false rows=2950 remaining=1 (~1000 ms)

Checkpoint: the first run takes about 200 ms, the length of the slowest feed, not the sum of all four. The second waits the full second, reports finished=false, and remaining=1 tells you exactly how many parties never showed up. That count is the best debugging aid a latch gives you.

Step 3: Keep phases in lockstep with CyclicBarrier

Goal: all four warehouses count, then reconcile, then publish, and nobody starts a phase before the others finish the previous one.

src/CountPhases.java

package com.example.stock;

import java.util.List;
import java.util.concurrent.BrokenBarrierException;
import java.util.concurrent.CyclicBarrier;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;
import java.util.concurrent.atomic.AtomicInteger;

/** Step 2: CyclicBarrier. Every warehouse finishes phase N before anyone starts phase N+1. */
public class CountPhases {

    public static void main(String[] args) throws Exception {
        List<Warehouse> ws = Warehouse.samples();
        AtomicInteger phase = new AtomicInteger(1);
        CyclicBarrier barrier = new CyclicBarrier(ws.size(),
                () -> System.out.println("--- phase " + phase.getAndIncrement() + " complete, all " + ws.size() + " arrived ---"));

        for (Warehouse w : ws) {
            Thread.ofPlatform().name(w.name()).start(() -> {
                try {
                    for (String step : new String[]{"count", "reconcile", "publish"}) {
                        Thread.sleep(w.loadMillis() / 4);
                        System.out.println(w.name() + " did " + step);
                        barrier.await(2, TimeUnit.SECONDS);
                    }
                } catch (InterruptedException | BrokenBarrierException | TimeoutException e) {
                    System.out.println(w.name() + " stopped: " + e.getClass().getSimpleName());
                }
            });
        }
        Thread.sleep(1500);
        System.out.println("== a party goes missing ==");
        CyclicBarrier b = new CyclicBarrier(3);
        for (int i = 0; i < 2; i++) {
            int id = i;
            Thread.ofPlatform().name("short-" + id).start(() -> {
                try {
                    b.await(300, TimeUnit.MILLISECONDS);
                } catch (Exception e) {
                    System.out.println(Thread.currentThread().getName() + " -> " + e.getClass().getSimpleName()
                            + ", broken=" + b.isBroken());
                }
            });
        }
        Thread.sleep(800);
    }
}

The barrier is built for 4 parties plus a barrier action that runs once per trip, on the last thread to arrive. Unlike a latch it resets itself after each trip, which is why the same barrier object serves all three phases. The second half of main builds a barrier for three parties and only sends two, with a 300 ms timeout.

java -cp out com.example.stock.CountPhases
Leeds did count
Rotterdam did count
Lyon did count
Gdansk did count
--- phase 1 complete, all 4 arrived ---
Leeds did reconcile
Lyon did reconcile
Rotterdam did reconcile
Gdansk did reconcile
--- phase 2 complete, all 4 arrived ---
Leeds did publish
Lyon did publish
Rotterdam did publish
Gdansk did publish
--- phase 3 complete, all 4 arrived ---
== a party goes missing ==
short-1 -> BrokenBarrierException, broken=true
short-0 -> TimeoutException, broken=true

Checkpoint: no "reconcile" line appears before the phase 1 banner. The order of warehouse names inside a phase changes from run to run; the banners never do. The last two lines are the part that surprised me the first time: only one thread gets the TimeoutException, and everyone else gets BrokenBarrierException. Once a barrier breaks, it stays broken for every waiter until you call reset(), so treat a broken barrier as a failed run, not something to retry in place.

Step 4: Cap supplier calls with Semaphore

Goal: ten SKUs need a supplier lookup, but the supplier's API allows three concurrent requests.

src/SupplierCalls.java

package com.example.stock;

import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.Semaphore;
import java.util.concurrent.atomic.AtomicInteger;

/** Step 3: Semaphore. At most 3 supplier API calls in flight, out of 10 requested. */
public class SupplierCalls {

    static final AtomicInteger inFlight = new AtomicInteger();
    static final AtomicInteger peak = new AtomicInteger();

    static void callSupplier(int sku, Semaphore permits) throws InterruptedException {
        permits.acquire();
        try {
            int now = inFlight.incrementAndGet();
            peak.accumulateAndGet(now, Math::max);
            Thread.sleep(50); // pretend HTTP call
        } finally {
            inFlight.decrementAndGet();
            permits.release(); // always in finally
        }
    }

    public static int run(int permitCount, int calls) throws Exception {
        inFlight.set(0);
        peak.set(0);
        Semaphore permits = new Semaphore(permitCount);
        List<Thread> ts = new ArrayList<>();
        for (int i = 0; i < calls; i++) {
            int sku = i;
            ts.add(Thread.ofPlatform().start(() -> {
                try { callSupplier(sku, permits); } catch (InterruptedException ignored) { }
            }));
        }
        for (Thread t : ts) t.join();
        System.out.println("permits=" + permitCount + " calls=" + calls + " peak=" + peak.get()
                + " availableAtEnd=" + permits.availablePermits());
        return peak.get();
    }

    public static void main(String[] args) throws Exception {
        run(3, 10);
        run(1, 5);
        // tryAcquire: shed load instead of queueing
        Semaphore p = new Semaphore(1);
        p.acquire();
        System.out.println("tryAcquire while exhausted = " + p.tryAcquire());
        // the classic leak: release without acquire grows the pool
        Semaphore leak = new Semaphore(2);
        leak.release();
        System.out.println("after stray release, permits = " + leak.availablePermits());
    }
}
java -cp out com.example.stock.SupplierCalls
permits=3 calls=10 peak=3 availableAtEnd=3
permits=1 calls=5 peak=1 availableAtEnd=1
tryAcquire while exhausted = false
after stray release, permits = 3

Checkpoint: peak never exceeds the permit count and availableAtEnd equals what you started with. The last line is the semaphore trap: release() doesn't check that you ever acquired, so a stray release quietly grows the pool from 2 to 3. That's why the acquire goes before the try and the release goes in the finally. If you'd rather shed load than queue, tryAcquire() returns false immediately, which is what the third line shows.

Prove it with checks

I didn't have Maven Central access in my environment, so these are plain checks without JUnit; they translate directly to JUnit 5 assertions once you add it. src/Checks.java

package com.example.stock;

import java.util.concurrent.*;

public class Checks {
    static int passed, failed;
    static void check(String name, boolean ok) {
        if (ok) passed++; else failed++;
        System.out.println((ok ? "PASS " : "FAIL ") + name);
    }
    public static void main(String[] args) throws Exception {
        check("latch loads every row", LoadAllLatch.loadAll(Warehouse.samples(), false) == 3400);
        check("a missing countDown makes await time out",
                LoadAllLatch.loadAll(Warehouse.samples(), true) == 2950);
        CountDownLatch l = new CountDownLatch(1);
        l.countDown(); l.countDown();
        check("countDown below zero is a no-op", l.getCount() == 0);
        check("semaphore(3) peak <= 3", SupplierCalls.run(3, 10) <= 3);
        check("semaphore(1) peak == 1", SupplierCalls.run(1, 5) == 1);
        Semaphore s = new Semaphore(0);
        check("tryAcquire on empty fails", !s.tryAcquire());
        CyclicBarrier b = new CyclicBarrier(2);
        Thread t = Thread.ofPlatform().start(() -> { try { b.await(); } catch (Exception e) { } });
        b.await(1, TimeUnit.SECONDS);
        t.join();
        check("barrier resets after trip", b.getNumberWaiting() == 0 && !b.isBroken());
        System.out.println(passed + " passed, " + failed + " failed");
        if (failed > 0) System.exit(1);
    }
}
java -cp out com.example.stock.Checks
PASS latch loads every row
PASS a missing countDown makes await time out
PASS countDown below zero is a no-op
PASS semaphore(3) peak <= 3
PASS semaphore(1) peak == 1
PASS tryAcquire on empty fails
PASS barrier resets after trip
7 passed, 0 failed

(The program also prints the job's own log lines between the checks; I've trimmed them.) javac -Xlint:all was clean.

Which one to use

Think of what the count means. In a latch it only goes down and never comes back, so it models an event that happens once: startup finished, all loaders done. A barrier counts arrivals and resets, so it models a recurring meeting point. A semaphore counts free slots that go both up and down, so it models a resource. Mixing them up is the usual mistake: people use a latch in a loop and wonder why the second iteration doesn't wait, because it can't be reset.

All three are built on the same queued-synchronizer machinery in the JDK; the pieces above link to the deep dives, and CountDownLatch vs CyclicBarrier compares the first two side by side. For limiting concurrent calls in a real service, see also how to implement rate limiting in Java, and for handing work between threads, how to use BlockingQueue for producer-consumer pipelines.

Errors I hit, and the fixes

  • Program hangs and never exits after await(). A loader threw before countDown(). Put countDown() in finally and use the timed await.
  • java.util.concurrent.BrokenBarrierException. Another party timed out, was interrupted, or the barrier action threw. Fix the cause; the barrier stays broken until reset().
  • error: cannot find symbol on Thread.ofPlatform(). You're compiling with --release 17. I confirmed this: that method needs Java 21. On 17, use new Thread(runnable, name).start().
  • Peak concurrency above the permit count. Check for a release() outside the finally or a release without a matching acquire.
  • Barrier party count doesn't match thread count. With 5 threads on a 4-party barrier, the fifth starts the next trip alone and never finishes. Size the barrier to the number of threads that will call await().

Before you ship it

  • Prefer an ExecutorService over raw threads in real code. I used raw threads to keep the steps visible.
  • Latches and barriers don't propagate exceptions. If you need results and failures from tasks, CompletableFuture usually fits better. For a "wait for all tasks" join, invokeAll on an executor is simpler than a latch.
  • On Java 21 with virtual threads, a Semaphore is the standard way to limit concurrency, because capping a virtual-thread pool doesn't limit anything.
  • I didn't test on Windows; nothing here is OS-specific.

Checklist

  • Use CountDownLatch for one-shot waits; countDown() in finally, await with a timeout.
  • Use CyclicBarrier for repeated phases; size it exactly to the number of waiting threads; a break is final until reset().
  • Use Semaphore for limits; acquire before try, release in finally, never release without acquiring.
  • Check getCount() / availablePermits() when something hangs; they tell you who's missing.

Questions people ask about these three classes

What is the difference between CountDownLatch and CyclicBarrier in Java? A latch is single-use and the threads counting down needn't be the ones waiting. A barrier is reusable and the threads that wait are the same threads that arrive.

Can I reuse a CountDownLatch? No. Once it reaches zero it stays at zero. Create a new one per round, or use a CyclicBarrier or Phaser.

When should I use a Semaphore instead of a thread pool size? When the limit applies to a resource, such as a remote API or a database, rather than to the number of worker threads.

Does CountDownLatch work with virtual threads? Yes, blocking in await() parks a virtual thread cheaply on Java 21.

References

  • Java SE 21 API: CountDownLatch, CyclicBarrier, Semaphore, Thread.Builder (docs.oracle.com/en/java/javase/21/docs/api)
  • JEP 444: Virtual Threads (openjdk.org/jeps/444)
how-to-tutorialsjava-tutorialsjava-collections

Staff engineer architecting Kafka and Flink platforms for banking, where a dropped event is a reconciliation break, not a retry. Through javahandbook.com, I share production-tested guidance on building reliable event-driven systems.

Comments