Concurrent Utilities

Synchronizers

15 min Lesson 9 of 13

Synchronizers

Thread pools and futures let you run work concurrently, but sometimes threads need to coordinate with each other — one thread must wait until others finish, a limited resource must not be overloaded, or a group of threads must all reach the same point before any of them continue. The java.util.concurrent package ships three purpose-built synchronizers for these scenarios: CountDownLatch, Semaphore, and CyclicBarrier. Each solves a distinct coordination problem, and choosing the right one makes concurrent code dramatically simpler and safer than trying to achieve the same effect with raw wait/notify or manual flags.

CountDownLatch — Wait for N Events to Happen

A CountDownLatch is initialised with a count. Any thread can call await() to block until the count reaches zero. Other threads call countDown() to decrement the count. Once the count hits zero the latch opens permanently — all waiting threads are released and subsequent await() calls return immediately.

import java.util.concurrent.*; public class CountDownLatchDemo { public static void main(String[] args) throws InterruptedException { int workers = 5; CountDownLatch ready = new CountDownLatch(workers); // coordinator waits for workers to be ready CountDownLatch start = new CountDownLatch(1); // workers wait for the start signal CountDownLatch done = new CountDownLatch(workers); // coordinator waits for completion ExecutorService pool = Executors.newFixedThreadPool(workers); for (int i = 0; i < workers; i++) { final int id = i + 1; pool.submit(() -> { System.out.printf("Worker %d: ready%n", id); ready.countDown(); // signal: I am ready try { start.await(); // wait for the gun System.out.printf("Worker %d: working...%n", id); Thread.sleep(200 + (long)(Math.random() * 300)); System.out.printf("Worker %d: done%n", id); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } finally { done.countDown(); // signal: I have finished } }); } ready.await(); // wait until all workers are ready System.out.println("All workers ready — GO!"); start.countDown(); // fire the start signal done.await(); // wait for all to finish pool.shutdown(); System.out.println("All workers finished."); } }

This classic "race-start" pattern uses two latches: one so the coordinator waits until every worker is standing by, and one so the coordinator can detect when all work is complete. Note that the start latch is initialised with 1 — a single countDown() releases all waiting workers simultaneously.

Latches are single-use. Once the count reaches zero a CountDownLatch cannot be reset. If you need to reuse the same barrier, use CyclicBarrier instead (covered below). For one-shot startup gates, service-readiness checks, and test synchronisation a latch is ideal.

A simpler but equally common use case: wait until N parallel service calls have all completed before proceeding.

CountDownLatch latch = new CountDownLatch(3); ExecutorService pool = Executors.newCachedThreadPool(); for (String service : List.of("auth", "inventory", "pricing")) { pool.submit(() -> { try { callService(service); // blocking HTTP/RPC call } finally { latch.countDown(); // always decrement, even on failure } }); } latch.await(10, TimeUnit.SECONDS); // timed wait — don't block forever pool.shutdown();
Always put countDown() in a finally block. If the worker throws an exception and countDown() is missed, any thread blocked in await() will wait forever. The finally guarantee is the only safe pattern.

Semaphore — Limit Concurrent Access to a Resource

A Semaphore controls access to a limited resource by maintaining a set of permits. A thread calls acquire() to obtain a permit (blocking if none are available) and release() when it is done. Unlike a mutex (which is either locked or unlocked), a semaphore can hold any number of permits — so you can allow, say, exactly 3 threads to access a database connection pool simultaneously.

import java.util.concurrent.*; public class ConnectionPoolDemo { private static final Semaphore POOL = new Semaphore(3); // at most 3 concurrent connections static void useConnection(int threadId) throws InterruptedException { POOL.acquire(); // blocks if all 3 permits are taken try { System.out.printf("Thread %d acquired connection (available: %d)%n", threadId, POOL.availablePermits()); Thread.sleep(500); // simulate DB work } finally { POOL.release(); // always release in finally System.out.printf("Thread %d released connection%n", threadId); } } public static void main(String[] args) throws InterruptedException { ExecutorService pool = Executors.newFixedThreadPool(8); for (int i = 1; i <= 8; i++) { final int id = i; pool.submit(() -> { try { useConnection(id); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } }); } pool.shutdown(); pool.awaitTermination(15, TimeUnit.SECONDS); } }

Run this and you will see at most three "acquired" messages before the first "released" message — the semaphore enforces the cap at runtime.

Fairness: By default, Semaphore uses a non-fair policy — threads waiting to acquire are not served in arrival order. Pass true to the constructor to get a fair (FIFO) semaphore. Fair semaphores have lower throughput but prevent starvation, which matters for long-running, heavily contended resources.

Semaphore fair = new Semaphore(3, true); // fair FIFO ordering
Use a semaphore to throttle external calls. Rate-limiting outbound API requests (e.g., an external service that allows only 10 concurrent connections) is one of the clearest real-world semaphore use cases. Combine with a timeout variant — tryAcquire(timeout, unit) — so callers fail fast if the resource is overloaded rather than piling up indefinitely.

A semaphore with one permit behaves like a mutex — but it is not reentrant. Unlike synchronized or ReentrantLock, a different thread can call release() on a semaphore that was acquired by another thread. This makes semaphores useful for producer/consumer signalling, not just mutual exclusion.

CyclicBarrier — Synchronise a Group of Threads at a Common Point

A CyclicBarrier is initialised with a party count. Each participating thread calls await() when it reaches the barrier. The last thread to arrive triggers an optional barrier action (a Runnable run in that thread), then all threads are released. The barrier automatically resets for the next cycle — hence "cyclic".

import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; public class MatrixRowProcessor { static final int THREADS = 4; static final int ROUNDS = 3; public static void main(String[] args) throws InterruptedException { AtomicInteger globalSum = new AtomicInteger(0); // barrier action: runs once after all threads reach the barrier each round Runnable barrierAction = () -> System.out.printf("=== Round complete. Running total: %d ===%n", globalSum.get()); CyclicBarrier barrier = new CyclicBarrier(THREADS, barrierAction); ExecutorService pool = Executors.newFixedThreadPool(THREADS); for (int t = 0; t < THREADS; t++) { final int threadId = t; pool.submit(() -> { try { for (int round = 1; round <= ROUNDS; round++) { // simulate per-thread work (compute a partial sum) int partial = (threadId + 1) * round; globalSum.addAndGet(partial); System.out.printf("Thread %d finished round %d (partial=%d)%n", threadId, round, partial); barrier.await(); // wait for all threads to finish this round } } catch (InterruptedException | BrokenBarrierException e) { Thread.currentThread().interrupt(); } }); } pool.shutdown(); pool.awaitTermination(30, TimeUnit.SECONDS); } }

The key difference from a latch: the barrier resets after each round. After the four threads all call await() in round 1, the barrier resets and the same threads can call await() again in round 2 without creating a new object.

What is BrokenBarrierException? If any thread is interrupted or times out while waiting at a barrier, the barrier enters a broken state and all threads currently or subsequently waiting at that barrier receive a BrokenBarrierException. This prevents the common deadlock where one thread dies and the rest wait forever. Always handle both InterruptedException and BrokenBarrierException when calling barrier.await().

A typical real-world use is parallel data processing pipelines: a large matrix or dataset is divided into N chunks, each worker processes its chunk in phase 1, all workers synchronise, then each worker processes its chunk in phase 2 using results from phase 1, and so on. The cyclic reset makes this pipeline pattern concise.

Choosing the Right Synchronizer

  • CountDownLatch — one-shot wait: a coordinator waits for N events. Think service startup gates, test synchronisation, and join-all patterns where reuse is not needed.
  • Semaphore — resource throttling: limit how many threads hold a resource at the same time. Think connection pools, rate limiters, and access guards.
  • CyclicBarrier — multi-phase coordination: N threads must all reach each phase boundary together before any of them proceed. Think parallel algorithms with synchronisation points between phases, simulation steps, and bulk data processing rounds.
Prefer the highest-level abstraction. If you find yourself using a raw AtomicInteger and a while loop to wait for a condition, step back — one of these three synchronizers, or a Phaser for more complex multi-phase scenarios, will almost certainly give you the same behaviour with far less code and far fewer bugs.

Summary

CountDownLatch is a one-shot gate: threads wait until an event count reaches zero. Semaphore enforces concurrency limits on a shared resource using acquire/release permits. CyclicBarrier makes a group of threads rendezvous at repeating checkpoints, optionally running a barrier action before releasing them. All three are battle-tested tools from java.util.concurrent that replace brittle, error-prone manual synchronisation. Always release permits and decrement latches in finally blocks, handle BrokenBarrierException at every barrier, and use timed variants to avoid indefinite blocking in production code.