CountDownLatch, CyclicBarrier, Semaphore, Exchanger, and the BlockingQueue family — the coordination primitives that sit above raw locks, plus a producer-consumer implementation.
Published September 22, 2026
Locks and synchronized protect shared state. This lesson covers a different job: coordinating threads — making one thread wait for others, limiting concurrent access to a resource, or safely handing data between producer and consumer threads.
CountDownLatch startGate = new CountDownLatch(3); // wait for 3 services
// Each service, on a separate thread, signals readiness
new Thread(() -> { initDatabase(); startGate.countDown(); }).start();
new Thread(() -> { initCache(); startGate.countDown(); }).start();
new Thread(() -> { initQueue(); startGate.countDown(); }).start();
startGate.await(); // blocks until count reaches 0
System.out.println("All services ready — starting server");
countDown() decrements the internal counter; await() blocks until it hits zero. The gate is one-time-use — once the count reaches zero, it stays open forever; there's no way to reset a CountDownLatch for a second round.
CyclicBarrier barrier = new CyclicBarrier(4, () -> System.out.println("Phase complete")); // 4 threads, + action on release
Runnable worker = () -> {
doPhaseOneWork();
try { barrier.await(); } catch (Exception e) { /* ... */ } // waits for all 4 to arrive
doPhaseTwoWork();
try { barrier.await(); } catch (Exception e) { /* ... */ } // reusable — same barrier, next phase
};
Unlike CountDownLatch, CyclicBarrier resets automatically once all parties arrive, ready to be reused for the next phase — the name literally means "cyclic." The typical fit: parallel computation with distinct phases, where every worker must finish phase N before any of them starts phase N+1 (e.g. a parallel matrix computation with dependent stages).
Semaphore connectionLimiter = new Semaphore(10); // at most 10 concurrent DB connections
void queryDatabase() throws InterruptedException {
connectionLimiter.acquire(); // blocks if 10 permits are already taken
try {
runQuery();
} finally {
connectionLimiter.release(); // ALWAYS release in finally
}
}
A Semaphore isn't a lock in the mutual-exclusion sense — it allows up to N threads through simultaneously (a Semaphore(1) behaves like a lock, but the general case controls access to a pool of N interchangeable resources, e.g. a fixed number of DB connections, API rate slots, or worker permits). new Semaphore(n, true) requests fair ordering (FIFO — first thread to call acquire() gets the next available permit); the default is unfair, which is faster under low contention but can theoretically starve a long-waiting thread under sustained high contention.
Exchanger<List<String>> exchanger = new Exchanger<>();
// Thread A: fills a buffer, then swaps it for a fresh empty one from Thread B
List<String> bufferA = new ArrayList<>();
bufferA = exchanger.exchange(bufferA); // blocks until Thread B also calls exchange()
// Thread B: does the reverse
List<String> bufferB = new ArrayList<>();
bufferB = exchanger.exchange(bufferB);
Exchanger synchronizes exactly two threads at a rendezvous point where they swap objects — each thread's exchange() call blocks until the other thread also calls exchange(), then both return with the other's object. A niche tool, useful specifically for two-thread producer/consumer buffer-swapping designs (one thread fills a buffer while the other drains the previous one, then they trade).
BlockingQueue implementations add blocking put()/take() on top of a normal queue — put() blocks if the queue is full (for bounded queues), take() blocks if it's empty, which is exactly the coordination a producer-consumer setup needs without any manual wait()/notify().
ArrayBlockingQueue — fixed capacity, array-backed, FIFO.LinkedBlockingQueue — optionally bounded (unbounded by default — watch for the same OOM risk as an unbounded ThreadPoolExecutor queue), linked-node-backed.SynchronousQueue — capacity zero: every put() must be matched by a waiting take() before it returns — effectively a direct handoff between exactly two threads, not a buffer at all.PriorityBlockingQueue — unbounded, orders elements by a Comparator instead of FIFO.BlockingQueue<Task> queue = new ArrayBlockingQueue<>(100);
// Producer
new Thread(() -> {
while (true) {
Task task = generateTask();
try { queue.put(task); } catch (InterruptedException e) { Thread.currentThread().interrupt(); break; }
}
}).start();
// Consumer
new Thread(() -> {
while (true) {
try {
Task task = queue.take(); // blocks if empty
process(task);
} catch (InterruptedException e) { Thread.currentThread().interrupt(); break; }
}
}).start();
This is the entire pattern — no manual locking, no wait()/notify() (compare to the hand-rolled BoundedQueue with Conditions shown in synchronized and Locks). BlockingQueue implementations handle the full/empty coordination internally, which is exactly why they're the default choice over hand-rolling it.
Q: When would you choose Semaphore over a fixed-size thread pool for limiting concurrency? A: When you need to limit concurrent access to a resource that isn't naturally "a task running on a thread" — e.g. limiting concurrent outbound HTTP calls made from within otherwise-unrelated request-handling threads, where the threads themselves are already managed by a web server's own pool.
Q: Why does SynchronousQueue have zero capacity — what's it actually useful for?
A: It's the queue implementation behind Executors.newCachedThreadPool() — a direct producer-to-consumer handoff with no buffering means a new thread is spun up immediately for a task if no idle thread is available to take() it right away, rather than the task sitting queued.
Q: What happens if you call CyclicBarrier.await() but one thread never arrives?
A: Every other thread blocks indefinitely at the barrier (or until a supplied timeout on await(long, TimeUnit) elapses, throwing TimeoutException) — a CyclicBarrier provides no progress guarantee if a party fails to show up, which is why production use nearly always pairs it with a timeout and a BrokenBarrierException/TimeoutException handling path.
Q: Could you implement CountDownLatch's behavior with a Semaphore instead?
A: Not cleanly — Semaphore doesn't have a "wait until N releases have happened" primitive matching await()'s all-or-nothing gate semantics; CountDownLatch is purpose-built for exactly that one-time "wait for N completions" shape, while Semaphore is purpose-built for "limit concurrent access to N." Different coordination shapes, not interchangeable despite both tracking a count internally.