Java's CompletableFuture provides a powerful and flexible framework for asynchronous programming. Introduced in Java 8, it allows writing non-blocking, event-driven applications with simple and readable code. With Java 21 and Project Loom, virtual threads can be combined with CompletableFutures to achieve highly scalable concurrency with minimal overhead. This article explores the core usage patterns of CompletableFuture and how to leverage virtual threads effectively.
Basics of CompletableFuture Usage
At its simplest, a CompletableFuture represents a future result of an asynchronous computation. You can create one that runs asynchronously using the supplyAsync()
or runAsync()
methods.
- supplyAsync() runs a background task that returns a result.
- runAsync() runs a background task with no returned result.
Example:
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> "Hello World");
System.out.println(future.get()); // Blocks until the result is ready
In this example, the supplier runs asynchronously, and the main thread waits for its result using get()
. The computation executes in the common ForkJoinPool by default.
Chaining and Composing Tasks
CompletableFuture excels at composing asynchronous tasks without nested callbacks:
- thenApply() transforms the result of a completed future.
- thenAccept() consumes the result without returning anything.
- thenRun() runs a task once the future is complete.
- thenCombine() combines results of two independent futures.
- thenCompose() chains dependent futures for sequential asynchronous steps.
Example of chaining:
CompletableFuture.supplyAsync(() -> 10)
.thenApply(result -> result + 20)
.thenAccept(result -> System.out.println("Result: " + result));
Example of combining:
CompletableFuture<Integer> f1 = CompletableFuture.supplyAsync(() -> 10);
CompletableFuture<Integer> f2 = CompletableFuture.supplyAsync(() -> 20);
CompletableFuture<Integer> combined = f1.thenCombine(f2, Integer::sum);
System.out.println(combined.get()); // 30
These patterns allow building complex, non-blocking workflows with clean and expressive code.
Exception Handling
CompletableFuture allows robust error handling without complicating the flow:
- Use exceptionally() to recover with a default value on error.
- Use handle() to process outcome result or exception.
- Use whenComplete() to perform an action regardless of success or failure.
Example:
CompletableFuture.supplyAsync(() -> {
if (true) throw new RuntimeException("Failure");
return "Success";
}).exceptionally(ex -> "Recovered from " + ex.getMessage())
.thenAccept(System.out::println); // Outputs: Recovered from java.lang.RuntimeException: Failure
Waiting for Multiple Futures
The allOf()
method is used to wait for multiple CompletableFutures to finish:
List<CompletableFuture<String>> futures = List.of(
CompletableFuture.completedFuture("A"),
CompletableFuture.completedFuture("B"),
CompletableFuture.completedFuture("C"));
CompletableFuture<Void> allDone = CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]));
allDone.join(); // Wait until all futures complete
This enables executing parallel asynchronous operations efficiently.
Using CompletableFuture with Virtual Threads
Java 21 introduces virtual threads, lightweight threads that allow massive concurrency with minimal resource consumption. To use CompletableFutures on virtual threads, create an executor backed by virtual threads and pass it to async methods.
Example:
try (var executor = Executors.newVirtualThreadPerTaskExecutor()) {
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
System.out.println("Running in virtual thread: " + Thread.currentThread());
try { Thread.sleep(1000); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
System.out.println("Task completed");
}, executor);
future.join();
}
- The executor is created with
Executors.newVirtualThreadPerTaskExecutor()
. - Async tasks run on virtual threads, offering high scalability.
- The executor must be closed to release resources and stop accepting tasks; using try-with-resources is recommended.
All operations such as thenApplyAsync()
or thenCombineAsync()
can similarly take the virtual thread executor to keep subsequent stages on virtual threads.
Summary
- CompletableFuture allows flexible, readable asynchronous programming.
- Tasks can be created, chained, combined, and composed easily.
- Robust exception handling is built-in.
allOf()
allows waiting on multiple futures.- With virtual threads, CompletableFuture scales brilliantly by offloading async tasks to lightweight threads.
- Always close virtual thread executors to properly release resources.
Using CompletableFuture with virtual threads simplifies asynchronous programming and enables writing performant scalable Java applications with clean and maintainable code.
Recent Comments