CompletableFuture: A Complete Practical Guide.
Table of contents
- 1. Simple Asynchronous Task Execution
- 2. Returning a Result with Async Computation
- 3. Chaining Async Tasks with thenApply
- 4. Combining Two Futures
- 5. Handling Errors with Exceptionally
- 6. Retrying on Failure
- 7. Timeout Management
- 8. Parallel Processing with AllOf
- 9. AnyOf for First Completed Task
- 10. Pipelining Dependent Tasks
- Conclusion
CompletableFuture
, part of Java’s java.util.concurrent
package, is an incredibly powerful class for managing asynchronous tasks and handling complex workflows. It allows you to create, combine, and handle async operations without blocking threads, making it ideal for building responsive applications.
In this guide, we’ll walk through 10 practical examples that demonstrate how CompletableFuture
can be used in real-world scenarios. By the end, you’ll have a solid grasp of how to use CompletableFuture
for concurrent tasks, exception handling, and performance optimization.
1. Simple Asynchronous Task Execution
A basic example of CompletableFuture
is running a task asynchronously, such as fetching data from an external source.
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
System.out.println("Fetching data asynchronously: " + Thread.currentThread().getName());
});
future.join(); // Block and wait for completion
Explanation: This code runs a task asynchronously on a separate thread, printing a message. join()
is used to wait for completion, blocking the main thread until the async task finishes.
2. Returning a Result with Async Computation
Use supplyAsync
to run a task that returns a result, such as fetching user data.
CompletableFuture<String> userFuture = CompletableFuture.supplyAsync(() -> {
System.out.println("Fetching user data...");
return "User Data";
});
String result = userFuture.join(); // Gets "User Data" after async completion
System.out.println("Result: " + result);
Explanation: This example shows how to run a task that produces a result ("User Data"
). supplyAsync
enables returning a value from an async operation.
3. Chaining Async Tasks with thenApply
Chaining tasks is essential for workflows where the output of one task is needed for the next.
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 50)
.thenApply(amount -> amount * 2); // Doubles the amount
System.out.println("Processed result: " + future.join());
Explanation: Here, thenApply
processes the result of a previous task (doubling a value). It returns a new CompletableFuture
with the transformed result.
4. Combining Two Futures
Use thenCombine
to combine results from two separate async tasks, such as fetching user and profile data.
CompletableFuture<String> userFuture = CompletableFuture.supplyAsync(() -> "John");
CompletableFuture<String> profileFuture = CompletableFuture.supplyAsync(() -> "Developer");
CompletableFuture<String> combinedFuture = userFuture.thenCombine(profileFuture,
(user, profile) -> user + " - " + profile);
System.out.println("Combined result: " + combinedFuture.join());
Explanation: This combines results from two futures. thenCombine
waits for both to complete, then merges their outputs, producing "John - Developer"
.
5. Handling Errors with Exceptionally
Handle exceptions in async tasks to provide a fallback or recover gracefully.
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
if (true) throw new RuntimeException("Failed to fetch data");
return "Data";
}).exceptionally(ex -> "Fallback Data");
System.out.println("Result: " + future.join());
Explanation: If an exception is thrown, exceptionally
provides "Fallback Data"
as a safe result.
6. Retrying on Failure
Using recursive CompletableFutures, you can retry a task when it fails.
import java.util.concurrent.CompletableFuture;
public class RetryExample {
public static CompletableFuture<String> fetchDataWithRetry(int retries) {
return CompletableFuture.supplyAsync(() -> {
if (Math.random() < 0.7) throw new RuntimeException("Temporary issue");
return "Fetched Data";
}).exceptionally(ex -> retries > 0 ? fetchDataWithRetry(retries - 1).join() : "Failed after retries");
}
public static void main(String[] args) {
String result = fetchDataWithRetry(3).join();
System.out.println("Result: " + result);
}
}
Explanation: If a task fails, exceptionally
attempts to retry by calling itself recursively with decremented retries.
7. Timeout Management
Set a timeout to avoid endless waits, useful for external services that may hang.
import java.util.concurrent.*;
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
try { Thread.sleep(3000); } catch (InterruptedException e) {}
return "Result";
});
String result = future.orTimeout(2, TimeUnit.SECONDS)
.exceptionally(ex -> "Timeout").join();
System.out.println("Result: " + result);
Explanation: If the task exceeds the 2-second limit, the exceptionally
block handles the timeout, returning "Timeout"
.
8. Parallel Processing with AllOf
Process multiple tasks in parallel and wait for all to complete.
CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> "Task 1");
CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> "Task 2");
CompletableFuture<String> future3 = CompletableFuture.supplyAsync(() -> "Task 3");
CompletableFuture<Void> allOf = CompletableFuture.allOf(future1, future2, future3);
allOf.thenRun(() -> {
System.out.println(future1.join());
System.out.println(future2.join());
System.out.println(future3.join());
}).join();
Explanation: allOf
waits for all tasks to complete before proceeding, making it useful for aggregating results from multiple async tasks.
9. AnyOf for First Completed Task
Use anyOf
when only one task needs to complete to proceed, ideal for race conditions.
CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> {
try { Thread.sleep(100); } catch (InterruptedException e) {}
return "First";
});
CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> "Second");
CompletableFuture<Object> anyOf = CompletableFuture.anyOf(future1, future2);
System.out.println("First completed: " + anyOf.join());
Explanation: anyOf
completes as soon as any task finishes, making it efficient when only the first result matters.
10. Pipelining Dependent Tasks
Pipelining allows you to create a sequence of async tasks where each depends on the previous task’s result, useful for multi-step workflows.
CompletableFuture<Void> pipeline = CompletableFuture.supplyAsync(() -> "Stage 1")
.thenApply(stage1 -> stage1 + " -> Stage 2")
.thenApply(stage2 -> stage2 + " -> Stage 3")
.thenAccept(result -> System.out.println("Pipeline result: " + result));
pipeline.join();
Explanation: This example creates a multi-step async pipeline where each stage builds upon the last, producing "Stage 1 -> Stage 2 -> Stage 3"
at the end.
Conclusion
CompletableFuture
provides a powerful framework for managing asynchronous programming in Java, allowing developers to create complex workflows, handle errors gracefully, and optimize performance. With these 10 practical examples, you can start applying CompletableFuture
to real-world scenarios in your applications to improve responsiveness, scalability, and code readability.
More such articles: