Introduction to CompletableFuture
CompletableFuture
is a class in the java.util.concurrent
package that extends the Future
interface and implements the CompletionStage
interface. It represents a future result of an asynchronous computation and allows you to write non-blocking, callback-based, and event-driven code.
The key features that make CompletableFuture
powerful include:
- Ability to run tasks asynchronously without blocking the main thread.
- Support for chaining multiple tasks together (dependent completion).
- Built-in support for handling success and failure.
- Ability to combine multiple asynchronous tasks.
- Support for both blocking (
get()
) and non-blocking (callback) result retrieval.
Why CompletableFuture?
Before Java 8, asynchronous programming in Java was limited to the Future
interface. However, Future
has several limitations:
- It is blocking. You have to call
get()
and wait for the result. - It does not support chaining or dependent tasks.
- It does not provide a simple way to handle exceptions or compose multiple futures.
CompletableFuture
solves these problems by offering a rich, fluent API for writing asynchronous, event-driven code in a clean and readable way.
Creating a CompletableFuture
There are several ways to create a CompletableFuture
:
Using supplyAsync()
This method runs a task asynchronously and returns a CompletableFuture
that will complete with the result.
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> "Hello");
Using runAsync()
This method runs a task asynchronously that doesn’t return a result (i.e., Runnable
).
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> { System.out.println("Running a background task"); });
Creating an incomplete future and completing it later
You can create a future using the constructor and complete it manually using the complete()
method.
CompletableFuture<String> future = new CompletableFuture<>(); future.complete("Manually completed");
This is useful when you’re working with callback-based APIs and need to integrate them with CompletableFuture
.
Running Tasks Asynchronously
There are two primary static methods to start asynchronous tasks:
supplyAsync(Supplier<U>)
Runs a task asynchronously that returns a value.
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> { return 10 + 20; });
runAsync(Runnable)
Runs a task asynchronously that doesn’t return a result.
CompletableFuture<Void> future = CompletableFuture.runAsync(() -> { System.out.println("Running task"); });
Both methods can also take an Executor
as a second parameter if you want to control the thread pool.
Chaining Tasks
One of the most powerful features of CompletableFuture
is the ability to chain tasks. You can run another task after the first one completes using methods like:
thenApply()
Transforms the result and returns a new CompletableFuture
.
CompletableFuture<Integer> future = CompletableFuture.supplyAsync(() -> 5) .thenApply(result -> result * 2); // returns 10
thenAccept()
Consumes the result and returns a CompletableFuture<Void>
.
CompletableFuture<Void> future = CompletableFuture.supplyAsync(() -> "Hello") .thenAccept(msg -> System.out.println(msg));
thenRun()
Runs a task after the previous one but does not consume the result.
CompletableFuture<Void> future = CompletableFuture.supplyAsync(() -> "Hello") .thenRun(() -> System.out.println("Task completed"));
Combining Multiple CompletableFutures
CompletableFuture
provides methods to combine multiple futures:
thenCombine()
Combines two futures and uses their results together.
CompletableFuture<Integer> f1 = CompletableFuture.supplyAsync(() -> 10); CompletableFuture<Integer> f2 = CompletableFuture.supplyAsync(() -> 20); CompletableFuture<Integer> result = f1.thenCombine(f2, (a, b) -> a + b); // returns 30
thenCompose()
Flattens nested CompletableFuture
by combining them sequentially.
CompletableFuture<String> fetchUser = CompletableFuture.supplyAsync(() -> "user123"); CompletableFuture<String> userDetails = fetchUser.thenCompose(userId -> CompletableFuture.supplyAsync(() -> "Details for " + userId) );
allOf()
Waits for all given futures to complete.
CompletableFuture<Void> all = CompletableFuture.allOf(future1, future2, future3);
After allOf()
completes, you can retrieve individual results by calling get()
on each original future.
anyOf()
Completes when any one of the given futures completes.
CompletableFuture<Object> any = CompletableFuture.anyOf(future1, future2, future3);
Blocking and Non-Blocking Result Retrieval
get()
Blocks the current thread until the future completes.
String result = future.get();
join()
Similar to get()
but throws unchecked exceptions (RuntimeException). Useful in streams or when you want to avoid checked exceptions.
String result = future.join();
Exception Handling in CompletableFuture
CompletableFuture
provides powerful ways to handle exceptions that occur during asynchronous processing.
There are three main methods used for exception handling:
exceptionally(Function<Throwable, T>)
Catches exceptions and returns a fallback result.
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> { if (true) throw new RuntimeException("Something went wrong"); return "Success"; }).exceptionally(ex -> { System.out.println("Exception: " + ex.getMessage()); return "Fallback result"; });
handle(BiFunction<T, Throwable, R>)
Handles both success and failure in the same place. The Throwable
is null if the task succeeded.
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> { if (true) throw new RuntimeException("Error"); return "Success"; }).handle((result, ex) -> { if (ex != null) { return "Recovered from: " + ex.getMessage(); } return result; });
whenComplete(BiConsumer<T, Throwable>)
Executes after the computation completes, regardless of outcome, and does not modify the result.
CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> { throw new RuntimeException("Boom"); }).whenComplete((result, ex) -> { if (ex != null) { System.out.println("Error occurred: " + ex.getMessage()); } });
These methods help you build resilient applications by allowing graceful recovery from errors.
Real-World Use Case Example
Imagine you are building a travel booking system that fetches flights and hotel availability in parallel and then combines both to make an offer.
CompletableFuture<String> flightFuture = CompletableFuture.supplyAsync(() -> { sleep(2000); return "Flight Available"; }); CompletableFuture<String> hotelFuture = CompletableFuture.supplyAsync(() -> { sleep(3000); return "Hotel Available"; }); CompletableFuture<String> combined = flightFuture.thenCombine(hotelFuture, (flight, hotel) -> flight + " and " + hotel ); System.out.println(combined.join());
This allows you to run both operations in parallel and wait for both to finish before combining the results.
Best Practices
- Always prefer
thenApply
,thenAccept
, orthenRun
over manually blocking withget()
orjoin()
. - Use
handle
orexceptionally
to provide fallback logic for failures. - Use custom
ExecutorService
if you want to control threading behavior. - Use
allOf
oranyOf
when working with collections of asynchronous tasks. - Avoid excessive chaining that makes code hard to read. Break down long chains into readable blocks.