Skip to main content

Welcome to a Grasp lesson

Create a free account to keep studying and create your own study path.

Lesson 31

Asynchronous Programming with CompletableFuture

Recommended resources

Hello! Welcome to the final lesson in our Advanced Java Concurrency module.

In our previous lessons, we've explored powerful concurrency tools, but they largely followed a blocking model. Threads would wait for locks, or for items in a queue. This model is simple to reason about but can be inefficient, especially in I/O-bound applications like microservices that spend much of their time waiting for network responses.

Today, we shift to a more modern, efficient, and scalable paradigm: non-blocking, asynchronous programming. Your learning outcome is to implement asynchronous, non-blocking operations using CompletableFuture. This is a cornerstone of modern Java development and a frequent topic in interviews for senior roles, as it demonstrates your ability to design responsive and resource-efficient systems.

1. From Blocking Future to Non-Blocking CompletableFuture

Before Java 8, asynchronous programming was primarily handled by the Future interface, introduced in Java 5. You could submit a task to an ExecutorService and get a Future object back, which represented a promise for a result that would be available later.

The problem? The only way to get that result was to call future.get(), a blocking operation. Your main thread would freeze until the result was ready. This made composing multiple asynchronous steps awkward and inefficient.

To understand the conceptual leap from the old way to the new, it's helpful to see the distinction between synchronous, multi-threaded, and asynchronous execution.

Asynchronous programming in Java 8: how to use CompletableFuture by José Paumard

This video from a Devoxx conference talk provides an excellent conceptual foundation. It clearly explains the limitations of the old Future API and sets the stage for why CompletableFuture was a necessary evolution.

Watch from 00:50 to 07:41. Focus on the comparison between synchronous, multi-threaded, and asynchronous execution models, and pay close attention to the critique of Future.get() being a blocking call.

Java 8's CompletableFuture solves these problems by implementing the CompletionStage interface. This interface provides a rich, fluent API for defining pipelines of computation that execute in a non-blocking fashion.

Think of it as building a dependency graph. Instead of saying "run this, wait, then run that," you say, "when this task completes, use its result to run that task, and when that one completes, use its result to run another." The system handles the execution flow automatically as data becomes available.

Comparison of Future, CompletionStage, and CompletableFuture
This diagram shows that `CompletableFuture` implements both the old `Future` interface (with its blocking `get()`/`join()`) and the new `CompletionStage` interface, which provides the powerful, non-blocking chaining methods like `thenApply()` and `thenAccept()`.

2. Creating Asynchronous Tasks

The simplest way to create a CompletableFuture is by using one of its static factory methods. These are ideal for offloading a long-running task from the main thread.

  • CompletableFuture.supplyAsync(Supplier<T>): Use this when your asynchronous task needs to return a value. The Supplier is a functional interface that takes no arguments and returns a result.
  • CompletableFuture.runAsync(Runnable): Use this for "fire-and-forget" tasks that perform an action but don't return a value.
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutionException;

public class CreationExample {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        // Use supplyAsync for a task that returns a value
        CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
            // Simulate a long-running task like a network call
            try {
                Thread.sleep(1000);
            } catch (InterruptedException e) {
                throw new IllegalStateException(e);
            }
            return "Result of the asynchronous computation";
        });

        System.out.println("Main thread is not blocked and continues execution.");

        // The old way: block and wait for the result
        String result = future.get(); // This line will block for ~1 second
        System.out.println(result);
    }
}

By default, these async methods run on the common ForkJoinPool, a shared thread pool. You can also provide your own Executor as a second argument to have more control over your thread resources, which is a best practice in large applications.


3. Building a Non-Blocking Pipeline

The real power of CompletableFuture comes from chaining operations. Instead of blocking with get(), you attach callback functions that will be executed automatically when the future completes.

Let's explore the core chaining methods. For a quick, practical overview of these methods, the following video is excellent.

A Guide To CompletableFuture in Java with Examples | Asynchronous Operations in Java | Geekific

This guide from Geekific walks through the most common methods for creating and chaining CompletableFutures with clear code examples.

Watch from 03:32 to 05:52. This section covers runAsync and supplyAsync for creation, and then demonstrates the core chaining methods: thenApply, thenAccept, and thenRun.

Here's a breakdown of those key methods:

  • thenApply(Function<T, U>): Transforms the result. It takes the result of the previous stage (T), applies a function to it, and returns a new CompletableFuture with the new result (U). This is analogous to Stream.map().

  • thenAccept(Consumer<T>): Consumes the result. It takes the result of the previous stage, performs an action with it (like printing to the console or saving to a database), but doesn't return anything. It returns CompletableFuture<Void>. This is analogous to Stream.forEach().

  • thenRun(Runnable): Runs an action after the previous stage completes, but doesn't use its result. It's useful for triggering a final action regardless of the output. It also returns CompletableFuture<Void>.

CompletableFuture.supplyAsync(() -> "John Doe") // Stage 1: Fetches a user's name
    .thenApply(name -> "Hello, " + name)      // Stage 2: Transforms the name into a greeting
    .thenAccept(greeting -> System.out.println(greeting)) // Stage 3: Consumes the greeting
    .thenRun(() -> System.out.println("Greeting pipeline finished.")); // Stage 4: Final action

// The main thread is free to do other work while this pipeline executes.
// To see the output in a simple main method, we need to prevent the app from exiting.
// In a real server app, the main thread would be busy handling other requests.
Thread.sleep(2000);

