Sitelet https://howtodoinjava.com/java/multi-threading/java-completablefuture/

Java CompletableFuture Tutorial with Examples (Java 25)

A CompletableFuture is a Future that runs the next step when its result arrives, so no thread has to wait for it. This tutorial creates, chains and combines CompletableFuture tasks on virtual threads, handles errors and timeouts, and shows the mistakes that make async code hang or lose exceptions.

CompletableFuture chain of supplyAsync, thenApply, thenCompose and exceptionally with results 5, 10, 9 for apple and 0 for kiwi

A CompletableFuture is a Future with methods that run the next step when its result arrives, so no thread has to block and wait for it. The class is in the java.util.concurrent package since Java 8.

We use CompletableFuture to run slow calls, such as REST calls and database queries, at the same time and to build the next step on their results. For example, a product page loads the price and the stock of an item in parallel.

The following example runs a few lookups of a small fruit shop on Java 25 virtual threads (source code on GitHub, with JUnit 6.1.3 tests). Each lookup takes 100 ms. The call priceOf(“apple”) returns 5, and discountAsync() is a second async call that takes 10% off.

try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {   // close() waits for all tasks
  FruitShop shop = new FruitShop(executor);

  CompletableFuture<Integer> price = CompletableFuture.supplyAsync(() -> shop.priceOf("apple"), executor); // 5
  CompletableFuture<Integer> stock = CompletableFuture.supplyAsync(() -> shop.stockOf("apple"), executor); // 12

  CompletableFuture<Integer> total = price.thenApply(p -> p * 2);                  // 10
  CompletableFuture<Integer> discounted = total.thenCompose(shop::discountAsync);  // 9
  CompletableFuture<Integer> value = price.thenCombine(stock, (p, s) -> p * s);    // 60

  CompletableFuture<Integer> unknown = CompletableFuture.supplyAsync(() -> shop.priceOf("kiwi"), executor); // fails
  CompletableFuture<Integer> safe = unknown.exceptionally(ex -> 0);                // 0

  CompletableFuture<Integer> slow = shop.priceAsync("mango", Duration.ofSeconds(2));
  CompletableFuture<Integer> limited = slow.completeOnTimeout(-1, 500, TimeUnit.MILLISECONDS);  // -1

  int result = discounted.join();                                                  // 9
}

Notice that only the join() line waits for a result. Every other call returns a CompletableFuture at once and registers the step that runs later.

Next, we go through each group of methods and the mistakes that make CompletableFuture code hang or lose errors.

1. CompletableFuture vs Future

The method ExecutorService.submit() returns a plain Future. The only way to read its result is get(), which blocks the calling thread until the task ends. CompletableFuture adds the missing parts and still implements Future, so it works anywhere a Future is expected.

What we needFutureCompletableFuture
Run the next step on the resultNot possiblethenApply(), thenCompose()
Wait for several tasksLoop over get() callsthenCombine(), allOf(), anyOf()
Handle an exceptionCatch ExecutionException from get()exceptionally(), handle(), whenComplete()
Timeoutget(timeout, unit) blocksorTimeout(), completeOnTimeout()

2. Creating a CompletableFuture With supplyAsync() and runAsync()

Two static methods start a task in another thread and return a CompletableFuture at once.

  • The supplyAsync(Supplier) method runs a task that returns a value and gives a CompletableFuture<T>.
  • The runAsync(Runnable) method runs a task without a result and gives a CompletableFuture<Void>.
CompletableFuture<Integer> price = CompletableFuture.supplyAsync(() -> shop.priceOf("apple"));    // 5
CompletableFuture<Void> opened = CompletableFuture.runAsync(() -> System.out.println("Shop opened")); // null, prints Shop opened

Without an executor argument, both methods run the task in ForkJoinPool.commonPool(). That is fine for short in-memory work, such as parsing or a calculation, but not for tasks that wait on a database or the network.

2.1. Passing Our Own Executor

The common pool is one small pool for the whole JVM. By default, it has one thread less than the number of CPU cores, so an 8-core server gets 7 threads. Parallel streams and every async call without an executor share these threads, so a few slow HTTP calls make all other async work in the app wait.

For tasks that call a database or a remote service, pass our own executor as the second argument. Any ExecutorService works. On Java 21 and later, virtual threads are the better choice, because a blocked virtual thread costs very little memory.

The intro example uses Executors.newVirtualThreadPerTaskExecutor(), which starts a new virtual thread for every task. Since Java 19, close() at the end of the try block waits for the submitted tasks and shuts the executor down. A Spring @Async method can return a CompletableFuture too.

3. Chaining Results With thenApply(), thenAccept() and thenCompose()

A chain is a list of steps where each step gets the result of the step before it. Each method takes a lambda and returns a new CompletableFuture, so we call the next method on the returned object.

  • The thenApply(Function) method changes the result into a new value.
  • The thenAccept(Consumer) method uses the result and returns nothing, for example to print or save it.
CompletableFuture<Integer> price = CompletableFuture.supplyAsync(() -> shop.priceOf("apple"), executor); // 5
CompletableFuture<Integer> total = price.thenApply(p -> p * 2);              // 10
CompletableFuture<String> label = total.thenApply(t -> "Total: " + t);       // "Total: 10"
CompletableFuture<Void> shown = label.thenAccept(System.out::println);       // prints Total: 10

These methods run the step in a thread that the chain already uses, so the step should be quick. For a slow step, we use the Async variant with our executor, such as thenApplyAsync(fn, executor).

3.1. thenApply() vs thenCompose()

Both methods get the previous result, and the difference is what the lambda returns. We use thenApply() when it returns a plain value, and thenCompose() when it returns another CompletableFuture, for example a second async call that needs the first result.

CompletableFuture<CompletableFuture<Integer>> nested = total.thenApply(t -> shop.discountAsync(t));  // future inside a future
CompletableFuture<Integer> discounted = total.thenCompose(t -> shop.discountAsync(t));             // 9

When the next step is itself an async call, use thenCompose(), because it removes the extra CompletableFuture layer the same way flatMap() does for a Stream.

The diagram shows the chain from the intro for two inputs. With “kiwi”, the first stage throws, so the next two stages are skipped and exceptionally() returns 0.

CompletableFuture chain of supplyAsync, thenApply, thenCompose and exceptionally with results 5, 10, 9 for apple and 0 for kiwi
A failed stage skips every thenApply() and thenCompose() after it and goes to the first error handler.

4. Combining Futures With thenCombine() and allOf()

When calls do not need each other’s results, we start all of them first and combine the results later. The total time is the time of the slowest call, not the sum.

4.1. Combining Two Results With thenCombine()

The thenCombine() method waits for two futures and passes both results to a lambda with two parameters. In the intro example, the price 5 and the stock 12 give a stock value of 60.

CompletableFuture<Integer> value = price.thenCombine(stock, (p, s) -> p * s);   // 60

4.2. Waiting for a List With CompletableFuture.allOf()

The allOf() method completes when all the given futures are done. It returns a CompletableFuture<Void> without the results, so we read each result with join() inside thenApply(). The join() call does not block there, because every future has already finished.

List<String> fruits = List.of("apple", "banana", "mango");
List<CompletableFuture<Integer>> futures = fruits.stream()
    .map(fruit -> CompletableFuture.supplyAsync(() -> shop.priceOf(fruit), executor))
    .toList();

CompletableFuture<Void> all = CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]));
CompletableFuture<List<Integer>> prices = all.thenApply(v -> futures.stream()
    .map(CompletableFuture::join)
    .toList());                                                            // [5, 3, 7]

The three lookups take 100 ms each, but together they finish in a little over 100 ms. If one of them fails, the future from allOf() also fails, but only after all the others have finished.

When we need only the fastest answer, for example from two price services, CompletableFuture.anyOf() completes with the result of the first future that finishes.

5. Handling Errors With exceptionally(), handle() and whenComplete()

When a step throws an exception, the chain skips every following thenApply() and thenCompose() step and passes the error to the next error handler. The handler gets a CompletionException that wraps our exception, so we read the original one with ex.getCause().

CompletableFuture<Integer> price = CompletableFuture.supplyAsync(() -> shop.priceOf("kiwi"), executor);

CompletableFuture<Integer> safe = price.thenApply(p -> p * 2)
    .exceptionally(ex -> 0);                                            // 0

CompletableFuture<String> message = price.handle((p, ex) ->
    ex == null ? "Price: " + p : "Error: " + ex.getCause().getMessage());   // "Error: Unknown fruit: kiwi"

The three handlers differ in when they run and whether they can replace the result.

MethodRuns whenCan replace the resultResult for “kiwi”
exceptionally()Only on failureYes0
handle()Always, gets the result or the exceptionYes“Error: Unknown fruit: kiwi”
whenComplete()Always, gets the result or the exceptionNoFails with CompletionException

