Futures and CompletableFuture
A thread that computes something is only half useful if you cannot get the
answer back. Future is how you do that, and CompletableFuture is how you
combine several without ending up in a nest of callbacks.
Future: a result that is not ready yet
ExecutorService pool = Executors.newFixedThreadPool(4);
Future<Long> total = pool.submit(() -> billing.monthlyTotalPaise(month));
// … do something else …
long paise = total.get(); // blocks until it is ready
submit returns immediately with a handle. get() waits.
Three things about get() that matter:
It blocks, possibly forever. Always prefer the timeout version:
try {
long paise = total.get(5, TimeUnit.SECONDS);
} catch (TimeoutException e) {
total.cancel(true);
throw new IllegalStateException("billing took more than 5s", e);
}
A get() with no timeout in a request handler is how a thread pool fills up
with threads waiting on something that will never arrive.
Exceptions arrive wrapped. Anything thrown inside the task comes back as an
ExecutionException, with the real one as its cause:
try {
total.get(5, TimeUnit.SECONDS);
} catch (ExecutionException e) {
Throwable actual = e.getCause(); // the exception your task threw
…
}
Forget getCause() and your logs fill with ExecutionException and no
information.
InterruptedException must be handled properly. If you catch it and cannot
propagate it, restore the flag:
catch (InterruptedException e) {
Thread.currentThread().interrupt(); // never swallow this silently
throw new IllegalStateException("interrupted while billing", e);
}
Swallowing an interrupt means the thread carries on doing work somebody asked it to stop doing, and shutdown hangs.
The limits of Future
Future can only be waited on. You cannot say "when this finishes, do that" —
you have to block a thread to find out it finished, which wastes the thread you
were trying to save.
Future<Rates> rates = pool.submit(this::loadRates);
Future<List<Delivery>> deliveries = pool.submit(this::loadDeliveries);
// Both run in parallel — good — but the calling thread now sits idle.
Rates r = rates.get();
List<Delivery> d = deliveries.get();
return bill(r, d);
CompletableFuture: composing instead of blocking
CompletableFuture<Rates> rates =
CompletableFuture.supplyAsync(this::loadRates, pool);
CompletableFuture<List<Delivery>> deliveries =
CompletableFuture.supplyAsync(this::loadDeliveries, pool);
CompletableFuture<String> report =
rates.thenCombine(deliveries, this::bill)
.thenApply(this::format);
String text = report.join();
Same parallelism, but the combining and formatting are described rather than
waited for. Nothing blocks until join(), and by then everything is done.
The methods worth knowing
| Method | Does |
|---|---|
supplyAsync(fn, pool) |
start work, produce a value |
runAsync(fn, pool) |
start work, no value |
thenApply(fn) |
transform the result |
thenCompose(fn) |
chain another future — avoids nesting |
thenCombine(other, fn) |
wait for two, combine |
allOf(a, b, c) |
wait for all |
anyOf(a, b, c) |
wait for the first |
exceptionally(fn) |
recover from a failure |
handle((v, e) -> …) |
deal with both outcomes together |
thenApply versus thenCompose is the one people get wrong.
thenApply with a function that itself returns a future gives you
CompletableFuture<CompletableFuture<T>>. thenCompose flattens it — the same
distinction as map versus flatMap on a stream.
Always pass the pool
CompletableFuture.supplyAsync(this::loadRates); // uses the common pool
CompletableFuture.supplyAsync(this::loadRates, pool); // uses yours
Without an executor, the work runs on ForkJoinPool.commonPool(), which is
shared by the whole JVM and sized to your CPU count minus one. Blocking I/O
there starves parallel streams and everything else using it — and it is a
genuinely nasty bug to diagnose, because the symptom appears in unrelated code.
Pass your own executor. Every time.
Errors do not throw where you wrote them
CompletableFuture.supplyAsync(this::loadRates, pool)
.thenApply(this::bill) // skipped if loadRates threw
.exceptionally(e -> {
log.warn("billing failed", e);
return "Report unavailable";
});
A failure short-circuits the chain to the first handler. If there is no handler
and nobody calls join(), the exception vanishes silently — no stack trace,
no log line, nothing.
That is the single most common CompletableFuture bug. Every chain should end
in exceptionally, handle, or a join() that is inside a try/catch.
// join throws CompletionException (unchecked); get throws ExecutionException
// (checked). Both wrap the real cause.
try {
return report.join();
} catch (CompletionException e) {
throw new ReportFailedException(e.getCause());
}
Virtual threads changed the calculus
The previous lesson introduced virtual threads. They change when you need any of this.
CompletableFuture exists largely so that blocking does not waste a platform
thread. A virtual thread that blocks costs almost nothing — the JVM parks it and
reuses the carrier thread.
So on Java 21, plain blocking code inside virtual threads is often clearer and just as fast:
try (var scope = Executors.newVirtualThreadPerTaskExecutor()) {
Future<Rates> rates = scope.submit(this::loadRates);
Future<List<Delivery>> deliveries = scope.submit(this::loadDeliveries);
return format(bill(rates.get(), deliveries.get())); // blocking is fine here
}
That reads like the sequential version and runs like the concurrent one.
Use CompletableFuture when you genuinely need composition — combining,
racing, recovering, fanning out. Use virtual threads and straightforward
blocking code when you just want several things to happen at once, which is
most of the time.
The older advice — "never block" — was advice about platform threads. It is worth knowing why it existed, and worth knowing it no longer applies the same way.
Check your work
What Future.get() costs: it blocks, so always use the timeout form.
Why getCause() matters: task exceptions arrive wrapped in
ExecutionException.
Why to restore the interrupt flag: swallowing it means the thread ignores a request to stop, and shutdown hangs.
What Future cannot do: react to completion — you must block to find out.
thenApply versus thenCompose: the second flattens a nested future, like
flatMap.
Why always pass an executor: the default common pool is shared JVM-wide, and blocking in it starves unrelated code.
The silent failure: a chain with no exceptionally/handle and no join()
loses its exception entirely.
What virtual threads changed: blocking is cheap again, so plain sequential -looking code is often the better answer.
When CompletableFuture still wins: genuine composition — combining,
racing, recovering.
Practice
- Submit a task returning a value and retrieve it with
get(2, SECONDS). - Make the task sleep for five seconds and confirm the timeout fires, then cancel it.
- Throw inside a task. Catch
ExecutionExceptionand print both it andgetCause(). - Catch
InterruptedExceptionwithout restoring the flag, then try to shut the pool down cleanly. - Load two things with
supplyAsyncand combine them withthenCombine. Time it against doing them one after the other. - Write a chain using
thenApplywith a function returning a future. Look at the type, then fix it withthenCompose. - Throw inside a
supplyAsyncchain with noexceptionallyand nojoin. Confirm nothing is printed anywhere. - Add
exceptionallyand confirm you get a usable fallback. - Run twenty blocking tasks on the common pool, then on your own fixed pool. Compare the timings.
- Rewrite exercise 5 with
newVirtualThreadPerTaskExecutorand plain blockingget(). Decide which you would rather read.
Next: deadlock, and finding it in a thread dump.
Stuck on this lesson?
Being stuck is part of it — but being stuck alone for three days is not. Our internship programme pairs this curriculum with code review and one-to-one help from working developers, and it is free.
About the internship