Asynchronous programming in Java with CompletableFuture
By: Aliaksandr Liakh
Tags: Java, Concurrency, Parallelism, Non Blocking, Asynchronous

Photo by Silas Baisch on Unsplash

Introduction

The CompletableFuture API is a high-level API for asynchronous programming in Java. This API supports pipelining (also known as chaining or combining) of multiple asynchronous computations into a single result without the mess of nested callbacks (“callback hell“). This API also is an implementation of the future/promise concurrency constructs in Java.

Since Java 5 there is a much simpler API for asynchronous programming: the Future interface and its base implementation, the FutureTask class. The Future interface represents the result of asynchronous computation and has only a few methods:

to check if a task is completed or canceled

to cancel a task

to wait for a task to complete (if necessary) and then get its result

However, the Future interface has significant limitations in building non-trivial asynchronous computations:

it is impossible to register a callback for a future competition

it is impossible to pipeline futures in a non-blocking manner

it is impossible to manually complete a future

To overcome these limitations, Java 8 added (and Java 9 and Java 12 updated) the CompletionStage interface and its base implementation, the CompletableFuture class. These classes allow building efficient and fluent multi-stage asynchronous computations.

However, the CompletableFuture API is not simple. The CompletionStage interface has 43 public methods. The CompletableFuture class implements 5 methods from the Future interface, 43 methods from the CompletionStage interface, and has 30 of its public methods. This article describes only the most useful methods of the CompletableFuture API.

Futures and promises

Future/promise are the high-level concurrency constructs that decouple a value (a future) from the way it is computed (a promise). That allows writing more fluent concurrent programs that transfer objects between threads without using any explicit synchronization mechanisms. The future/promise constructs are often used when multiple threads work on different tasks, and the results need to be combined by the main thread.

Implementations of future/promise exist in many programming languages:

JаvаScript: Promise

Java: java.util.concurrent.Future, java.util.concurrent.CompletableFuture

Scala: scala.concurrent.Future

C#: Task, TaskCompletionSource

Concepts of futures and promises are sometimes used interchangeably. In reality, they are separate objects that encapsulate the two different sets of functionality.

A future is a read-only object to encapsulate a value that may not be available yet but will be provided at some point. The future is used by a consumer to retrieve the result which was computed. A promise is a writable, single-assignment object to guarantee that some task will compute some result and make it available in the future. The promise is used by a producer to store the success value or exception in the corresponding future.

The following workflow example can help you to understand the idea of future/promise. A consumer sends a task to a producer to execute it asynchronously. The producer creates a promise that starts the given task. From the promise, the producer extracts a future and sends it to the consumer. The consumer receives the future that is not completed and waits for its completion.

The consumer can call a blocking getter of the future to wait for the value to be available. If the future has already been completed, the call to the getter will return the result immediately. Otherwise, the call to the getter will wait until the future is finished. (Also, the consumer can use a non-blocking checking method to identify whether the future has already been completed or not).

Once the task has finished, the producer sets the value of the promise, and the future becomes available. But when the task fails, the future will contain an exception instead of a success value. In this case, when the consumer calls the getter method, the exception in the future will be thrown.

One of the important features of future/promise implementations is the ability to chain tasks together. The idea is that when one future/promise is finished, another future/promise is created that takes the result of the previous one. This means that the consumer is not blocked by calling the getter on a future. Once the future is completed, the result of the previous task is automatically passed to the next task in the chain. Compared to callbacks, this allows writing more fluent asynchronous code that supports the composition of nested success and failure handlers without ”callback hell”.

In Java, the Future interface represents a future: it has the isDone method to check if the task is completed and the get method to wait for the task to complete and get its result. The CompletableFuture class represents a promise: it has the complete and completeExceptionally methods to set the result of the task with a successful value or with an exception. However, the CompletableFuture class also implements the Future interface allowing it to be used as a future.

CompletableFuture in practice

The following code example can help you to understand the use of the CompletableFuture class as a future/promise implementation in Java.

Let’s implement the following simplified multi-stage workflow. First, we need to call two long-running methods that return a product price in the EUR and the EUR/USD exchange rate. Then, we need to calculate the net product price from the results of these methods. Then, we need to call the third long-running method that takes the net product price and returns the tax amount. Finally, we need to calculate the gross product price from the net product price and the tax amount.

Implementation of this workflow is divided into the following tasks:

to get the product price in the EUR (a slow task) to get the EUR/USD exchange rate (a slow task) to calculate the net product price (a fast task, depends on tasks 1, 2) to get the tax amount (a slow task, depends on tasks 3) to calculate the gross product price (a fast task, depends on tasks 3, 4)

Note that not all tasks are similar. Some of them are fast (they should be executed synchronously), and some of them are slow (they should be executed asynchronously). Some of them are independent (they can be executed in parallel), and some of them depend on the results of previous tasks (they have to be executed sequentially).

