Java
ExecutorService
multithreading
concurrency
thread management

How to wait for all threads to finish, using ExecutorService?

Master System Design with Codemia

Enhance your system design skills with over 120 practice problems, detailed solutions, and hands-on exercises.

Introduction

When you submit work to an ExecutorService, the tasks start running asynchronously and your main thread keeps moving. If later code depends on those tasks being finished, you need an explicit coordination point. Java gives you several good options, and the best one depends on whether you only care about completion or also need the results.

The Basic Pattern: shutdown() Plus awaitTermination()

If you just want to wait until all submitted tasks finish, the standard approach is:

  1. submit the tasks
  2. call shutdown()
  3. call awaitTermination()
java
1import java.util.concurrent.ExecutorService;
2import java.util.concurrent.Executors;
3import java.util.concurrent.TimeUnit;
4
5public class WaitForPool {
6    public static void main(String[] args) throws InterruptedException {
7        ExecutorService pool = Executors.newFixedThreadPool(3);
8
9        for (int i = 1; i <= 5; i++) {
10            final int taskId = i;
11            pool.submit(() -> {
12                System.out.println("Starting task " + taskId);
13                try {
14                    Thread.sleep(500);
15                } catch (InterruptedException e) {
16                    Thread.currentThread().interrupt();
17                }
18                System.out.println("Finished task " + taskId);
19            });
20        }
21
22        pool.shutdown();
23
24        if (!pool.awaitTermination(10, TimeUnit.SECONDS)) {
25            pool.shutdownNow();
26        }
27    }
28}

Why this works:

  • 'shutdown() stops new tasks from being submitted.'
  • already submitted tasks are still allowed to run
  • 'awaitTermination() blocks the caller until the pool is done or a timeout expires'

This is the right tool when you want a clear "wait here until everything is finished" point.

When You Need Results: Keep the Future Objects

If each task returns a value, capture the Future returned by submit(). Waiting on each future guarantees completion and also gives you each result.

java
1import java.util.ArrayList;
2import java.util.List;
3import java.util.concurrent.*;
4
5public class WaitForResults {
6    public static void main(String[] args)
7            throws InterruptedException, ExecutionException {
8        ExecutorService pool = Executors.newFixedThreadPool(3);
9        List<Future<Integer>> futures = new ArrayList<>();
10
11        for (int i = 1; i <= 4; i++) {
12            final int value = i;
13            futures.add(pool.submit(() -> value * value));
14        }
15
16        for (Future<Integer> future : futures) {
17            System.out.println(future.get());
18        }
19
20        pool.shutdown();
21    }
22}

future.get() blocks until that task completes. If the task failed, get() throws an ExecutionException, which is exactly what you want when failure should not be silently ignored.

invokeAll() for a Batch of Callable Tasks

When you already have a collection of Callable tasks, invokeAll() is often the cleanest solution. It submits the whole batch and waits until all of them finish.

java
1import java.util.List;
2import java.util.concurrent.*;
3
4public class InvokeAllExample {
5    public static void main(String[] args) throws Exception {
6        ExecutorService pool = Executors.newFixedThreadPool(2);
7
8        List<Callable<String>> tasks = List.of(
9            () -> "alpha",
10            () -> "beta",
11            () -> "gamma"
12        );
13
14        List<Future<String>> futures = pool.invokeAll(tasks);
15
16        for (Future<String> future : futures) {
17            System.out.println(future.get());
18        }
19
20        pool.shutdown();
21    }
22}

This is especially nice when the tasks are already represented as a collection and the caller naturally wants to wait for the entire group.

Which Option Should You Use?

Use shutdown() plus awaitTermination() when:

  • tasks are fire-and-forget
  • you care about completion, not return values
  • you want one shutdown point for the whole pool

Use stored Future objects when:

  • each task produces a result
  • task failures must be surfaced
  • you need per-task control

Use invokeAll() when:

  • you have a batch of Callable tasks
  • waiting for the whole group is the main goal

These approaches can also be combined. For example, you can keep futures for results and still call shutdown() once all submissions are complete.

Common Pitfalls

One common mistake is calling awaitTermination() without calling shutdown() first. If the pool is still accepting new work, termination may never happen.

Another problem is ignoring interruption. If awaitTermination() or future.get() throws InterruptedException, restore the interrupt status with Thread.currentThread().interrupt() unless you have a clear reason not to.

Some code calls shutdownNow() immediately, expecting it to wait. It does not. It attempts to interrupt running tasks and returns quickly, so it is not the normal waiting mechanism.

Timeouts also matter. Waiting forever can hide deadlocks or hung tasks. In production systems, use a sensible timeout and log or handle the failure path explicitly.

Finally, do not forget that submit() wraps exceptions inside the future. If you never inspect the future, task failures can go unnoticed.

Summary

  • The usual way to wait for all ExecutorService tasks is shutdown() followed by awaitTermination().
  • If tasks return values, keep the Future objects and call get().
  • 'invokeAll() is a clean batch API for collections of Callable tasks.'
  • Always handle timeouts and interruption deliberately.
  • Waiting for completion and collecting results are related, but they are not the same requirement.

Course illustration
Course illustration

All Rights Reserved.