CompletableFuture In Java

Reetesh Kumar
11 min readJan 31, 2024

--

Introduction

A CompletableFuture is a versatile Java tool that is essentially a more advanced version of Future. It’s a kind of Future we can manually complete, setting its value and status when we choose. Plus, it doubles as a CompletionStage, which means it can trigger a chain of actions or functions once it’s done. This article aims to demystify CompletableFuture and demonstrate its utility through practical examples.

What Is CompletableFuture?

A CompletableFuture is an enhancement over the traditional Future API in Java. It represents a future result of an asynchronous computation — a result that will eventually appear in the Future. What sets CompletableFuture apart is its ability to manually complete the computation, reactively chain multiple steps of computation, and handle exceptions elegantly.

But there’s more to a CompletableFuture. Let’s break it down:

  1. Dependent Actions: For tasks that aren’t asynchronous, the thread that finishes a CompletableFuture might run subsequent actions. Alternatively, any other thread calling a completion method might take up the baton.
  2. Async Methods: If we don’t specify an executor, CompletableFuture uses a common pool (ForkJoinPool.commonPool()) for async tasks. However, if this pool can’t handle at least two parallel tasks, it creates a new thread for each task. Plus, all asynchronous tasks are marked as CompletableFuture for easier management and identification.AsynchronousCompletionTask.
  3. Independent CompletionStage Methods: All methods under CompletionStage are independent of CompletableFuture’s other public methods. This means their behaviour isn’t affected by how other methods are defined or overridden.

Now, when it comes to CompletableFuture as a Future:

4. Cancellation: Unlike FutureTask, CompletableFuture handles cancellation as a form of exceptional completion. The cancel method is equivalent to causing an exceptional completion with a CancellationException. We can use isCompletedExceptionally() to check if a CompletableFuture ended this way.

5. Handling Exceptions: If there’s an exceptional completion, the get methods throw an ExecutionException, carrying the same cause as the original CompletionException. CompletableFuture introduces join() and getNow(T) methods to make things easier. These methods throw the CompletionException directly, making them more straightforward in most cases.

Creating A CompletableFuture

A CompletableFuture can be instantiated without arguments, providing a simple way to create a future that can be manually completed:

CompletableFuture<String> completableFuture = new CompletableFuture<>();

This basic form of CompletableFuture allows for explicit completion. Before completion, any attempt to retrieve its result will lead to blocking.

Retrieving the Result: To access the result of the CompletableFuture, you can use the get() method:

String result = completableFuture.get(); // This call blocks until the future completes

The get() method will block the calling thread indefinitely until the CompletableFuture is completed, which, in the initial state, it could wait forever if the future is never explicitly completed.

Completing the CompletableFuture: To prevent indefinite blocking and to supply a result to the waiting threads, you can complete the CompletableFuture manually:

completableFuture.complete("Completable Future Result");

Once the CompletableFuture is completed with a value, all clients waiting on get() will receive the specified result. Any attempts to complete the CompletableFuture again with complete() will be ignored, as a CompletableFuture can only be completed once.

Asynchronous Computation with runAsync()

The runAsync() CompletableFuture method executes asynchronous computations that don’t return a value. It’s a static method that takes a Runnable object as an argument and executes it in a separate thread, typically managed by the ForkJoinPool. This method is beneficial for running non-blocking operations in the background, such as writing to a log file, sending notifications, or any other task without waiting for the operation to complete before proceeding with the program’s execution.

Key Characteristics

  1. Asynchronous Execution: runAsync() runs the given task in a separate thread, allowing the main program to continue running independently.
  2. No Return Value: Since runAsync() accepts a Runnable, it returns no result. If we need to perform operations that return a result, consider using supplyAsync() instead.
  3. Default Executor: By default, runAsync() uses the common ForkJoinPool, but we can also specify a custom Executor if needed.

Example: Asynchronous File Operation with runAsync() → Imagine a Java application that needs to write a list of records to a file. Writing to a file can be time-consuming, especially if the data set is large. We want to perform this operation without stalling the main application flow.

We’ll simulate writing a list of strings to a file in an asynchronous manner using CompletableFuture.runAsync():

import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.StandardOpenOption;
import java.io.IOException;

public class AsyncFileWritingExample {

public static void main(String[] args) {
List<String> dataToWrite = List.of("Line 1", "Line 2", "Line 3");

CompletableFuture<Void> fileWritingFuture = CompletableFuture.runAsync(() -> {
try {
writeFile("example.txt", dataToWrite);
System.out.println("File writing completed asynchronously: " + Thread.currentThread().getName());
} catch (IOException e) {
System.err.println("Error occurred while writing to the file: " + e.getMessage());
}
});

// Continue with other operations
System.out.println("Main thread is free to run other tasks.");

// Optionally, wait for the file writing to complete
try {
fileWritingFuture.get(); // Blocks until the asynchronous file writing is complete
} catch (Exception e) {
e.printStackTrace();
}

System.out.println("All tasks completed.");
}

private static void writeFile(String fileName, List<String> data) throws IOException {
Path path = Path.of(fileName);
Files.write(path, data, StandardOpenOption.CREATE, StandardOpenOption.APPEND);
}
}

Output:

Main thread is free to run other tasks.
File writing completed asynchronously: ForkJoinPool.commonPool-worker-1
All tasks completed.

In this example, the runAsync() method performs the file-writing operation in a separate thread. This prevents the file I/O operation from blocking the main thread. The try-catch block inside the lambda function passed to runAsync() handles any IOException that might occur during the file operation. After initiating the file writing task, the main thread prints a message and can continue performing other tasks. At the end of the primary method, we optionally wait for the file writing task to complete using get(). This is useful if subsequent operations depend on achieving the file writing.

Asynchronous Computation And Returning A Result With supplyAsync()

The supplyAsync() method of CompletableFuture is used for asynchronous computations that return a result. Unlike runAsync(), which is used for tasks that don’t return anything, supplyAsync() takes a Supplier<U> and returns a CompletableFuture<U> where U is the type of the value obtained by the Supplier<U>. This method is beneficial when a long-running computation returns a value and you don’t want to block the main thread.

Example: Let’s consider an example where we need to calculate the sum of an extensive list of numbers. This computation can be time-consuming, so we’ll perform it asynchronously using supplyAsync().

import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.stream.LongStream;

public class AsyncComputationExample {

public static void main(String[] args) {
// Creating a large list of numbers
List<Long> numbers = LongStream.rangeClosed(1, 1_000_000L).boxed().toList();

// Running the sum calculation asynchronously
CompletableFuture<Long> sumFuture = CompletableFuture.supplyAsync(() -> sumOfList(numbers));

// Do some other tasks if needed
System.out.println("Main thread is free to run other tasks.");

// Retrieve the result of the computation
try {
Long sum = sumFuture.get(); // This call is blocking, but the computation is already running asynchronously
System.out.println("Sum of numbers: " + sum);
} catch (Exception e) {
e.printStackTrace();
}

System.out.println("All tasks completed.");
}

private static Long sumOfList(List<Long> numbers) {
return numbers.stream().reduce(0L, Long::sum);
}
}

Output:

Main thread is free to run other tasks.
Sum of numbers: 500000500000
All tasks completed.

In this example, We use CompletableFuture.supplyAsync() to perform the sum calculation in a separate thread, which doesn’t block the main thread. The sumOfList method computes the sum and returns it. The supplyAsync() method wraps this value in a CompletableFuture. After initiating the asynchronous computation, the main thread continues to execute, demonstrating the non-blocking nature of the supplyAsync() method. We call get() on the CompletableFuture to get the computation result. While get() is a blocking call, it is used here after the computation has time to progress or complete.

Transforming and Handling CompletableFuture Without Blocking

The CompletableFuture.get() method is inherently blocking, halting the execution flow until the Future completes and its result becomes available. However, this approach contradicts the principles of asynchronous programming, where the goal is to maintain system responsiveness and avoid waiting for tasks to be completed.

Embracing Asynchronous Callbacks: To build truly asynchronous systems, it’s essential to utilize callback mechanisms that trigger automatically upon the completion of the Future. This approach eliminates the need to block calls to wait for results, allowing us to define the logic that should be executed after the Future is completed directly within the callback function.

Attaching Callbacks to CompletableFuture: CompletableFuture provides several methods to attach callbacks that respond to its completion:

  1. thenApply(Function): This method applies a function to the result of the CompletableFuture. If the Future completes successfully, thenApply processes its result and returns a new CompletableFuture containing the function’s result. It’s helpful in transforming the result.
