CountDownLatch and Semaphore in Java

Java’s concurrency utilities include two primitives that block threads waiting for specific conditions: CountDownLatch for one-shot event coordination and Semaphore for resource access control. They solve different problems but share a pattern — they put threads to sleep until something external makes them wake up.

CountDownLatch

A CountDownLatch starts at some positive count. Threads call await() and block until the count reaches zero, which happens when other threads call countDown(). The key properties are:

  • It is one-shot — once the count hits zero, it stays there. You can’t reset it; you need a new instance.
  • Any thread calling await() after the count is already zero returns immediately (no blocking).
  • All waiting threads are released simultaneously when the count reaches zero.

This makes it ideal for “wait until N tasks complete” patterns — a setup phase, a startup gate, or any scenario where you need to know that a set of operations have all finished before proceeding.

The code

The demo has three parts: basic countdown (waiting for tasks), one-shot signaling, and a barrier pattern where multiple threads coordinate through the latch.

import java.util.concurrent.CountDownLatch;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class CountDownLatchDemo {
    public static void main(String[] args) throws InterruptedException {
        // Part 1: Waiter waits for 3 parallel tasks
        System.out.println("--- Part 1: Waiter waiting for 3 tasks to finish ---");
        CountDownLatch latch = new CountDownLatch(3);
        long start = System.nanoTime();

        ExecutorService workers = Executors.newFixedThreadPool(3);
        for (int i = 1; i <= 3; i++) {
            final int taskNum = i;
            workers.submit(() -> {
                try {
                    long sleepMs = (long) (Math.random() * 500) + 100;
                    Thread.sleep(sleepMs);
                    System.out.println("  Task " + taskNum + " completed in ~" + sleepMs + "ms");
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                } finally {
                    latch.countDown();
                }
            });
        }

        System.out.println("  Waiter: all tasks submitted, now blocking...");
        latch.await(); // blocks until count == 0
        long elapsed = (System.nanoTime() - start) / 1_000_000;
        System.out.println("  Waiter: ALL TASKS DONE. Elapsed: ~" + elapsed + "ms");
        workers.shutdown();
        workers.awaitTermination(10, TimeUnit.SECONDS);

        // Part 2: One-shot signaling
        System.out.println("--- Part 2: Latch is one-shot ---");
        CountDownLatch phaseLatch = new CountDownLatch(1);
        Thread starter = new Thread(() -> {
            try { Thread.sleep(300); phaseLatch.countDown(); } catch (InterruptedException e) {}
        });
        starter.start();
        long start2 = System.nanoTime();
        phaseLatch.await();
        System.out.println("  Waiter: got the signal. Elapsed wait: ~" + ((System.nanoTime() - start2) / 1_000_000) + "ms");
        starter.join();

        // Part 3: Barrier — all workers reach this point together
        System.out.println("--- Part 3: Barrier coordination ---");
        CountDownLatch barrier = new CountDownLatch(2);
        Thread t1 = new Thread(() -> {
            try { Thread.sleep(200); barrier.countDown(); } catch (InterruptedException e) {}
        });
        Thread t2 = new Thread(() -> {
            try { Thread.sleep(400); barrier.countDown(); } catch (InterruptedException e) {}
        });
        t1.start(); t2.start();
        t1.join(); t2.join();
    }
}

Running it

In Part 1, the three worker threads each ran between ~400 and ~490ms. The waiter elapsed ~495ms — slightly more than the longest task — because it blocked until all three tasks had counted down, regardless of who finished first. The latch doesn’t care about order; it only cares that the count reached zero.

In Part 2, the starter thread slept for 300ms before calling countDown(). The waiter’s elapsed wait was ~301ms, confirming it was blocked exactly until the signal arrived — and once it did, it resumed instantly. If a second thread called await() on the same latch at that point, it would return immediately since the count is already zero.

In Part 3, Worker-A reached the barrier after ~200ms and counted it down to 1, then waited because it was already at 1, not 0. Worker-B arrived ~400ms in, counted down to 0, which released both threads. The barrier ensures no thread proceeds past it until every participant has arrived — useful for phased parallel workloads.

