跪拜 Guibai
← All articles
Java

Future vs. CompletableFuture: When Java's Async Placeholder Isn't Enough

By 唐青枫 ·
Read original on juejin.cn ↗ Google Translate ↗ Alt translation

Any Java service that fans out to multiple downstream APIs, databases, or caches pays a latency tax when those calls run sequentially. CompletableFuture turns independent I/O into concurrent calls with declarative error recovery and timeouts, cutting tail latency without restructuring the whole codebase.

Summary

Java's Future has been the go-to async placeholder since Java 5, but its design forces blocking get() calls and manual coordination when multiple tasks must run together. CompletableFuture, introduced in Java 8, adds a pipeline of thenApply, thenCompose, thenCombine, and exception handlers that execute automatically as each stage completes.

The difference shows up immediately in real workloads. A user homepage that queries four independent services can fire them all concurrently with CompletableFuture.supplyAsync, merge results with allOf, and set per-task timeouts like completeOnTimeout for the coupon service so a slow dependency never blocks the whole page. An order-placement flow mixes serial validation with parallel inventory, user, and price lookups, then chains into a stock-deduction step that depends on the inventory result.

Custom thread pools, bounded queues, and explicit Executor arguments keep business tasks isolated from the shared ForkJoinPool. The guide also covers bridging legacy callback APIs into CompletableFuture, returning CompletableFuture directly from Spring MVC controllers, and pairing virtual threads with CompletableFuture for I/O-heavy workloads in Java 21+.

Takeaways
Future.get() blocks the calling thread; CompletableFuture.join() throws an unchecked CompletionException, making it easier to chain.
thenApply transforms a plain value, while thenCompose flattens a nested CompletableFuture to avoid CompletableFuture<CompletableFuture<T>>.
thenCombine runs two independent async tasks in parallel and merges their results; allOf waits for a whole list of futures.
anyOf and applyToEither let a system proceed with whichever of several redundant calls returns first, useful for multi-region config or failover reads.
exceptionally provides a fallback value only on failure; handle runs on both success and failure to unify the response shape.
orTimeout (Java 9+) completes a future exceptionally after a deadline; completeOnTimeout supplies a default value instead.
Manually completing a CompletableFuture with complete() or completeExceptionally() wraps old callback-style APIs into the reactive chain.
Always pass an explicit Executor to supplyAsync in production to avoid starving the shared ForkJoinPool.commonPool().
Place join() at the outermost boundary and keep intermediate methods returning CompletableFuture so the chain stays non-blocking.
Virtual threads (Java 21+) reduce the cost of blocking I/O inside CompletableFuture stages but don't replace the orchestration model itself.
Conclusions

CompletableFuture's real value isn't just parallelism — it's that error handling, timeouts, and fallbacks become part of the composition rather than scattered try-catch blocks around get().

The API surface is large enough that teams often misuse thenApply where thenCompose is needed, creating nested futures that are hard to debug; the distinction is one of the most common production bugs.

Combining CompletableFuture with virtual threads splits concerns cleanly: the future graph models what depends on what, while virtual threads make blocking calls cheap enough that you don't need to rewrite everything as non-blocking.

Spring MVC's native support for returning CompletableFuture means you can free up the request thread without adopting a full reactive stack, which is a pragmatic middle ground for many teams.

Concepts & terms
ForkJoinPool.commonPool()
The default thread pool used by CompletableFuture when no Executor is specified. It's shared across the JVM, so a single blocked task can starve unrelated async operations. Production code should supply a dedicated Executor.
CompletionException
An unchecked exception thrown by CompletableFuture.join() when the underlying task fails. It wraps the original exception, accessible via getCause(), and avoids the checked ExecutionException that Future.get() forces callers to handle.
CallerRunsPolicy
A thread-pool rejection policy that runs the submitted task in the caller's thread when the queue is full. It provides back-pressure by slowing down the producer instead of silently dropping work.
From the discussion
Featured comments
徐嘉迪

I feel like it's kind of like Promise in JS

唐青枫

The ideas are pretty much the same; once you get one, you get them all

See top comments, translated →
Source: juejin.cn ↗ Google Translate ↗ Backup ↗