To wait for all threads to finish in Java, we submit the work to an ExecutorService and close it in a try-with-resources block, because close() returns only after every submitted task has completed. For threads we start ourselves, we call join() on each of them.
We need such a wait whenever a task splits into parts that run in parallel and the next step needs all the parts, for example when a photo app creates thumbnails for an album and shows the album only after the last one is ready.
The following example runs three tasks on virtual threads, and the code after the try block runs only when all three are done.
List<Integer> thumbnails = Collections.synchronizedList(new ArrayList<>());
try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
for (int i = 1; i <= 3; i++) {
int photo = i;
pool.submit(() -> thumbnails.add(photo));
}
} // close() waits for all three tasks
int ready = thumbnails.size(); // 3
We never touch a Thread object in that code. Since Java 19, ExecutorService implements AutoCloseable, so the end of the block is the wait. We compare the options first and look at Future.get(), shutdown() with awaitTermination(), invokeAll(), CountDownLatch, close(), CompletableFuture.allOf() and plain join(), each with the case it fits best.
1. Choosing a Way to Wait for Threads
The right method depends on two questions, namely how the work was started and whether we need the results. Code that uses an executor waits through the executor or the futures, code that starts threads by hand joins them, and a latch fits when the threads are not ours at all.

| Way to wait | Gives results | Timeout | Best for |
|---|---|---|---|
| Future.get() in a loop | Yes, in submit order | Per task | A few tasks whose results we need |
| shutdown() + awaitTermination() | No | Yes | A long-lived pool that must stop |
| invokeAll() | Yes, in task order | Yes | A batch of Callable tasks |
| CountDownLatch | No | Yes | Threads or callbacks we do not start ourselves |
| ExecutorService.close(), Java 19 | No | No | A pool that lives only for one batch |
| CompletableFuture.allOf() | Yes, via each future | Yes | Async pipelines that combine results |
| Thread.join() | No | Yes | Threads we created with Thread |
The examples share two small methods. The method resize() stands for real work that returns a size, and the helper pause() parks the thread for a few milliseconds to make the work take time.
static void pause(long ms) {
LockSupport.parkNanos(TimeUnit.MILLISECONDS.toNanos(ms));
}
static int resize(int photoId) {
pause(10L * (5 - photoId % 5)); // later photos finish first
return photoId * 100;
}
2. Using ExecutorService and Future.get()
The ExecutorService method submit() returns a Future for each task. The get() method of a Future waits until that task completes and returns its result, so calling get() on every future waits for all tasks.
ExecutorService pool = Executors.newFixedThreadPool(4);
List<Future<Integer>> futures = new ArrayList<>();
for (int id = 1; id <= 5; id++) {
int photoId = id;
futures.add(pool.submit(() -> resize(photoId)));
}
List<Integer> sizes = new ArrayList<>();
for (Future<Integer> future : futures) {
sizes.add(future.get(5, TimeUnit.SECONDS));
}
pool.shutdown();
List<Integer> allSizes = List.copyOf(sizes); // [100, 200, 300, 400, 500]
The tasks finish in a different order than we submitted them, but the results come back in submit order, because we read the futures in that order. Reading them in order adds no extra wait, since get() returns at once for a task that is already done.
The wait for one task ends early in three cases, and each one surfaces as an exception from get().
- The task threw an exception, so get() throws ExecutionException with the original exception as its cause.
- Someone cancelled the task, so get() throws CancellationException.
- The waiting thread was interrupted or the timeout ended, so get() throws InterruptedException or TimeoutException.
Since Java 19, Future.state() returns SUCCESS, FAILED, CANCELLED or RUNNING, and resultNow() and exceptionNow() read a completed future without checked exceptions, which keeps result loops short.
3. Using ExecutorService shutdown() and awaitTermination()
The shutdown() method stops the executor from accepting new tasks, but the tasks already submitted keep running, and the call itself does not wait. The awaitTermination() method waits until all tasks have completed after a shutdown, the timeout ends or the waiting thread is interrupted.
The method shutdownAndAwaitTermination() is a version of the clean stop in the ExecutorService usage examples that returns whether the pool stopped. It waits up to a minute and, if tasks still run after that, cancels them with shutdownNow(), and it keeps the interrupt flag if the waiting thread is interrupted.
static boolean shutdownAndAwaitTermination(ExecutorService pool) {
pool.shutdown();
try {
if (!pool.awaitTermination(60, TimeUnit.SECONDS)) {
pool.shutdownNow();
return pool.awaitTermination(60, TimeUnit.SECONDS);
}
return true;
} catch (InterruptedException e) {
pool.shutdownNow();
Thread.currentThread().interrupt();
return false;
}
}
ExecutorService pool = Executors.newFixedThreadPool(4);
AtomicInteger resized = new AtomicInteger();
for (int id = 1; id <= 8; id++) {
int photoId = id;
pool.submit(() -> resized.addAndGet(resize(photoId) > 0 ? 1 : 0));
}
boolean finished = shutdownAndAwaitTermination(pool); // true
int count = resized.get(); // 8
The shutdown-and-await pattern fits a pool that lives as long as the app, such as an executor bean that a Spring Boot application shuts down on exit. More shutdown variants are in our guide to ExecutorService shutdown.
4. Using ExecutorService invokeAll()
The invokeAll() method submits a collection of Callable tasks and returns only when all of them have completed, normally or with an exception. It returns the futures in the same order as the tasks, and each of them is already done. The call does not shut the executor down, so we can keep using the executor afterwards.
ExecutorService pool = Executors.newFixedThreadPool(3);
List<Callable<Integer>> tasks = List.of(() -> resize(1), () -> resize(2), () -> resize(3));
List<Future<Integer>> done = pool.invokeAll(tasks, 5, TimeUnit.SECONDS);
List<Integer> sizes = done.stream().map(Future::resultNow).toList(); // [100, 200, 300]
pool.close();
With the timeout overload, tasks that are still running when the time ends are cancelled, and resultNow() throws IllegalStateException for them, so we check state() first in that case. The full guide to invokeAll() and invokeAny() covers failures and the timeout in detail.
5. Using CountDownLatch
A CountDownLatch starts with a count, every finished worker calls countDown(), and await() returns when the count reaches zero. It does not care which threads count down, so it works with threads from any source, such as callbacks of a messaging client.
Say an app checks the network, the cache and the database at startup and reports healthy only when all three checks have completed. Each check counts down in a finally block, so a check that throws does not leave the main thread waiting forever.
List<String> checks = List.of("network", "cache", "database");
CountDownLatch latch = new CountDownLatch(checks.size());
Set<String> passed = ConcurrentHashMap.newKeySet();
for (String check : checks) {
Thread.ofVirtual().start(() -> {
try {
passed.add(check);
} finally {
latch.countDown();
}
});
}
boolean allChecked = latch.await(2, TimeUnit.SECONDS); // true
int healthy = passed.size(); // 3
A latch cannot be reset after it reaches zero. For repeated rounds, a CyclicBarrier or a Phaser is the matching class.
6. Using ExecutorService close()
Since Java 19, ExecutorService has a default close() method, which calls shutdown() and waits until all tasks have completed. With try-with-resources, the closing brace of the block is the wait, as the intro example showed.
AtomicInteger uploaded = new AtomicInteger();
try (ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor()) {
for (int id = 1; id <= 100; id++) {
int photoId = id;
pool.submit(() -> uploaded.addAndGet(resize(photoId) > 0 ? 1 : 0));
}
}
int total = uploaded.get(); // 100
The close() method has no timeout. If the waiting thread is interrupted, close() cancels the running tasks as shutdownNow() would, keeps waiting for them to end and sets the interrupt flag again before it returns. For a one-off batch on newVirtualThreadPerTaskExecutor(), this is the shortest correct code, and for a time limit we use awaitTermination() or invokeAll() with a timeout.
7. Using CompletableFuture.allOf()
The CompletableFuture.allOf() method returns a new future that completes when all given futures complete. We call join() on it to wait, and read each result afterwards with join() on the single futures, which no longer blocks.
For example, a travel site asks three suppliers for a price and shows the cheapest one after all answers are in.
ExecutorService pool = Executors.newVirtualThreadPerTaskExecutor();
List<CompletableFuture<Integer>> quotes = List.of(
CompletableFuture.supplyAsync(() -> resize(1), pool),
CompletableFuture.supplyAsync(() -> resize(2), pool),
CompletableFuture.supplyAsync(() -> resize(3), pool));
CompletableFuture.allOf(quotes.toArray(CompletableFuture[]::new)).get(5, TimeUnit.SECONDS);
int cheapest = quotes.stream().mapToInt(CompletableFuture::join).min().orElseThrow(); // 100
pool.close();
If any future fails, the future from allOf() completes exceptionally, and get() throws ExecutionException. The CompletableFuture guide shows how to recover from failed parts with exceptionally() or handle().
8. Using Thread.join()
When we create threads ourselves, we call join() on each of them. Joining in a loop waits as long as the slowest thread, because a thread that has already terminated returns at once.
List<Thread> workers = new ArrayList<>();
AtomicInteger processed = new AtomicInteger();
for (int id = 1; id <= 4; id++) {
int photoId = id;
workers.add(Thread.ofPlatform().start(() -> processed.addAndGet(resize(photoId) > 0 ? 1 : 0)));
}
for (Thread worker : workers) {
worker.join();
}
int count = processed.get(); // 4
The join() loop gives no results and no error reports from the threads, so for anything beyond small demos, an executor is the better choice. The related Java concurrency guide lists the classes used here.
9. Waiting for Threads FAQs
Two situations drive most of these questions, a task that fails and a batch that takes longer than the caller can wait.
9.1. Does shutdown() wait for running tasks?
No. The shutdown() method only stops new submissions and returns at once. To wait, we call awaitTermination() after it, or use close(), which does both.
9.2. How do we wait for all threads with a timeout?
We use awaitTermination(timeout, unit), invokeAll(tasks, timeout, unit), latch.await(timeout, unit), allOf(…).get(timeout, unit) or join(Duration). Each one returns or throws when the time ends, so we decide whether to cancel the rest.
9.3. What happens if one of the tasks throws an exception?
The other tasks keep running. With futures, the exception shows up as ExecutionException when we call get() on that task, and with allOf(), the combined future fails. With plain threads and a latch, the exception goes to the uncaught exception handler, which is why countDown() belongs in finally.
9.4. Is invokeAll() better than CompletableFuture.allOf()?
For a fixed batch of Callable tasks on one executor, invokeAll() is shorter. For results that feed further async steps, such as combining prices before sending an email, allOf() fits better because it does not block until we call join() or get().
9.5. Can we use close() on Java 17?
No. The close() method was added to ExecutorService in Java 19. On Java 17 and older, we call shutdown() and awaitTermination() as shown in section 3.
10. Conclusion
For tasks on an executor, close() in try-with-resources is the shortest way to wait for all of them, and invokeAll() or a loop over Future.get() adds the results. A long-lived pool waits with shutdown() and awaitTermination().
A CountDownLatch fits threads we do not control, CompletableFuture.allOf() fits async pipelines, and Thread.join() stays the tool for threads we create ourselves.
11. References
- ExecutorService Javadoc (Java 25)
- CountDownLatch Javadoc (Java 25)
- CompletableFuture Javadoc (Java 25)
- Future Javadoc (Java 25)
Happy Learning !!