The mentioned workflow is implemented below in three programming styles: synchronous, asynchronous based on the Future interface, and asynchronous based on the CompletableFuture class.

In synchronous programming, the main thread starts an axillary task and blocks until this task is finished. When the axillary task is completed, the main thread continues the main task. In asynchronous programming, the main thread starts an axillary task in a worker thread and continues its task. When the worker thread completes the auxiliary task, it notifies the main thread (for example, with a callback call).

The advantage of the synchronous implementation is the simplest and most reliable code. The disadvantage of this implementation is the longest execution time (because all tasks run sequentially).

logger.info("this task started"); int netAmountInUsd = getPriceInEur() * getExchangeRateEurToUsd(); // blocking

float tax = getTax(netAmountInUsd); // blocking

float grossAmountInUsd = netAmountInUsd * (1 + tax); logger.info("this task finished: {}", grossAmountInUsd);

logger.info("another task started");

The advantage of the asynchronous implementation based on the Future interface is shorter execution time (because some tasks run in parallel). The disadvantage of this implementation is the most complicated code (because the Future interface lacks methods for tasks pipelining).

logger.info("this task started"); Future<Integer> priceInEur = executorService.submit(this::getPriceInEur);

Future<Integer> exchangeRateEurToUsd = executorService.submit(this::getExchangeRateEurToUsd); while (!priceInEur.isDone() || !exchangeRateEurToUsd.isDone()) { // non-blocking

Thread.sleep(100);

logger.info("another task is running");

} int netAmountInUsd = priceInEur.get() * exchangeRateEurToUsd.get(); // actually non-blocking

Future<Float> tax = executorService.submit(() -> getTax(netAmountInUsd)); while (!tax.isDone()) { // non-blocking

Thread.sleep(100);

logger.info("another task is running");

} float grossAmountInUsd = netAmountInUsd * (1 + tax.get()); // actually non-blocking logger.info("this task finished: {}", grossAmountInUsd);

logger.info("another task is running");

The advantage of the asynchronous implementation based on the CompletableFuture class is shorter execution time (because some tasks run in parallel) and more fluent code. The disadvantage of this implementation is that the more advanced CompletableFuture API is at the same time harder to learn.

CompletableFuture<Integer> priceInEur = CompletableFuture.supplyAsync(this::getPriceInEur);

CompletableFuture<Integer> exchangeRateEurToUsd = CompletableFuture.supplyAsync(this::getExchangeRateEurToUsd); CompletableFuture<Integer> netAmountInUsd = priceInEur

.thenCombine(exchangeRateEurToUsd, (price, exchangeRate) -> price * exchangeRate); logger.info("this task started"); netAmountInUsd

.thenCompose(amount -> CompletableFuture.supplyAsync(() -> amount * (1 + getTax(amount))))

.whenComplete((grossAmountInUsd, throwable) -> {

if (throwable == null) {

logger.info("this task finished: {}", grossAmountInUsd);

} else {

logger.warn("this task failed: {}", throwable.getMessage());

}

}); // non-blocking logger.info("another task started");

The CompletionStage interface

The CompletionStage interface represents a stage in a multi-stage (possibly asynchronous) computation where stages can be forked, chained, and joined.

This interface specifies pipelining of the future/promise implementation in the CompletableFuture API:

Each stage performs a computation. A stage can or can not require arguments. A stage can either compute a value (returns a result) or performs an action (returns no result). Stages can be chained in a pipeline. A stage can be started by finishing a single previous stage (or two previous stages) in the pipeline. A stage finishes when its computation is completed. Finishing a stage can start a single next stage in the pipeline. A stage can be executed synchronously or asynchronously. The appropriate execution type should be selected depending on the parameters of the computation.

The methods of the CompletionStage interface can be divided into two groups according to their purpose:

methods to pipeline computations

methods to handle exceptions

The CompletionStage interface contains only methods for stages pipelining. This interface does not contain methods for other parts of stages workflow: creating, checking, completing, reading. This functionality is delegated to the CompletableFuture class — the main implementation of the CompletionStage interface.

Methods to pipeline computations

The CompletionStage interface has 43 public methods, most of which follow three clear naming patterns.

The first naming pattern explains how the new stage starts:

if a method name has fragment “then“, then the new stage starts after completion of a single previous stage

if a method name has fragment “either“, then the new stage starts after completion of the first of two previous stages

if a method name has fragment “both“, then the new stage starts after completion of both of two previous stages

The second naming pattern explains what computations perform the new stage:

if a method name has fragment “apply“, then the new stage transforms an argument by the given Function

if a method name has fragment “accept“, then the new stage consumes an argument by the given Consumer

if a method name has fragment “run“, then the new stage runs an action by the given Runnable

If the new stage depends on both of the two previous stages, it uses BiFunction instead of Function and BiConsumer instead of Consumer.

Summary of methods to pipeline computations: