🚀AdvancedAdvanced Concurrency

CompletableFuture

Compose asynchronous pipelines with CompletableFuture for non-blocking concurrent code.

Overview

CompletableFuture is the modern way to write asynchronous, non-blocking code in Java. Introduced in Java 8, it represents a future value that can be chained, composed, and combined with other futures without blocking threads. Unlike the older Future interface, CompletableFuture lets you attach callbacks that run when the computation finishes, chain multiple async steps, handle errors inline, and combine results from several async tasks. The default thread pool used is ForkJoinPool.commonPool(), but you can supply your own ExecutorService. Think of CompletableFuture as a pipeline builder: each stage produces a value, and you describe what to do next without sleeping or blocking.

Key Points

  • •supplyAsync(() -> value) starts an async task that returns a result
  • •runAsync(() -> {}) starts an async task with no result
  • •thenApply(fn) transforms the result (like Stream.map)
  • •thenAccept(fn) consumes the result without returning a new value
  • •thenCompose(fn) chains another CompletableFuture (flatMap equivalent)
  • •thenCombine(other, fn) waits for two futures and merges results
  • •allOf(cf1, cf2, ...) waits for all futures to complete
  • •anyOf(cf1, cf2, ...) completes when the first future finishes
  • •exceptionally(fn) handles errors inline without try-catch
  • •join() blocks and retrieves the result (throws unchecked exception on failure)

Examples

Basic async pipeline with thenApply and thenAccept

import java.util.concurrent.CompletableFuture;

public class Main {
    public static void main(String[] args) {
        CompletableFuture.supplyAsync(() -> {
            // Simulates fetching data from a remote source
            return "raw data";
        })
        .thenApply(data -> data.toUpperCase())       // transform
        .thenApply(data -> "[" + data + "]")         // another transform
        .thenAccept(result -> System.out.println("Result: " + result))
        .join(); // wait for the pipeline to finish
    }
}

Each thenApply runs on a thread from the common pool (or whichever executor the previous stage used). join() blocks the calling thread until the whole chain completes.

Combining two independent futures with thenCombine

import java.util.concurrent.*;

public class Main {
    static CompletableFuture<Integer> fetchPrice() {
        return CompletableFuture.supplyAsync(() -> {
            try { Thread.sleep(100); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            return 100;
        });
    }

    static CompletableFuture<Integer> fetchDiscount() {
        return CompletableFuture.supplyAsync(() -> {
            try { Thread.sleep(80); } catch (InterruptedException e) { Thread.currentThread().interrupt(); }
            return 20;
        });
    }

    public static void main(String[] args) {
        CompletableFuture<Integer> result = fetchPrice()
            .thenCombine(fetchDiscount(), (price, discount) -> price - discount);

        System.out.println("Final price: " + result.join()); // 80
    }
}

Both futures run in parallel. thenCombine merges their results only after both complete. Total wait is ~100ms, not 180ms, because they run concurrently.

Error handling with exceptionally

import java.util.concurrent.CompletableFuture;

public class Main {
    public static void main(String[] args) {
        String result = CompletableFuture.supplyAsync(() -> {
            if (Math.random() > 0.5) throw new RuntimeException("fetch failed");
            return "fetched data";
        })
        .exceptionally(ex -> "fallback: " + ex.getMessage())
        .join();

        System.out.println(result);
    }
}

exceptionally acts like a catch block. It only runs when the upstream stage threw an exception. The returned value becomes the new result for downstream stages.

Waiting for all futures with allOf

import java.util.List;
import java.util.concurrent.*;
import java.util.stream.*;

public class Main {
    public static void main(String[] args) {
        List<String> urls = List.of("url1", "url2", "url3");

        List<CompletableFuture<String>> futures = urls.stream()
            .map(url -> CompletableFuture.supplyAsync(() -> "fetched: " + url))
            .collect(Collectors.toList());

        CompletableFuture<Void> allDone =
            CompletableFuture.allOf(futures.toArray(new CompletableFuture[0]));

        List<String> results = allDone.thenApply(v ->
            futures.stream()
                   .map(CompletableFuture::join)
                   .collect(Collectors.toList())
        ).join();

        results.forEach(System.out::println);
    }
}

allOf returns CompletableFuture<Void>. To collect results, use thenApply after allOf and call join() on each individual future (they are all done at that point so join() does not block).

Common Mistakes

  • Calling get() instead of join() in a pipeline. get() throws checked exceptions that force awkward try-catch inside lambdas. join() throws unchecked CompletionException instead.
  • Blocking inside supplyAsync. If you call Thread.sleep or another blocking call, you tie up a thread from the pool. Use thenCompose to chain the next async step instead.
  • Ignoring the return value of thenApply and friends. Each method returns a new CompletableFuture. You must chain or store it; the original future is unaffected.
  • Using allOf on a list of futures and then calling join() on them before allOf completes. Always obtain results inside the thenApply callback after allOf, or call join() after allOf.join().

Note

Supply a dedicated ExecutorService to supplyAsync and thenApplyAsync to isolate slow tasks from the common pool: CompletableFuture.supplyAsync(() -> slowCall(), myExecutor). This prevents one slow task from starving the pool shared by the whole application.

Exercises

Write Java code and click Run to execute it instantly. When your output looks correct, mark as done.

Loading editor…

Completed: 0 / 4 exercises

Knowledge Check

Question 1 of 4

Which method would you use to transform the result of a CompletableFuture from one type to another?