We write exceptionally() most often, because a fallback value is the most common need. The whenComplete() method fits logging, because it leaves the failure in place for the caller, as we will see in section 8.2.

6. Setting a Timeout With orTimeout() and completeOnTimeout()

A remote service can hang, and without a limit, our chain waits as long as the service does. Java 9 added two methods that end the wait if the future is not done in time.

  • The orTimeout(time, unit) method fails the future with TimeoutException.
  • The completeOnTimeout(value, time, unit) method completes the future with a default value.
CompletableFuture<Integer> slow = shop.priceAsync("mango", Duration.ofSeconds(2));
CompletableFuture<Integer> fallback = slow.orTimeout(500, TimeUnit.MILLISECONDS)   // TimeoutException after 500 ms
    .exceptionally(ex -> -1);                                                     // -1

CompletableFuture<Integer> late = shop.priceAsync("mango", Duration.ofSeconds(2));
CompletableFuture<Integer> withDefault = late.completeOnTimeout(0, 500, TimeUnit.MILLISECONDS);  // 0

A timeout only completes the future, and the slow task itself keeps running. CompletableFuture does not interrupt it, so to stop the call too, we also set a timeout on the HTTP client or the database query.

7. join() vs get()

Both methods block until the future is done and return the result. They differ in the exceptions they throw.

join()get()
On failureUnchecked CompletionExceptionChecked ExecutionException
On interruptKeeps waitingThrows InterruptedException
Timeout overloadNoget(timeout, unit)

We use join() inside chains and streams, as in section 4.2, because it needs no try-catch. Where our code must wait for the final result, such as at the end of a request handler, get() with a timeout is the safe choice. The catch block for InterruptedException restores the interrupt flag.

String outcome;
try {
  int price = future.get(1, TimeUnit.SECONDS);
  outcome = "Price: " + price;
} catch (ExecutionException e) {
  outcome = "Lookup failed: " + e.getCause().getMessage();   // Lookup failed: Unknown fruit: kiwi
} catch (TimeoutException e) {
  outcome = "Lookup timed out";
} catch (InterruptedException e) {
  Thread.currentThread().interrupt();
  outcome = "Interrupted";
}

8. Common CompletableFuture Mistakes

Besides blocking calls in the common pool from section 2.1, two mistakes compile without warnings and show up only under load or when a call fails.

8.1. Blocking With get() or join() Inside the Chain

Calling join() or get() inside a step blocks a pool thread while it waits for another task. If that other task needs a thread from the same small pool, both wait forever, as with the one-thread pool below.

// single = Executors.newFixedThreadPool(1)
// Wrong: the step blocks the only pool thread
CompletableFuture<Integer> blocked = total.thenApplyAsync(t -> shop.discountAsync(t).join(), single);

// Right: thenCompose() waits without a thread
CompletableFuture<Integer> discounted = total.thenCompose(shop::discountAsync);   // 9

8.2. Exceptions That Nobody Sees

A CompletableFuture stores the exception and does not print it. If we start a runAsync() task and never read its result or add a handler, nobody ever sees the error.

// Wrong: nothing is printed when the lookup fails
CompletableFuture<Void> lost = CompletableFuture.runAsync(() -> shop.priceOf("kiwi"), executor);

// Right: whenComplete() logs the error
CompletableFuture<Void> refresh = CompletableFuture.runAsync(() -> shop.priceOf("kiwi"), executor)
    .whenComplete((v, ex) -> {
      if (ex != null) {
        System.out.println("Price refresh failed: " + ex.getCause().getMessage()); // Price refresh failed: Unknown fruit: kiwi
      }
    });

Every chain needs an end that either returns the future to a caller or handles the error.

9. Conclusion

CompletableFuture lets us write async work as a chain of steps. We start tasks with supplyAsync() on our own executor, preferably virtual threads. Inside the chain, thenApply() changes a value and thenCompose() calls the next async service, whereas allOf() waits for independent calls.

Errors skip the normal steps until the first handler, and a timeout keeps a hanging call from holding up the chain.

For subtasks that must all succeed or fail together, Java 25 has the preview Structured Concurrency API. More topics are in our Java Concurrency guide.

10. References

Happy Learning !!

Source Code on Github

About Us

HowToDoInJava provides tutorials and how-to guides on Java and related technologies.

It also shares the best practices, algorithms & solutions and frequently asked interview questions.