Java’s CompletableFuture vs. Future

On the standard library, Java has many “futures”:

  • Future<V> (interface)
  • CompletableFuture<V> (implementation)
  • RunnableFuture<V> (interface)
  • FutureTask<V> (implementation)
  • ScheduledFuture<V> (interface)
  • ForkJoinTask<V> (abstract class)

All these live within package java.util.concurrent.

In this post, we focus on CompletableFuture, and its relation to Future.
If you want to know more about CompletableFuture, namely what it is, and how to use it, there is a practical guide at A Guide to CompletableFuture.

For contextualisation, it is relevant to list all the other classes/interfaces that somewhat represent a “deferred result” and that have future in their name. They have a different purpose to CompletableFuture, but extend the same Future interface, which is at the centre of all others:

Type hierarchy up to Future, for most common "future" related classes/interfaces.
Type hierarchy up to Future, for most common “future” related classes/interfaces.

The key distinction of CompletableFuture from the other versions, is that it was designed to be composed and pipelined. As you will see, it enables pipelining of operations that are triggered upon completion of the original future. Many programming languages have similar constructs. JavaScript calls it a Promise, Scala has a Future class and a Promise class that work together to achieve the same behaviour.

The words Future, Promise, Deferred, and Future/Promise is jargon associated with classes similar to CompletableFuture. In particular, you can say that Java’s CompletableFuture is both a future and a promise in the literature.

Other “future/promise” futures

CompletableFuture belongs to the standard library, and so it is of central importance. However, many external libraries and frameworks implement their own versions – which again are normally referred to as future/promises. Their motivation is so that their versions integrate better into their ecosystem. For example, Vert.x has futures that integrate well with their reactive API:

Plain Futures block

We have seen above that Future is an interface that is extended by all future related classes and interfaces. Namely it is extended by CompletableFuture. The API of Future is:

Java Future interface. CompletableFuture implements all these
public interface Future<V> {

  boolean cancel(boolean mayInterruptIfRunning);
  
  boolean isCancelled();
  
  // Returns true on normal termination, an exception, or cancellation
  boolean isDone();

  // This is a blocking method
  V get() throws InterruptedException, ExecutionException;

  // This is a blocking method
  V get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException;
}    

A Future represents the result of an asynchronous computation. Namely, a computation that is running on a separate thread.

Naturally, at some point, you want to use that result value to do something useful. Maybe the future “wraps” reading a file into memory, and afterwards you want to process the contents.

The only mechanism the Future interface gives you is to call the get() methods. But these methods are blocking. The thread that calls these will block if that computation has not yet finished.

Blocking is the antithesis to async programming. You should avoid it because – in some scenarios – it makes your app unresponsive, wastes thread resources, or leads to thread starvation.

There is however method isDone(), which does not block and returns whether or not the result is already available. If it returns true, then a subsequent call to get() does not block.

You can leverage this to avoid blocking, to come up with a solution that keeps checking if the future is done, and if it is not, it proceeds to doing “something else”. Naturally, the “something else” must be something useful for the program:

// it throws these exceptions because `get()` does
public static void main(String[] args) throws ExecutionException, InterruptedException {
  
  // how this is obtained is not shown
  Future<String> fT; 

  while (!fT.isDone()) {
    // do something useful (but WHAT?)
  }

  // this now will not block
  String res = fT.get();
  
  // Do something with res ....
  System.out.println("The result was: " + res);
}

At first glance, this might be a good solution. But in practice it doesn’t work well for larger programs. The problem is this is not composable.

  1. What exactly would you put inside the while loop?
  2. What if the code inside the loop finishes before that future completes? How would you determine what other useful thing to continue doing?
  3. What if you have a computation that requires the value of two futures? Would you have a separate while loop on another thread checking for the completion of both? How would you manage many such loops?

This makes it hard to structure your code. You cannot divide your program into simpler computations that you can easily join to create more complex logic.

CompletableFutures compose

And this is exactly the main advantage of CompletableFuture. It was designed for you to be able to express computations on top of deferred results (i.e. results inside a future), in such a way that:

  • It is easy to “combine/chain” multiple computations.
  • It is possible to divide a complex problem into many such “chained” computations.
  • It is easy to understand what is going on.
  • It is possible to combine multiple futures together.
  • The registered computations run immediately and automatically after the previous completes, rather than you having to poll.

All these features related with chaining come from the interface CompletionStage, which also lives on package java.util.concurrent. This interface is not extended by any other of the “futures” we listed to begin with.

This interface contains many dozens of abstract methods which are implemented by CompletableFuture, and which give it all its power. All these methods are centred around the idea of pipelining operations on top of existing futures (that have not yet completed):

CompletableFuture class hierarchy from Future and CompletionStage interfaces. Which methods come from which interface, and which are blocking and non-blocking
CompletableFuture class hierarchy. Green is non-blocking, red blocking.

A non-exhaustive list of such methods are:

  • thenApply
  • exceptionally
  • thenCompose
  • thenApplyBoth
  • runAfterBoth
  • applyToEither

