Articles
CountDownLatch vs CyclicBarrier Interview Questions: Coordination and Broken Workers
Compare CountDownLatch and CyclicBarrier in Java interviews: worker failures, timeouts, interruption, thread-pool starvation, and safe phase recovery.
“CountDownLatch is one-shot; CyclicBarrier is reusable” is a useful opening answer. It becomes incomplete as soon as one worker throws before reaching the synchronization point.
The stronger interview answer identifies who waits, what completion means, and who owns recovery. A latch can tell a coordinator that all accounted-for workers have finished. A barrier makes a fixed group rendezvous before moving into another phase. Neither statement, by itself, means the work succeeded.
This guide separates official Java contracts from original failure schedules and implementation recommendations. The Java snippets were reviewed against JDK documentation; no JVM execution is claimed here.
Choose according to the waiting relationship
Official facts: CountDownLatch releases waiters after its count reaches zero and cannot be reset. CyclicBarrier releases a fixed number of arriving parties and can be reused for another generation. A barrier action, when supplied, runs on the last arriving thread. CountDownLatch, CyclicBarrier.
For an original batch-sort exercise, three workers sort independent chunks and a coordinator merges them afterward. The workers do not need to wait for each other after sorting. A completion latch fits that relationship; futures can additionally carry each sorted result or exception.
Now change the task to iterative processing. Each worker computes a new portion of shared phase state, then all workers must observe the merged phase before beginning the next iteration. A barrier can express that repeated rendezvous. The coordinator does not become a party unless it actually participates in every required phase.
| Interview decision | CountDownLatch | CyclicBarrier |
|---|---|---|
| Typical waiting relationship | Coordinator waits for counted events | Parties wait for one another |
| After a successful release | Remains open at zero | Next generation is available |
| Worker fails before signaling | Count may remain positive | Missing arrival may leave peers waiting |
| Result and failure transport | Supply separately | Supply separately |
The “counted events” wording matters. A latch initialized to three does not register three named threads. Three calls from one thread can exhaust it. Your application must enforce one terminal signal per intended work item.
Define completion separately from success
Our original contract is: each admitted worker records a result or failure, then signals its terminal outcome exactly once. The coordinator may merge only if every required worker succeeded. A worker's finally block is a useful place for its completion signal, because ordinary exceptions must not leave the batch permanently waiting.
That design changes what zero means. Zero means “all expected terminal paths were accounted for,” not “all chunks sorted correctly.” A shared result array containing a missing chunk must never be merged merely because await() returned.
Official fact: actions before a latch's countDown() happen-before actions after a corresponding successful await(). This visibility guarantee supports reading state published before the signal. It does not supply an application success flag. CountDownLatch memory-consistency contract.
A future is a useful separate outcome channel. Its get() can return a value or report failure; cancellation is another terminal outcome. Official fact: Future distinguishes normal results, execution exceptions, and cancellation. Future reference.
Explain who performs each write. One worker owning one result slot is easier to reason about than several workers appending unsafely to the same list. If there are shared mutable collections, the latch does not make their concurrent updates intrinsically safe.
Trace a worker that disappears before the checkpoint
Use this original schedule with workers A, B, and C:
- A finishes its local work and arrives at a barrier configured for three parties.
- B throws while computing, before calling
await(). - C finishes and calls
await(). - A and C remain short of the required third arrival.
A missing party is not automatically a broken barrier. The barrier cannot infer an exception thrown by code that never called it. An unbounded wait can remain stuck until something else intervenes.
Official fact: CyclicBarrier uses collective breakage when a synchronization attempt fails through interruption or timeout; peers waiting at that barrier leave abnormally. A barrier action that throws also breaks the barrier. CyclicBarrier failure contract.
Our recommended recovery policy is therefore explicit: use bounded arrival waits, record B's original failure, cancel sibling work, and make the coordinator reject the phase. Preserve the causal error from B rather than reporting only the timeout later observed by A.
For the latch variant, put B's terminal signal in finally and keep its exception in the future. The latch can reach zero while the coordinator still rejects the merge. That is a correct failed batch, not a synchronization failure disguised as success.
Write a bounded batch skeleton
This original Java method illustrates one batch with caller-owned execution capacity. The supplied operation must cooperate with interruption and must not submit dependent work back into the same saturated pool. It is a source-reviewed example, not a complete production executor framework.
static List<Integer> runBatch(
ExecutorService pool, IntUnaryOperator work)
throws InterruptedException, ExecutionException,
TimeoutException {
int workers = 3;
CountDownLatch done = new CountDownLatch(workers);
List<Future<Integer>> futures = new ArrayList<>();
long deadline = System.nanoTime()
+ TimeUnit.SECONDS.toNanos(2);
boolean success = false;
try {
for (int i = 0; i < workers; i++) {
final int slot = i;
futures.add(pool.submit(() -> {
try {
return work.applyAsInt(slot);
} finally {
done.countDown();
}
}));
}
long left = Math.max(0, deadline - System.nanoTime());
if (!done.await(left, TimeUnit.NANOSECONDS)) {
throw new TimeoutException("batch incomplete");
}
List<Integer> results = new ArrayList<>();
for (Future<Integer> f : futures) {
left = Math.max(0, deadline - System.nanoTime());
results.add(f.get(left, TimeUnit.NANOSECONDS));
}
success = true;
return results;
} finally {
if (!success) {
for (Future<Integer> f : futures) {
f.cancel(true);
}
}
}
}
The two-second budget is an original exercise setting. All waits share one deadline; assigning two seconds independently to every future could exceed the intended overall budget. If submission is rejected, the method exits through cleanup and does not wait for a latch whose full count can no longer be reached.
This skeleton uses completion-first error collection, so it may wait for the deadline when one task fails and another stalls. If the requirement is immediate sibling cancellation on the first failure, propose a completion queue or a separately coordinated failure signal. Do not call this version fail-fast.
The latch also creates a narrow ordering gap: a task counts down in its body before its future publishes completion. The subsequent timed get() accounts for that gap. A successful latch wait alone does not guarantee that every future's bookkeeping is already complete.
Understand cancellation before using it as cleanup
Official fact: cancelling a future may prevent an unstarted task from running; interrupting a running computation is an attempt to stop it, not proof that it has terminated. ExecutorService also separates orderly shutdown from attempts to stop outstanding work. Future cancellation, ExecutorService lifecycle.
In our skeleton, a task cancelled before starting never enters the worker's finally. The latch might therefore remain above zero after cleanup. That is acceptable because the failed invocation abandons that latch and does not wait on it again.
If your design promises that a completion latch always reaches zero, even for cancelled queued tasks, worker-body signaling alone is insufficient. You need a single terminal-accounting owner that handles never-started cancellation as well as executed work, with protection against double signaling.
At a Runnable boundary that cannot propagate InterruptedException, restore interruption with Thread.currentThread().interrupt() and return through cleanup. At a method that declares the checked exception, propagating it is often clearer. Swallowing interruption and continuing into another barrier generation makes cancellation behavior unpredictable.
Check pool capacity before blaming the primitive
Our original starvation counterexample uses a two-thread pool and a three-party barrier. The first two tasks reach the barrier and occupy both workers. The third task remains queued. No party can advance, even if every task's computation is correct.
Adding a timeout makes the failure observable; it does not make that capacity design sound. Give the rendezvous enough simultaneously runnable participants, remove the blocking dependency, or redesign the phases. Explain the dependency graph rather than guessing a larger thread count.
A related latch failure puts the coordinator into the same one-thread pool as the workers it submits. The coordinator blocks on the latch while its children remain queued behind it. Moving the wait outside that pool can remove this particular dependency, but nested submissions need their own review.
Official fact: ThreadPoolExecutor documents its queueing and saturation behavior, including bounded queues and rejected tasks. ThreadPoolExecutor reference.
Use this contract to explain admission failures separately from computation failures. A discarded task is especially dangerous when some other component still waits for its signal. “The executor accepted my method call” is not a sufficient completion guarantee under every rejection policy.
Reuse generations only with an ownership plan
A successful barrier trip starts another generation. That is useful for the same group performing repeated phases, but partial state from a failed phase must not silently become input to the next one.
Our recovery recommendation is to identify the failed generation, stop or join its surviving workers, and establish a clean participant set before restarting. Include a phase ID in logs and keep phase output isolated until the merge decision succeeds.
Calling reset() while old workers are still arriving can mix recovery with an unfinished generation. Official fact: reset breaks currently waiting parties, and the JDK warns that coordinating reset after breakage can be complicated. CyclicBarrier reset contract.
Resetting the object does not recreate B, replenish execution capacity, undo partially written data, or decide whether an external operation should be retried. Those are application responsibilities. If participants change dynamically, first clarify that requirement; a fixed-party barrier may no longer express the task cleanly.
Demonstrate the schedule, not a lucky sleep
For an interview test, use explicit start gates to hold B before its intended failure, then observe the other workers reaching the checkpoint. Test failure before arrival, interruption during a wait, timeout, and a throwing barrier action as separate cases. Bound every test and clean up its threads.
A short sleep cannot prove that another worker reached the relevant state. Log worker ID, phase, arrival, terminal outcome, and recovery decision. Show that a failed phase never produces an accepted merge.
Sources and Further Reading
- JDK 26 CountDownLatch
- JDK 26 CyclicBarrier
- JDK 26 Future
- JDK 26 ExecutorService
- JDK 26 ThreadPoolExecutor
Research checked October 4, 2026. Failure schedules are original reasoning exercises; source review does not replace executing and stress-testing a Java implementation.