Executors, CompletableFuture, and virtual threads
Thread pools and futures, composing async work with CompletableFuture, the wrapper exceptions both put around a failure, and what Java 21's virtual threads change.
After this lesson you can
- Submit work to an ExecutorService and shut it down correctly
- Unwrap ExecutionException and CompletionException to the real failure
- Compose asynchronous steps with thenApply, thenCombine and handle
- Say when virtual threads help and when they do not
Creating a platform thread per task is expensive, and an unbounded
number of them exhausts memory. An ExecutorService separates what
to run from how: you submit tasks, and a pool of threads runs them.
Executors.newFixedThreadPool(n) keeps n threads and a queue of
waiting tasks.
submit(callable) returns a Future. future.get() blocks until the
task finishes and returns its result — or, if the task threw, throws an
ExecutionException whose getCause() is the task's real
exception. An executor must be shut down, or its non-daemon threads
keep the JVM alive; since Java 19 ExecutorService is AutoCloseable,
and close() shuts it down and waits for submitted tasks, so
try-with-resources does both.
import java.util.*;import java.util.concurrent.*; public class Solution { public static List<String> describe() throws InterruptedException { List<String> out = new ArrayList<>(); try (ExecutorService pool = Executors.newFixedThreadPool(2)) { Future<Integer> ok = pool.submit(() -> 6 * 7); Future<Integer> bad = pool.submit(() -> { if (true) throw new IllegalStateException("inventory offline"); return 0; }); try { out.add("ok.get() = " + ok.get()); bad.get(); } catch (ExecutionException e) { out.add("bad.get() threw " + e.getClass().getSimpleName()); out.add("cause: " + e.getCause().getClass().getSimpleName() + ": " + e.getCause().getMessage()); } } // close() shuts the pool down and waits for its tasks return out; }}CompletableFuture: composing instead of blocking
CompletableFuture lets you describe what happens when a result
arrives, without blocking a thread to wait for it:
supplyAsync(supplier, executor)starts the work;thenApply(fn)transforms the result;thenCompose(fn)chains a step that is itself asynchronous;thenCombine(other, fn)joins two independent results;exceptionally(fn)recovers from a failure;handle((value, error) -> ...)sees both outcomes;join()waits for the result, likeget()but throwing the uncheckedCompletionException.
Without an executor argument, the async methods run on the shared
ForkJoinPool.commonPool() (or on a new thread per task when that pool
has a parallelism below 2), which is sized for CPU work — do not block
in it. Pass your own executor for I/O.
A failure travelling down a chain usually arrives wrapped: when the
supplier throws, the future completes with a CompletionException
whose cause is the original exception, and that wrapper is what
handle and exceptionally receive.
import java.util.*;import java.util.concurrent.*; public class Solution { public static List<String> describe() { List<String> out = new ArrayList<>(); try (ExecutorService pool = Executors.newFixedThreadPool(2)) { CompletableFuture<Integer> price = CompletableFuture.supplyAsync(() -> 20, pool); CompletableFuture<Integer> quantity = CompletableFuture.supplyAsync(() -> 3, pool); int total = price.thenCombine(quantity, (p, q) -> p * q) .thenApply(t -> t + 5) // shipping .join(); out.add("total = " + total); CompletableFuture<Integer> failing = CompletableFuture.supplyAsync(() -> { throw new IllegalArgumentException("unknown product"); }, pool); String seen = failing.handle((value, error) -> error.getClass().getSimpleName() + " wrapping " + error.getCause().getClass().getSimpleName()).join(); out.add("handle saw: " + seen); int recovered = failing.exceptionally(error -> -1).join(); out.add("exceptionally -> " + recovered); try { failing.join(); } catch (CompletionException e) { out.add("join threw CompletionException, cause: " + e.getCause().getMessage()); } } return out; }}Virtual threads (Java 21)
A virtual thread is a Thread managed by the JVM rather than the OS.
When it blocks — on I/O, sleep, a lock from java.util.concurrent —
the JVM unmounts it from its carrier (platform) thread, which goes on
to run another virtual thread. Millions can exist at once, so the
simple thread-per-request style scales again:
try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
for (Request r : requests) executor.submit(() -> handle(r));
}
import java.time.Duration;import java.util.*;import java.util.concurrent.*;import java.util.concurrent.atomic.AtomicInteger; public class Solution { public static String describe() { AtomicInteger completed = new AtomicInteger(); AtomicInteger onVirtual = new AtomicInteger(); try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) { for (int i = 0; i < 10_000; i++) { executor.submit(() -> { if (Thread.currentThread().isVirtual()) onVirtual.incrementAndGet(); Thread.sleep(Duration.ofMillis(100)); // a blocking call completed.incrementAndGet(); return null; }); } } // close() waits for all 10,000 tasks return "completed=" + completed.get() + " ranOnVirtualThreads=" + onVirtual.get(); }}They make blocking cheap; they do not make CPU-bound code faster —
by default the scheduler has only as many carriers as cores. Do not
pool them (they are meant to be created per task); use a Semaphore
to limit access to a scarce resource instead. In Java 21, blocking
while holding a synchronized monitor pins the virtual thread to its
carrier, so guard long blocking operations with a ReentrantLock
rather than synchronized. (Java 24 removed this limitation; on Java
21 it still applies.)
Common mistakes
- Never shutting an executor down — the program does not exit, or threads leak in a long-running service.
- Logging the
ExecutionException/CompletionExceptionitself and reporting "ExecutionException" as the error; the useful information is ingetCause(). - Calling
get()/join()right after eachsubmitin a loop, which makes the "parallel" code run one task at a time. - Blocking inside
CompletableFuturestages that run on the common pool, starving everything else that uses it.
What interviewers ask
FutureversusCompletableFuture? AFuturecan only be polled, waited on or cancelled; a can be composed, combined and completed explicitly, with callbacks instead of blocking.
Exercise · Pro
Fan out with CompletableFuture and report the real failure
Exercises are free in the first 3 lessons of every course. From here on they are part of Pro and Sprint; the lesson and its runnable examples stay open.
Upgrade