Java
Multithreading
Thread Synchronization
Concurrent Programming
Thread Management

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:

java
1Thread t1 = new Thread(() -> {
2    try { Thread.sleep(1000); } catch (InterruptedException e) {}
3    System.out.println("Thread 1 done");
4});
5
6Thread t2 = new Thread(() -> {
7    try { Thread.sleep(2000); } catch (InterruptedException e) {}
8    System.out.println("Thread 2 done");
9});
10
11t1.start();
12t2.start();
13
14t1.join();  // Block until t1 finishes
15t2.join();  // Block until t2 finishes (may already be done)
16
17System.out.println("Both threads complete");

join() with a timeout:

java
1t1.join(5000);  // Wait at most 5 seconds
2if (t1.isAlive()) {
3    System.out.println("Thread 1 is still running after 5s");
4}

CountDownLatch

A latch initialized with a count. Each thread calls countDown() when done. The waiting thread blocks on await() until the count reaches zero:

java
1import java.util.concurrent.CountDownLatch;
2
3int threadCount = 3;
4CountDownLatch latch = new CountDownLatch(threadCount);
5
6for (int i = 0; i < threadCount; i++) {
7    final int id = i;
8    new Thread(() -> {
9        try {
10            Thread.sleep(1000 * (id + 1));
11            System.out.println("Thread " + id + " done");
12        } catch (InterruptedException e) {
13            Thread.currentThread().interrupt();
14        } finally {
15            latch.countDown();  // Decrement the count
16        }
17    }).start();
18}
19
20latch.await();  // Block until count reaches 0
21System.out.println("All threads complete");
22
23// With timeout
24boolean completed = latch.await(10, TimeUnit.SECONDS);
25if (!completed) {
26    System.out.println("Timeout: not all threads finished");
27}

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:

java
1import java.util.concurrent.*;
2import java.util.List;
3
4ExecutorService executor = Executors.newFixedThreadPool(4);
5
6List<Callable<String>> tasks = List.of(
7    () -> { Thread.sleep(1000); return "Result 1"; },
8    () -> { Thread.sleep(2000); return "Result 2"; },
9    () -> { Thread.sleep(1500); return "Result 3"; }
10);
11
12// invokeAll blocks until ALL tasks complete
13List<Future<String>> futures = executor.invokeAll(tasks);
14
15for (Future<String> future : futures) {
16    System.out.println(future.get());  // Already done, no blocking
17}
18
19executor.shutdown();

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

java
1ExecutorService executor = Executors.newFixedThreadPool(4);
2
3for (int i = 0; i < 10; i++) {
4    final int id = i;
5    executor.submit(() -> {
6        Thread.sleep(1000);
7        System.out.println("Task " + id + " done");
8        return null;
9    });
10}
11
12executor.shutdown();  // No new tasks accepted
13boolean done = executor.awaitTermination(30, TimeUnit.SECONDS);
14
15if (done) {
16    System.out.println("All tasks completed");
17} else {
18    System.out.println("Timeout — forcing shutdown");
19    executor.shutdownNow();  // Interrupt running tasks
20}

CompletableFuture.allOf()

The modern async approach (Java 8+):

java
1import java.util.concurrent.CompletableFuture;
2
3CompletableFuture<String> f1 = CompletableFuture.supplyAsync(() -> {
4    sleep(1000);
5    return "Result 1";
6});
7
8CompletableFuture<String> f2 = CompletableFuture.supplyAsync(() -> {
9    sleep(2000);
10    return "Result 2";
11});
12
13CompletableFuture<String> f3 = CompletableFuture.supplyAsync(() -> {
14    sleep(1500);
15    return "Result 3";
16});
17
18// Wait for all to complete
19CompletableFuture.allOf(f1, f2, f3).join();
20
21// Collect results
22System.out.println(f1.join());  // Result 1
23System.out.println(f2.join());  // Result 2
24System.out.println(f3.join());  // Result 3

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:

java
1import java.util.concurrent.CyclicBarrier;
2
3CyclicBarrier barrier = new CyclicBarrier(3, () -> {
4    System.out.println("All threads reached the barrier");
5});
6
7for (int i = 0; i < 3; i++) {
8    final int id = i;
9    new Thread(() -> {
10        System.out.println("Thread " + id + " working...");
11        try {
12            Thread.sleep(1000 * (id + 1));
13            barrier.await();  // Wait for all threads to reach here
14        } catch (Exception e) {
15            Thread.currentThread().interrupt();
16        }
17        System.out.println("Thread " + id + " continuing after barrier");
18    }).start();
19}

Unlike CountDownLatch, a CyclicBarrier can be reused for multiple rounds.

Comparison

MechanismReusableTimeoutReturn ValuesBest For
Thread.join()NoYesNoSimple raw threads
CountDownLatchNoYesNoUnknown thread count at wait time
ExecutorService.invokeAll()N/AYesYesTask pools with results
CompletableFuture.allOf()N/AYesYesAsync pipelines
CyclicBarrierYesYesNoPhased parallel algorithms

Common Pitfalls

  • Not calling join() in a loop for multiple threads: Calling t1.join(); t2.join(); is correct — t2.join() may return immediately if t2 finished while waiting for t1. But forgetting to join one thread means the main thread may exit before it completes.
  • CountDownLatch countDown() not in a finally block: If a thread throws an exception before calling countDown(), the latch never reaches zero and await() blocks forever. Always put countDown() in a finally block.
  • 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 with ExecutorService (Java 19+) or call shutdown() in a finally block.
  • Calling get() instead of join() on CompletableFuture: get() throws checked exceptions (ExecutionException, InterruptedException) requiring try-catch. join() throws unchecked CompletionException. Use join() 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 use join(), await(), or Future.get() for reliable synchronization.

Summary

  • Use Thread.join() for simple cases with a known number of raw threads
  • Use CountDownLatch when 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 timeout and TimeUnit
  • Put cleanup calls (countDown(), shutdown()) in finally blocks to prevent hangs

Course illustration
Course illustration

All Rights Reserved.