Waiting on multiple threads to complete in Java
Master System Design with Codemia
Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.
Introduction
Java provides several mechanisms to wait for multiple threads to finish before proceeding: Thread.join() blocks until a specific thread completes, CountDownLatch blocks until a counter reaches zero, ExecutorService.invokeAll() runs tasks and returns when all complete, and CompletableFuture.allOf() provides an async approach. The best choice depends on whether you use raw threads or an executor framework, and whether you need timeouts or return values.
Thread.join()
The simplest approach — call join() on each thread:
join() with a timeout:
CountDownLatch
A latch initialized with a count. Each thread calls countDown() when done. The waiting thread blocks on await() until the count reaches zero:
CountDownLatch is preferred over join() when you do not hold references to individual threads, or when threads are created by a framework.
ExecutorService with invokeAll()
Submit tasks to an executor and wait for all results:
invokeAll() returns when every task has completed (or thrown an exception). Each Future.get() returns immediately since the task is already done.
ExecutorService with shutdown and awaitTermination
CompletableFuture.allOf()
The modern async approach (Java 8+):
allOf() returns a CompletableFuture<Void> that completes when all input futures complete. Call .join() or .get() to block until that happens.
CyclicBarrier (Phased Synchronization)
For threads that need to synchronize at a specific point, then continue:
Unlike CountDownLatch, a CyclicBarrier can be reused for multiple rounds.
Comparison
| Mechanism | Reusable | Timeout | Return Values | Best For |
Thread.join() | No | Yes | No | Simple raw threads |
CountDownLatch | No | Yes | No | Unknown thread count at wait time |
ExecutorService.invokeAll() | N/A | Yes | Yes | Task pools with results |
CompletableFuture.allOf() | N/A | Yes | Yes | Async pipelines |
CyclicBarrier | Yes | Yes | No | Phased parallel algorithms |
Common Pitfalls
- Not calling
join()in a loop for multiple threads: Callingt1.join(); t2.join();is correct —t2.join()may return immediately ift2finished while waiting fort1. But forgetting to join one thread means the main thread may exit before it completes. - CountDownLatch
countDown()not in afinallyblock: If a thread throws an exception before callingcountDown(), the latch never reaches zero andawait()blocks forever. Always putcountDown()in afinallyblock. - Not shutting down ExecutorService:
executor.shutdown()must be called, or the JVM will not exit because the thread pool threads are non-daemon. Use try-with-resources withExecutorService(Java 19+) or callshutdown()in afinallyblock. - Calling
get()instead ofjoin()on CompletableFuture:get()throws checked exceptions (ExecutionException,InterruptedException) requiring try-catch.join()throws uncheckedCompletionException. Usejoin()in non-critical code for cleaner syntax. - Using
Thread.sleep()instead of proper synchronization: Sleeping for a fixed duration and hoping threads are done is fragile. Always usejoin(),await(), orFuture.get()for reliable synchronization.
Summary
- Use
Thread.join()for simple cases with a known number of raw threads - Use
CountDownLatchwhen threads are managed externally and you need a reliable countdown - Use
ExecutorService.invokeAll()when tasks return values and you want managed thread pools - Use
CompletableFuture.allOf()for modern async pipelines with chained operations - Always handle timeouts — use the overloads that accept
timeoutandTimeUnit - Put cleanup calls (
countDown(),shutdown()) infinallyblocks to prevent hangs