CompletableFuture<String> originalFuture = CompletableFuture.supplyAsync(() -> "Hello");
CompletableFuture<String> transformedFuture = originalFuture.thenApply(result -> result + " World");

2. thenAccept(Consumer): Use this when we’re interested in the result of the CompletableFuture but don’t need to return any value. thenAccept takes a Consumer and applies it to the result upon completion.

originalFuture.thenAccept(result -> System.out.println("Result is: " + result));

3.thenRun(Runnable): This is for scenarios where we want to execute some code after the Future completes, but we don’t need to use its result. It takes a Runnable and runs it once the Future is complete.

originalFuture.thenRun(() -> System.out.println("Future has been completed."));

Combining Two CompletableFutures

Combining CompletableFutures is a powerful feature in Java’s CompletableFuture API. It allows us to execute multiple independent asynchronous operations and then do something with their results. This can be achieved using various methods provided by CompletableFuture, with thenCombine and thenCompose being the most commonly used.

Using thenCombine(Independent Futures)

The thenCombine method is used to combine the results of two CompletableFutures once they are both complete. It takes another CompletableFuture and a BiFunction as arguments. The BiFunction is applied once both CompletableFutures are complete, combining their results.

CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> "Hello");
CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> " World");

CompletableFuture<String> combinedFuture = future1.thenCombine(future2, (result1, result2) -> result1 + result2);

combinedFuture.thenAccept(System.out::println); // Prints "Hello World"

Using thenCompose(Dependent Futures)

The thenCompose method is used to compose two CompletableFuture instances sequentially. It is similar to flatMap in Streams API. This method is proper when the execution of the second future depends on the result of the first one.

CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> "100");
CompletableFuture<Integer> combinedFuture = future1.thenCompose(number -> CompletableFuture.supplyAsync(() -> Integer.parseInt(number) * 2));

combinedFuture.thenAccept(System.out::println); // Prints 200

Choosing the Right Method

  1. Use thenCombine when we have two independent futures and want to do something with both results.
  2. Use thenCompose when the execution of the second future depends on the result of the first one.

Combining Multiple CompletableFutures

Combining multiple CompletableFutures in Java is a common need in complex asynchronous programming scenarios. We often want to run several independent asynchronous tasks and then perform some action when all, or a specific subset of them, are complete. Java’s CompletableFuture provides several methods to achieve this, with allOf and anyOf being the most prominent.

Using CompletableFuture.allOf

CompletableFuture.allOf is used when we want to wait for all of the given CompletableFutures to complete. It takes an array of CompletableFutures and returns a new CompletableFuture<Void> that is achieved when all the given CompletableFutures are complete.

CompletableFuture<String> future1 = CompletableFuture.supplyAsync(() -> "Task 1");
CompletableFuture<String> future2 = CompletableFuture.supplyAsync(() -> "Task 2");
CompletableFuture<String> future3 = CompletableFuture.supplyAsync(() -> "Task 3");

CompletableFuture<Void> allFutures = CompletableFuture.allOf(future1, future2, future3);

allFutures.thenRun(() -> {
// This block will be executed after all the Futures are complete.
System.out.println("All tasks are completed.");
});

To get the results of all futures, you typically combine allOf with additional processing since allOf itself doesn’t return the results.

allFutures.thenRun(() -> {
try {
System.out.println(future1.get());
System.out.println(future2.get());
System.out.println(future3.get());
} catch (InterruptedException | ExecutionException e) {
e.printStackTrace();
}
});

Using CompletableFuture.anyOf

In contrast, CompletableFuture.anyOf is used when we want to do something as soon as any of the given CompletableFutures completes. It also takes an array of CompletableFutures but returns a CompletableFuture<Object> that completes with the same result as the first of these CompletableFutures to complete.

CompletableFuture<Object> anyFuture = CompletableFuture.anyOf(future1, future2, future3);

anyFuture.thenAccept(result -> {
// This block will be executed after any of the Futures complete.
System.out.println("First completed task result: " + result);
});

CompletableFuture Exception Handling

Exception handling in CompletableFuture is crucial to writing robust asynchronous Java code. Unlike traditional try-catch blocks used in synchronous code, CompletableFuture provides specific methods to handle exceptions that may occur during asynchronous computation. The primary methods for this are exceptionally() and handle().

Using exceptionally()

