Java Concurrency Deep Dive: CompletableFuture and Virtual Threads
The Evolution of Java Concurrency
Java has moved from native threads (heavyweight) to Future (limited) to CompletableFuture (reactive) and finally to Virtual Threads (lightweight). This guide covers the current state of the art in Java async programming.
Core Concepts
1. CompletableFuture
Introduced in Java 8, it allows for functional-style async programming. It solves the “Callback Hell” of the old Future interface by allowing you to chain tasks using .thenApply(), .thenCompose(), and .handle().
2. Virtual Threads (Project Loom)
Introduced in Java 21, these are “user-mode” threads that are extremely cheap to create. You can run millions of them on the same hardware that would struggle with a few thousand platform threads.
Practice Exercise: Asynchronous Task Orchestration
We will fetch data from two separate services and combine them, handling errors gracefully.
Step 1: Using CompletableFuture
public CompletableFuture<String> fetchUserStats() {
CompletableFuture<String> userInfo = CompletableFuture.supplyAsync(() -> {
// Mock remote call
return "User: Truong Nhon";
});
CompletableFuture<Integer> userOrders = CompletableFuture.supplyAsync(() -> {
return 42;
});
return userInfo.thenCombine(userOrders, (name, count) -> name + " has " + count + " orders")
.exceptionally(ex -> "Error fetching data: " + ex.getMessage());
}
Step 2: The Project Loom Revolution (Virtual Threads)
With Virtual Threads, you can go back to simple, synchronous-looking code while retaining high scalability.
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
executor.submit(() -> {
// This is a virtual thread. It's OK to block here!
String data = heavyNetworkCall();
System.out.println(data);
});
}
Why Virtual Threads Change Everything
Traditional threads are mapped 1:1 to OS threads. Blocking an OS thread (e.g., waiting for a DB) is expensive because the thread is “held hostage.”
Virtual Threads are mounted on “Carrier Threads.” When a Virtual Thread blocks on I/O, it is simply “unmounted,” and the carrier thread is free to run other virtual threads. This makes the Thread-per-Request model scalable again, potentially killing the need for complex reactive programming in many scenarios.
Performance Tip: Executor Configuration
Never use the default ForkJoinPool.commonPool() for I/O-bound tasks in a production environment. Always provide a custom Executor to avoid starving other parts of the application:
Executor customExecutor = Executors.newFixedThreadPool(10);
CompletableFuture.runAsync(mytask, customExecutor);
Summary
CompletableFuture remains essential for complex pipelines, but Virtual Threads are the future of high-throughput Java web applications. By mastering both, you can build systems that are both expressive and incredibly performant.