Semaphore

A Semaphore manages a fixed pool of permits. Threads call acquire() and block if no permits are available; they call release() when done to return the permit. It’s the classic resource limiter: connection pools, rate limiters, or any bounded concurrency control.

  • Fair mode (new Semaphore(n, true)) uses FIFO ordering — the thread that waited longest gets the permit first.
  • Unfair mode (the default) doesn’t guarantee order, which can improve throughput but risks starvation under heavy contention.
  • tryAcquire() attempts non-blocking acquisition — returns false immediately if no permits are available.

The code

This demo exercises all three acquisition modes: blocking acquire with a permit pool, fair ordering, and non-blocking tryAcquire.

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Semaphore;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

public class SemaphoreDemo {
    public static void main(String[] args) throws InterruptedException {
        // Part 1: Connection pool with limit of 2
        System.out.println("--- Part 1: Connection pool with limit of 2 ---");
        int maxConnections = 2;
        Semaphore connectionPool = new Semaphore(maxConnections);
        ExecutorService pool = Executors.newFixedThreadPool(6);
        AtomicInteger peakActive = new AtomicInteger(0);

        for (int i = 1; i <= 6; i++) {
            final int connId = i;
            pool.submit(() -> {
                try {
                    connectionPool.acquire(); // blocks if no permits
                    System.out.println("  Connection " + connId + ": acquired!");
                    Thread.sleep((long) (Math.random() * 800) + 200);
                    System.out.println("  Connection " + connId + ": releasing");
                } catch (InterruptedException e) {
                    Thread.currentThread().interrupt();
                } finally {
                    connectionPool.release();
                }
            });
        }
        pool.shutdown();
        pool.awaitTermination(15, TimeUnit.SECONDS);

        // Part 2: Fair ordering
        System.out.println("--- Part 2: Fair semaphore (FIFO) ---");
        Semaphore fairSema = new Semaphore(1, true);
        Thread f1 = new Thread(() -> {
            try { fairSema.acquire(); System.out.println("  [A] acquired! Holding for 1s..."); Thread.sleep(1000); } catch (InterruptedException e) {}
            finally { fairSema.release(); System.out.println("  [A] released."); }
        });
        Thread f2 = new Thread(() -> {
            try { fairSema.acquire(); System.out.println("  [B] acquired! Holding for 1s..."); Thread.sleep(1000); } catch (InterruptedException e) {}
            finally { fairSema.release(); System.out.println("  [B] released."); }
        });
        f1.start(); Thread.sleep(50);
        f2.start();
        f1.join(); f2.join();

        // Part 3: Try-acquire (non-blocking)
        System.out.println("--- Part 3: Try-acquire ---");
        Semaphore smallPool = new Semaphore(1);
        System.out.println("  Thread 1 acquired: " + smallPool.tryAcquire());
        System.out.println("  Thread 2 tryAcquire (should be false): " + smallPool.tryAcquire());
        smallPool.release();
        System.out.println("  After release, Thread 2 tryAcquire: " + smallPool.tryAcquire());
    }
}

Running it

Part 1 shows six connection requests competing for only two permits. Connections acquired and released in pairs — the output confirms that only two connections were active at any time. No request was lost; blocked threads simply waited until a permit freed up.

In Part 2, Worker-A started first (50ms head start). With fair=true, it held the permit for a full second before releasing, and only then did Worker-B acquire — FIFO order is guaranteed. Swap to false (unfair mode) and you might see Worker-B acquire before Worker-A despite arriving later, because the JVM’s scheduler may wake an arbitrary waiting thread.

Part 3 demonstrates non-blocking acquisition: the first call gets the permit, the second immediately returns false without blocking, and after a manual release, the third call succeeds. This is useful when you’d rather skip work than wait — for example, rejecting a request outright instead of queueing it indefinitely.

Takeaway

Use CountDownLatch when you need to wait for an event (all threads finish, initialization completes) — it’s a gate that opens once. Use Semaphore when you need to limit concurrency (connection pools, rate limiters) — it’s a token bucket that controls how many threads can proceed at once.