4. thenApply vs. thenCompose: The "flatMap" of Futures

This is a critical distinction and a common interview question. It's parallel to the map vs. flatMap difference in the Stream API.

  • thenApply: Use this when your transformation function returns a plain value.
  • thenCompose: Use this when your transformation function itself returns another CompletableFuture. thenCompose will "flatten" the result, so you don't end up with a CompletableFuture<CompletableFuture<T>>.

Imagine you have two functions:

  1. getUserById(id) which returns CompletableFuture<User>
  2. getPermissions(user) which returns CompletableFuture<Permissions>

If you use thenApply to chain them, you'll get a nested future:
CompletableFuture<CompletableFuture<Permissions>> nestedFuture = getUserById(1).thenApply(user -> getPermissions(user));

This is awkward to work with. thenCompose solves this by composing the two asynchronous steps into a single, flat pipeline.

Guide To CompletableFuture

The Baeldung guide has a dedicated section that clearly explains this crucial difference with concise code examples. Understanding this is key to correctly building complex asynchronous workflows.

Read Section 7, 'Difference Between thenApply() and thenCompose()'. Focus on how thenCompose is used to chain functions that themselves return a CompletableFuture.

Here’s the correct way to chain our example methods:
CompletableFuture<Permissions> permissionsFuture = getUserById(1).thenCompose(user -> getPermissions(user));

Test your understanding!

You have two methods:
CompletableFuture<String> findOrder(int orderId)
CompletableFuture<String> getCustomerDetails(String orderJson)

You need to write a function findCustomerForOrder(int orderId) that returns a CompletableFuture<String> containing the customer details. Which chaining method should you use and why? Write the one-line implementation.

Show answer

You should use thenCompose. The first step, findOrder, returns a CompletableFuture. The function you need to apply to its result, getCustomerDetails, also returns a CompletableFuture. Using thenCompose correctly flattens the result.

public CompletableFuture<String> findCustomerForOrder(int orderId) {
    return findOrder(orderId).thenCompose(orderJson -> getCustomerDetails(orderJson));
}

5. Combining Multiple Futures

You often need to run several asynchronous operations in parallel and then combine their results.

  • thenCombine(other, BiFunction): Use this when you have two independent futures and want to do something with both of their results when they are complete.

  • allOf(futures...): Use this to create a single CompletableFuture that completes only when all of the provided futures have completed. A key gotcha is that allOf returns CompletableFuture<Void>. To get the results, you must manually collect them from the original futures.

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

// Wait for all three to complete
CompletableFuture<Void> allFutures = CompletableFuture.allOf(future1, future2, future3);

// The allFutures.join() call will block until all three are done.
allFutures.join();

// Now collect the results
String result = Stream.of(future1, future2, future3)
                      .map(CompletableFuture::join) // join() is like get() but throws unchecked exceptions
                      .collect(Collectors.joining(" "));

System.out.println(result); // Prints "Hello Beautiful World"

6. Graceful Error Handling

What happens if a stage in your pipeline throws an exception? The exception propagates down the chain, and any subsequent .get() or .join() call will throw it. CompletableFuture provides methods to handle this gracefully within the pipeline itself.

A Guide to CompletableFuture

Handling errors asynchronously is crucial. This detailed guide explains the key methods for recovering from exceptions within a chain.

First, read the subsections on exceptionally and handle within Section 5 ('Transformations'). Then, skim Section 7 ('Exception Handling') for a confirmation of how exceptions propagate.

  • exceptionally(Function<Throwable, T>): This is your catch block. It lets you catch an exception from an upstream stage and provide a default/fallback value, allowing the pipeline to continue successfully.

  • handle(BiFunction<T, Throwable, U>): This is your finally block. This stage is always executed, whether the previous stage completed successfully or with an error. Your BiFunction receives two arguments: the result (which will be null if an error occurred) and the throwable (which will be null if it succeeded). This is perfect for logging or for mapping both success and failure to a common result type.

String name = null;

CompletableFuture<String> greetingFuture = CompletableFuture.supplyAsync(() -> {
    if (name == null) {
        throw new RuntimeException("Name is null!");
    }
    return "Hello, " + name;
}).exceptionally(ex -> {
    System.err.println("An error occurred: " + ex.getMessage());
    return "Hello, Stranger!"; // Provide a fallback value
});

System.out.println(greetingFuture.join()); // Prints "Hello, Stranger!"

Conclusion

You've now covered the fundamentals of one of the most important concurrency utilities in modern Java. Mastering CompletableFuture is essential for building scalable, responsive applications and is a strong indicator of seniority in technical interviews.

Key Takeaways:

  • Shift in Paradigm: Move from blocking (get()) to non-blocking composition by building a pipeline of dependent actions.
  • Creation: Use supplyAsync for tasks with return values and runAsync for fire-and-forget tasks.
  • Chaining: Use thenApply for simple transformations (map) and thenCompose for chaining asynchronous functions (flatMap).
  • Combining: Use thenCombine for a pair of futures and allOf to wait for a group of futures to complete.
  • Error Handling: Use exceptionally to recover from errors and handle to process any outcome, success or failure.
  • get() vs. join(): Both block for a result, but get() throws checked exceptions while join() throws unchecked exceptions, making it more convenient in functional pipelines.

Preview of the Next Lesson:
This lesson concludes our deep dive into advanced concurrency. In the next lesson, we will explore another core Java topic often asked in interviews: explaining the Java serialization mechanism, including the roles of the Serializable interface and the transient keyword.