Executors & CompletableFuture
Thread pools done right, and composing asynchronous work with CompletableFuture instead of nesting callbacks.
On this page
Creating threads by hand with new Thread() doesn't scale - you can't manage lifecycles, limit
concurrency, or reuse threads. The executor framework solves this by separating what work runs from
how it's scheduled, and CompletableFuture lets you compose asynchronous results without callback
nesting. Together they're how modern Java does concurrency.
Executors: pools, not raw threads
An ExecutorService manages a pool of reusable threads. You submit tasks; the pool schedules them,
reusing threads instead of paying the cost of creating one per task:
ExecutorService pool = Executors.newFixedThreadPool(4); // reuse 4 threads
Future<Integer> future = pool.submit(() -> expensiveComputation()); // returns a handle
Integer result = future.get(); // blocks until the task completes
pool.shutdown(); // stop accepting new tasks, let running ones finishChoosing the pool matters: a fixed pool bounds concurrency (good for CPU-bound work, sized near the
core count); a cached pool grows as needed (I/O-bound bursts); and since Java 21,
Executors.newVirtualThreadPerTaskExecutor() gives each task its own virtual thread (the next
lesson).
CompletableFuture: composing async work
A plain Future only lets you block on get(). CompletableFuture lets you build a pipeline of
asynchronous steps - transform a result, chain a dependent call, combine two futures - without ever
blocking or nesting callbacks:
CompletableFuture
.supplyAsync(() -> fetchUser(id)) // async step 1
.thenApply(user -> user.email()) // transform the result
.thenCompose(email -> sendAsync(email)) // chain another async call
.thenAccept(result -> log(result)) // consume the final value
.exceptionally(ex -> { log(ex); return null; }); // handle failures in the chainEach stage runs when the previous completes, off the calling thread. thenCompose flattens a future of a
future (like flatMap), thenCombine merges two independent futures, and exceptionally/handle
manage errors along the whole pipeline - all without blocking.
thenApply vs. thenCompose
Use thenApply when your function returns a plain value (transform the result). Use thenCompose when your
function returns another CompletableFuture (chain a dependent async call) - it flattens the nested future,
exactly like Optional.map vs. flatMap or Stream.map vs. flatMap. Mixing them up gives you a
nested CompletableFuture<CompletableFuture<T>>, a classic bug.
Spawning a raw thread per task is like hiring a brand-new cook for every single order and firing them when the dish is done - wasteful and chaotic. An executor is a head chef with a fixed line of cooks: orders (tasks) go on a rail, and whichever cook is free takes the next one, so the same staff handles a stream of orders efficiently. CompletableFuture is the prep pipeline: chop, then sauté, then plate - each step starts when the previous finishes, and you describe the whole sequence up front rather than standing over each pot waiting.
You need to call two independent slow services - fetch a user's profile and their recent orders - then combine them into a summary. Describe how to do this with CompletableFuture so the two calls run in parallel, and what you'd use to combine them.
What advantage does CompletableFuture have over a plain Future?
Key takeaways
- Use an ExecutorService thread pool instead of raw new Thread() - it reuses threads and bounds concurrency.
- Choose the pool for the workload: fixed (CPU-bound), cached (I/O bursts), or virtual-thread-per-task (Java 21+).
- CompletableFuture composes asynchronous steps into a non-blocking pipeline instead of blocking on Future.get().
- thenApply transforms a value; thenCompose chains a dependent future (flatten); thenCombine merges two independent futures.
- Start independent async calls before joining them (thenCombine) so they run in parallel, not serially.