With these, you can express highly complex async computations made up of small easy to understand stages. You can specify error flow control as well, and you don’t have to poll for results, or manually trigger the next computation.

Example

Study the snippet below, where we a) read a file, b) parse a user id from its contents, c) fetch some permissions for that user by making a network request, d) check the permissions and conditionally download some resource, and finally e) log the access for security purposes.

Signatures of mock methods and classes

import java.nio.file.Path;

// read a file as string UTF-8 into memory
static String readFile(Path path);

// models saving saving into a relational db
static void logAccessOnDB(Data accessData);

// extracts a user id from a large string by looking for a pattern
static String extractUserId(String rawString);

// models obtaining permissions for all users. Returns a future,
// possibly via an HTTP request.
static CompletableFuture<UserPermissions> fetchUserPermissions();

// models obtaining the profile for a given user. Returns a future,
// possibly via an HTTP request.
static CompletableFuture<UserProfile> fetchUserProfile(String rawString);

// models downloading some data for that user (depending on permissions)
static Data downloadRestrictedData(UserProfile user, UserPermissions permissions);

record Data() {}

record UserProfile() {}

record UserPermissions() {}

class InvalidUserId extends RuntimeException {}

Path path; // not relevant how it is obtained

CompletableFuture<Void> cF1 =
    CompletableFuture.supplyAsync(() -> readFile(path))        // 1
        .thenApplyAsync(str -> extractUserId(str))             // 2 
        .thenComposeAsync(userId -> fetchUserProfile(userId))  // 3
        .exceptionallyAsync(                                   // 4 
            exception -> {
              if (exception instanceof InvalidUserId) return defaultProfile;
              else throw new CompletionException(exception);
            })
        .thenCombineAsync(                                      // 5 
            fetchUserPermissions(),                             // 6 
            (userProfile, allPermissions) ->
                downloadRestrictedData(userProfile, allPermissions))
        .thenAcceptAsync(res -> logAccessOnDB(res));            // 7

// Doing more useful work ... 
        
// (1) reads file into memory
// (2) parses contents of file to extract id
// (3) requests profile for id from external service
// (4) if there was an error for that id, use a default profile
// (5) acess restricted data, if the user has those permissions
// (6) a different future obtains all permissions across many users
// (7) record the access and contents in a DB for security reasons.

The snippet used 5 different “pipelining” functions from the CompletionStage interface.
The intent is obvious, the code concise, it is non-blocking, we can re-use the methods passed in as lambdas, and it is easy to add more stages.

Implementing the same behaviour with plain Futures would either be blocking and therefore non-performant, or else lead to a complete mess.

CompletableFutures are completable

Besides the ability to chain/pipeline operations, the other main difference of completable futures – and this is a characteristic of future/promise concepts in other languages – is being able to complete a future explicitly.

The diagram highlights this, where methods complete and completeExceptionally are not inherited by either Future or CompletionStage interfaces, but only exist on CompletableFuture.

CompletableFutures are often associated with a computation. That is, a function running on some thread. Once it finishes running, the result value of the function is used to complete the future.
But that is not always the case. They are also useful to abstract a value that will become available from an IO operation. For example, you are listening on a network socket to waiting for an HTTP request, and once it arrives you want to complete a future with the parsed HTTP request, so that another part of the code base handles it.

Read more on this on A Guide to CompletableFuture.

From Future to CompletableFuture

The Future interface was introduced in Java 5 (~2004), and the CompletableFuture appeared in the widely used Java 8 (~2014). It has been over a decade since completable futures appeared, and everyone should be using them as they are so much more powerful and useful to model async computations.

However, many external libraries that we rely upon are still stuck on returning a plain Future.
For example, even in the more modern Java 17, the standard library’s AsynchronousFileChannel has methods that return Future, and no method returning a CompletableFuture:

Contract of method read() on AsynchronousFileChannel
package java.nio.channels;

import java.nio.file.Path;
import java.util.concurrent.Future;
import java.nio.channels.CompletionHandler;
import java.nio.file.OpenOption;

public abstract class AsynchronousFileChannel {

    // method read() is overloaded. This one returns a plain Future
    public Future<Integer> read(ByteBuffer bytebuffer, long position); 

    // This accepts a callback (the handler).
    // You can use the callback to complete a CompletableFuture
    // Not all APIs provide a callback based method
    public abstract <A> void read(
        ByteBuffer bytebuffer,
        long position,
        CompletionHandler<Integer,? super A> handler);
        
    // ... other methods    
}

In such situations you have 1 or 2 options:

  • In the ideal scenario, the library provides an additional callback based method. You can then construct a callback that completes an empty CompletableFuture. This is a possibility on the AsynchronousFileChannel above, but not all APIs provide this.
  • Otherwise, you need to transform a Future into a CompletableFuture. This is less performant, and its important to understand the caveats involved.

Read more about these two approaches on Transform a Future into CompletableFuture.

Leave a Comment

Index