Mastering CompletableFuture in Java: Basics, Advanced Features, and Best Practices
This comprehensive guide introduces Java's CompletableFuture, explains its advantages over the traditional Future API, demonstrates how to create and compose asynchronous tasks, manage thread pools, handle timeouts and cancellations, and apply robust exception handling techniques for reliable concurrent programming.
Preface
When I decided to write this article about CompletableFuture , countless painful moments of asynchronous programming flashed through my mind.
I aim to present the ultimate Java asynchronous tool in a simple, entertaining way, breaking the knowledge into bite‑size pieces so readers don’t get overwhelmed by a long text.
The article is split into two parts: the first focuses on fundamentals and intermediate concepts, while the second will cover advanced features such as multi‑task orchestration, real‑world scenarios, and performance tuning.
Body
Review of Basics
Remember the confusion when first encountering Java multithreading—Thread, Runnable, Callable—running wild in your mind?
Later we met Future , thinking it was the savior of async programming, only to discover its limitations.
Limitations of the Future Interface
Future is like a "future that can only be queried, not changed". You submit a task and can only poll isDone() or block with get() :
Future<String> future = executor.submit(() -> {
Thread.sleep(1000);
return "I am the result";
});
// Forced blocking
String result = future.get(); // blocksThis approach is as clumsy as ordering food without being able to notify the delivery person, inform friends, or set cancellation rules.
What is CompletableFuture?
CompletableFuture appears as an upgraded version of Future , adding asynchronous callbacks, task composition, and more, thereby eliminating the shortcomings of the original API.
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
return "I am the result";
}).thenApply(result -> {
return "Processed result: " + result;
}).thenAccept(finalResult -> {
System.out.println("Final result: " + finalResult);
});Think of it as a modern delivery system that can automatically notify you, handle various states, and combine multiple orders.
Why Use CompletableFuture?
Consider a product‑detail page that must fetch basic info, stock, promotion, and reviews simultaneously.
With plain Future you would submit four tasks and then block on each get() call, leading to painful waiting.
Future<ProductInfo> productFuture = executor.submit(() -> getProductInfo());
Future<Stock> stockFuture = executor.submit(() -> getStock());
Future<Promotion> promotionFuture = executor.submit(() -> getPromotion());
Future<Comments> commentsFuture = executor.submit(() -> getComments());
// Then a series of blocking get() calls …With CompletableFuture you can launch the same tasks and combine them elegantly:
CompletableFuture<ProductInfo> productFuture = CompletableFuture.supplyAsync(() -> getProductInfo());
CompletableFuture<Stock> stockFuture = CompletableFuture.supplyAsync(() -> getStock());
CompletableFuture<Promotion> promotionFuture = CompletableFuture.supplyAsync(() -> getPromotion());
CompletableFuture<Comments> commentsFuture = CompletableFuture.supplyAsync(() -> getComments());
CompletableFuture.allOf(productFuture, stockFuture, promotionFuture, commentsFuture)
.thenAccept(v -> {
buildPage(productFuture.join(), stockFuture.join(),
promotionFuture.join(), commentsFuture.join());
});Thus, CompletableFuture acts like a small manager that monitors completion, notifies automatically, allows custom post‑processing, and simplifies task orchestration.
Creating Asynchronous Tasks in Different Ways
Choosing between supplyAsync and runAsync
supplyAsync returns a result, similar to waiting for a delivered meal; runAsync performs a fire‑and‑forget action, like posting a status update.
// supplyAsync: returns a value
CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> {
return "I have a result";
});
// runAsync: no return value
CompletableFuture<Void> future2 = CompletableFuture.runAsync(() -> {
System.out.println("I just run, no result");
});Proper Custom ThreadPool Usage
Using a shared pool is like a public bike‑share—convenient but may be crowded. A custom pool is a private car you can tune to your needs.
// Wrong: a fixed pool that can become a runaway horse
ExecutorService wrongPool = Executors.newFixedThreadPool(10);
// Correct: a well‑tuned ThreadPoolExecutor
ThreadPoolExecutor rightPool = new ThreadPoolExecutor(
5, // core threads
10, // max threads
60L, // keep‑alive time
TimeUnit.SECONDS,
new LinkedBlockingQueue<>(100),
new ThreadFactoryBuilder().setNameFormat("async-pool-%d").build(),
new ThreadPoolExecutor.CallerRunsPolicy()
);
// Use the custom pool
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
return "Task executed with custom pool";
}, rightPool);Cancellation and Timeout Handling
Just as you might cancel an overdue food order, CompletableFuture lets you cancel or set timeouts.
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
try {
Thread.sleep(5000);
} catch (InterruptedException e) {
return "Interrupted!";
}
return "Completed normally";
});
// Blocking get with timeout
try {
String result = future.get(3, TimeUnit.SECONDS);
} catch (TimeoutException e) {
future.cancel(true);
System.out.println("Too long, cancelled!");
}
// Non‑blocking timeout handling
future.completeOnTimeout("Default value", 3, TimeUnit.SECONDS)
.thenAccept(r -> System.out.println("Final result: " + r));
future.orTimeout(3, TimeUnit.SECONDS)
.exceptionally(ex -> "Timeout fallback")
.thenAccept(r -> System.out.println("Final result: " + r));Chaining Calls – The Art of Composition
Chain methods to build a processing pipeline.
CompletableFuture.supplyAsync(() -> "Hello")
.thenApply(s -> s + " World")
.thenAccept(result -> System.out.println("Received: " + result))
.thenRun(() -> System.out.println("Pipeline finished, cleaning up"));Key methods:
thenApply : transform the result and pass it on.
thenAccept : consume the result without returning.
thenRun : execute a side‑effect after previous stages, ignoring the result.
thenApplyAsync for Heavy Transformations
CompletableFuture.supplyAsync(() -> {
return "User basic info";
}).thenApplyAsync(info -> {
Thread.sleep(1000); // simulate heavy work
return info + " + extra info";
}, customExecutor);thenCompose vs thenCombine
thenCompose performs serial composition (one task depends on the previous result), while thenCombine merges two independent tasks.
// Serial composition
CompletableFuture<String> getUserEmail(String userId) {
return CompletableFuture.supplyAsync(() -> "[email protected]");
}
CompletableFuture<String> future = CompletableFuture
.supplyAsync(() -> "userId")
.thenCompose(id -> getUserEmail(id));
// Parallel combination
CompletableFuture<String> priceFuture = CompletableFuture.supplyAsync(() -> "Price");
CompletableFuture<String> stockFuture = CompletableFuture.supplyAsync(() -> "Stock");
CompletableFuture<String> result = priceFuture.thenCombine(stockFuture, (price, stock) ->
String.format("Price: %s, Stock: %s", price, stock));Exception Handling Techniques
exceptionally – Emergency Backup
CompletableFuture<String> future = CompletableFuture
.supplyAsync(() -> {
if (Math.random() < 0.5) {
throw new RuntimeException("Service unavailable");
}
return "Normal data";
})
.exceptionally(ex -> {
log.error("Operation failed", ex);
return "Fallback data due to exception";
});handle – Dual‑Path Processing
CompletableFuture<String> future = CompletableFuture
.supplyAsync(() -> {
if (Math.random() < 0.5) {
throw new RuntimeException("Simulated error");
}
return "Raw data";
})
.handle((result, ex) -> {
if (ex != null) {
log.error("Handling exception", ex);
return "Backup data after error";
}
return result + " – processed";
});whenComplete – Observation Only
// whenComplete: can only observe, cannot modify
CompletableFuture<String> f1 = CompletableFuture
.supplyAsync(() -> "Raw data")
.whenComplete((res, ex) -> {
if (ex != null) {
log.error("Exception", ex);
} else {
log.info("Completed: {}", res);
}
});
// handle: can modify the outcome
CompletableFuture<String> f2 = CompletableFuture
.supplyAsync(() -> "Raw data")
.handle((res, ex) -> {
return (ex != null) ? "Alternative after error" : res + " – processed";
});Use exceptionally for simple fallback values, handle when you need to transform both success and failure, and whenComplete for logging or cleanup without altering the result.
Conclusion
The first part covers the basics and intermediate usage of CompletableFuture . The upcoming second part will dive into advanced orchestration, real‑world tricks, performance optimization, and virtual threads.
Stay tuned!
Rare Earth Juejin Tech Community
Juejin, a tech community that helps developers grow.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.