Java developers today have more power — and more choices — than ever before when it comes to building high-performance, concurrent applications. Two of the most widely discussed concurrency mechanisms are:
CompletableFuture
(and traditional Futures)- Java 21's new
Virtual Threads
(part of Project Loom)
While both allow you to run tasks asynchronously or concurrently, they do so in fundamentally different ways. This blog aims to demystify both options, show how they work under the hood, and help you decide when to use which in real-world scenarios.
🧠 A Quick Primer
What is CompletableFuture
?
Introduced in Java 8, CompletableFuture
allows you to run asynchronous tasks without blocking the main thread. You can chain actions, handle exceptions, and combine multiple computations.
CompletableFuture.supplyAsync(() -> expensiveCall())
.thenApply(result -> transform(result))
.thenAccept(finalResult -> store(finalResult));
What are Virtual Threads?
Virtual Threads, introduced in Java 21, are lightweight, user-mode threads managed by the JVM. You can spin up millions of virtual threads cheaply, without tuning thread pools.
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
executor.submit(() -> blockingCall());
Virtual threads allow you to write code that looks blocking (imperative), while still achieving massive concurrency.
⚙️ How They Work Internally
Feature | CompletableFuture |
Virtual Threads |
---|---|---|
Scheduling | ForkJoinPool or custom executor | JVM-managed carrier threads |
Style | Callback-based | Imperative/blocking style |
Memory Usage | Moderate (depends on pool size) | Very low per thread (few KB) |
Stack Handling | Uses call stacks like normal threads | Uses stack copying/yielding under the hood |
Debuggability | Poor (stack traces break with async chains) | Great (preserves clean stack traces) |
✅ When to Use CompletableFuture
✔ Best Fit Scenarios:
- You are already using a reactive-style architecture
- You need to chain multiple async computations
- You want fine-grained composition of multiple parallel flows (e.g.,
allOf
,anyOf
) - You want timeouts, cancellation, and completion hooks
🔥 Example:
CompletableFuture<String> userData = CompletableFuture.supplyAsync(() -> fetchUser());
CompletableFuture<String> orders = userData.thenCompose(user -> fetchOrders(user));
❌ Downsides:
- Debugging is hard (stack traces get split across lambdas)
- Exception handling is verbose
- Doesn’t scale well if you try to avoid blocking with it
✅ When to Use Virtual Threads
✔ Best Fit Scenarios:
- You are building I/O-heavy applications (HTTP, DB, file I/O)
- You want thread-per-request design without scaling issues
- You prefer writing clean, blocking-style logic
- You are migrating from synchronous to scalable async
🔥 Example:
executor.submit(() -> {
var user = fetchUser();
var orders = fetchOrders(user);
save(orders);
});
❌ Downsides:
- Still maturing (some third-party libraries may not be virtual-thread-friendly)
- CPU-bound workloads won’t benefit much
- Needs JDK 21+, some tooling still catching up
🧪 Use Case Comparison
Web Server with 100,000 Connections
Criteria | CompletableFuture |
Virtual Threads |
---|---|---|
Style | Complex async chains | One thread per request (clean) |
Scalability | High (but harder to write) | Very high and easy |
Stack trace | Fragmented | Clean |
Exceptions | Manual .exceptionally() chains |
Try-catch just works |
Parallelizing Multiple Tasks
Scenario | CompletableFuture | Virtual Threads |
---|---|---|
1000 async DB calls | Works well, needs chaining | Works better, no chaining |
CPU-intensive parallel tasks | Same for both, no major gain |
🧩 Complex Task Execution: 10 Tasks, Mixed Strategy
Let’s look at a concrete example involving 10 tasks, some sequential and some parallel:
🧭 Scenario
- Group 1: T1 → T2 → T3 (sequential)
- Group 2: T4, T5, T6, T7 (parallel)
- Group 3: T8 → T9 → T10 (sequential)
✅ Virtual Threads Version
ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();
executor.submit(() -> {
runTask("T1");
runTask("T2");
runTask("T3");
}).get();
Future> f4 = executor.submit(() -> runTask("T4"));
Future> f5 = executor.submit(() -> runTask("T5"));
Future> f6 = executor.submit(() -> runTask("T6"));
Future> f7 = executor.submit(() -> runTask("T7"));
f4.get(); f5.get(); f6.get(); f7.get();
executor.submit(() -> {
runTask("T8");
runTask("T9");
runTask("T10");
}).get();
executor.shutdown();
✅ CompletableFuture
Version
CompletableFuture<Void> group1 = CompletableFuture.runAsync(() -> runTask("T1"))
.thenRun(() -> runTask("T2"))
.thenRun(() -> runTask("T3"));
group1.join();
CompletableFuture<Void> f4 = CompletableFuture.runAsync(() -> runTask("T4"));
CompletableFuture<Void> f5 = CompletableFuture.runAsync(() -> runTask("T5"));
CompletableFuture<Void> f6 = CompletableFuture.runAsync(() -> runTask("T6"));
CompletableFuture<Void> f7 = CompletableFuture.runAsync(() -> runTask("T7"));
CompletableFuture.allOf(f4, f5, f6, f7).join();
CompletableFuture<Void> group3 = CompletableFuture.runAsync(() -> runTask("T8"))
.thenRun(() -> runTask("T9"))
.thenRun(() -> runTask("T10"));
group3.join();
Note: Virtual threads give a natural, readable structure with try-catch
and blocking behavior. CompletableFuture
offers async composition but comes with verbosity and cognitive load.
🏆 TL;DR: When to Use What
Use Case | Pick This |
---|---|
Highly composable flows | CompletableFuture |
I/O heavy services (DB, HTTP) | Virtual Threads |
Microservice backends | Virtual Threads |
Real-time dashboards, event-based UI |
CompletableFuture or reactive |
Quick migrations from thread-per-request | Virtual Threads |
🔥 Bonus: Combine Both (Yes, Really)
You can still use CompletableFuture
inside virtual threads for cases like:
- Combining multiple tasks with
.allOf()
- Handling optional timeouts
But your main flow can stay clean, blocking, and idiomatic.
executor.submit(() -> {
CompletableFuture<Void> future = CompletableFuture.allOf(
CompletableFuture.runAsync(() -> loadA()),
CompletableFuture.runAsync(() -> loadB())
);
future.get();
});
💬 Final Thoughts
Virtual Threads won’t kill
CompletableFuture
, but they do kill the reason you had to use it everywhere.
Write clean code. Let the JVM do the async gymnastics. Virtual threads are here to make concurrency boring again — and that’s a good thing.
Coming Next: A practical Spring Boot migration guide using Virtual Threads for REST APIs and DB calls. Stay tuned! 🚀