The exceptionally() method handles exceptions in a CompletableFuture pipeline. It takes a function called with the exception thrown from the future and returns a default value or another exception.

CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
if (Math.random() > 0.5) {
throw new RuntimeException("Something went wrong");
}
return "Success";
});

CompletableFuture<String> exceptionallyHandledFuture = future.exceptionally(ex -> {
System.out.println("Error occurred: " + ex.getMessage());
return "Default Value";
});

exceptionallyHandledFuture.thenAccept(System.out::println);

In this example, if the supplyAsync throws an exception, exceptionally() will handle it and return “Default Value”. Otherwise, it will pass the successful result to the next stage.

Using handle()

The handle() method is more versatile than exceptionally(). It handles both the computation's result and any exception that might have been thrown. handle() takes a BiFunction with two arguments: the result and the exception.

CompletableFuture<String> futureWithHandle = future.handle((result, ex) -> {
if (ex != null) {
System.out.println("Error occurred: " + ex.getMessage());
return "Fallback Result";
} else {
return result;
}
});

futureWithHandle.thenAccept(System.out::println);

In this code, handle() checks for an exception. If so, it provides a fallback result; otherwise, it passes on the successful result.

Methods To Check Whether A CompletableFuture Is Complete

In Java’s CompletableFuture, several methods exist to check whether a CompletableFuture has been completed, either successfully or exceptionally. These methods provide a way to monitor the status of asynchronous computations without blocking the current thread. Here are the primary methods:

1. isDone()

The isDone() method checks whether the CompletableFuture has been completed in any fashion: successfully, exceptionally, or due to cancellation. However, it doesn’t distinguish between these outcomes.

CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> "Hello");
// ... some operations ...
if (future.isDone()) {
// The future has completed but could be either successful, exceptional, or cancelled.
}

2. isCompletedExceptionally()

To check if a CompletableFuture is completed exceptionally (i.e., if it is completed with an error), use isCompletedExceptionally().

if (future.isCompletedExceptionally()) {
// The future has completed with an exception.
}

3. isCancelled()

Another related method is isCancelled(), which checks if the CompletableFuture was cancelled. A cancellation is a specific type of exceptional completion.

if (future.isCancelled()) {
// The future was cancelled.
}

CompletableFuture join() Method

The join() method in Java’s CompletableFuture retrieves the result of the asynchronous computation, similar to the get() method. However, there are key differences in how these two methods handle Interrupted Exceptions and execution exceptions, making join() particularly useful in certain scenarios.

Characteristics of join()

  1. No Checked Exceptions: Unlike get(), which can throw an InterruptedException and an ExecutionException, join() throws an unchecked exception. This means we don’t have to explicitly handle the exceptions with a try-catch block.
  2. Throws CompletionException: If the CompletableFuture completes exceptionally, join() wraps the underlying exception in a CompletionException. This behaviour differs from get(), which throws an ExecutionException that wraps the underlying exception.
  3. Blocking Call: Similar to get(), the join() method is also a blocking call. It will block the thread until the computation is complete and the result is available.

Example Usage of join()

Here’s a simple example to demonstrate the use of join():

CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
// Simulating a long-running task
try {
TimeUnit.SECONDS.sleep(2);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
}
return "Result of the computation";
});

// Using join to retrieve the result. This will block until the future is complete.
String result = future.join();
System.out.println(result);

In this example, join() is used to retrieve the result of the CompletableFuture. Since join() doesn’t require you to handle checked exceptions, it can make the code look cleaner, especially when chaining multiple CompletableFuture methods.

When to Use Join ()

join() is particularly useful in scenarios where:

  1. We are combining CompletableFuture instances using methods like thenCombine or thenCompose, and we want a cleaner syntax without the need to handle checked exceptions.
  2. We are working in a context where checked exceptions are undesirable or when we prefer to deal with unchecked exceptions.

Conclusion

CompletableFuture in Java provides a robust, flexible framework for writing non-blocking, asynchronous code. Its ability to chain tasks, combine multiple futures and handle exceptions elegantly makes it an indispensable tool for modern Java applications. By embracing CompletableFuture, developers can build highly responsive and efficient applications.

Happy Learning !!!

--

--

Reetesh Kumar

Software developer. Writing about Java, Spring, Cloud and new technologies. LinkedIn: www.linkedin.com/in/reeteshkumar1