Backend Development 20 min read

Using CompletableFuture for Asynchronous Programming in Java: Examples, APIs, and Best Practices

This article explains how Java's Future and CompletableFuture interfaces enable asynchronous task execution, demonstrates common pitfalls of Future, shows how to replace it with CompletableFuture for more elegant code, and covers creation methods, result retrieval, callback chaining, task composition, and practical usage tips.

Selected Java Interview Questions
Selected Java Interview Questions
Selected Java Interview Questions
Using CompletableFuture for Asynchronous Programming in Java: Examples, APIs, and Best Practices

Example Review of Future

In some business scenarios we need multithreaded asynchronous execution to speed up tasks. JDK5 introduced the Future interface to represent the result of an asynchronous computation.

Although Future provides asynchronous execution, obtaining the result is inconvenient because we must call Future.get() to block the calling thread or poll Future.isDone() to check completion.

Typical code using Future:

@Test
public void testFuture() throws ExecutionException, InterruptedException {
    ExecutorService executorService = Executors.newFixedThreadPool(5);
    Future
future = executorService.submit(() -> {
        Thread.sleep(2000);
        return "hello";
    });
    System.out.println(future.get());
    System.out.println("end");
}

Future also cannot handle dependent asynchronous tasks; a CountDownLatch is often used to make the main thread wait for sub‑tasks.

Example with CountDownLatch:

@Test
public void testCountDownLatch() throws InterruptedException, ExecutionException {
    ExecutorService executorService = Executors.newFixedThreadPool(5);
    CountDownLatch downLatch = new CountDownLatch(2);
    long startTime = System.currentTimeMillis();
    Future
userFuture = executorService.submit(() -> {
        // simulate 500 ms query
        Thread.sleep(500);
        downLatch.countDown();
        return "用户A";
    });
    Future
goodsFuture = executorService.submit(() -> {
        // simulate 400 ms query
        Thread.sleep(400);
        downLatch.countDown();
        return "商品A";
    });
    downLatch.await();
    Thread.sleep(600); // simulate main work
    System.out.println("获取用户信息:" + userFuture.get());
    System.out.println("获取商品信息:" + goodsFuture.get());
    System.out.println("总共用时" + (System.currentTimeMillis() - startTime) + "ms");
}

Running the code shows a total time of about 1110 ms, shorter than the sequential 1500 ms.

Since Java 8, CompletableFuture offers a more elegant solution.

Implementing the Same Example with CompletableFuture

@Test
public void testCompletableInfo() throws InterruptedException, ExecutionException {
    long startTime = System.currentTimeMillis();
    // user service
    CompletableFuture
userFuture = CompletableFuture.supplyAsync(() -> {
        try { Thread.sleep(500); } catch (InterruptedException e) { e.printStackTrace(); }
        return "用户A";
    });
    // goods service
    CompletableFuture
goodsFuture = CompletableFuture.supplyAsync(() -> {
        try { Thread.sleep(400); } catch (InterruptedException e) { e.printStackTrace(); }
        return "商品A";
    });
    System.out.println("获取用户信息:" + userFuture.get());
    System.out.println("获取商品信息:" + goodsFuture.get());
    Thread.sleep(600);
    System.out.println("总共用时" + (System.currentTimeMillis() - startTime) + "ms");
}

The result is similar (≈1112 ms) but the code is cleaner and can replace CountDownLatch functionality.

CompletableFuture Creation Methods

Four static methods are commonly used:

public static
CompletableFuture
supplyAsync(Supplier
supplier)
public static
CompletableFuture
supplyAsync(Supplier
supplier, Executor executor)
public static CompletableFuture
runAsync(Runnable runnable)
public static CompletableFuture
runAsync(Runnable runnable, Executor executor)

supplyAsync executes a task with a return value; runAsync executes a task without a return value.

Result Retrieval Methods

public T get()
public T get(long timeout, TimeUnit unit)
public T getNow(T valueIfAbsent)
public T join()

get() and get(long, TimeUnit) behave like the original Future methods; getNow returns immediately with a default if not completed; join does not throw checked exceptions.

Asynchronous Callback Methods

thenRun / thenRunAsync – execute a second task after the first finishes, without returning a value.

@Test
public void testCompletableThenRunAsync() throws InterruptedException, ExecutionException {
    long startTime = System.currentTimeMillis();
    CompletableFuture
cf1 = CompletableFuture.runAsync(() -> {
        Thread.sleep(600);
    });
    CompletableFuture
cf2 = cf1.thenRun(() -> {
        Thread.sleep(400);
    });
    System.out.println(cf2.get());
    Thread.sleep(600);
    System.out.println("总共用时" + (System.currentTimeMillis() - startTime) + "ms");
}

If a custom thread pool is supplied to the first task, thenRun reuses it; thenRunAsync uses the common ForkJoin pool.

thenAccept / thenAcceptAsync – similar to thenRun but receives the result of the preceding stage as an argument and still returns no value.

@Test
public void testCompletableThenAccept() throws ExecutionException, InterruptedException {
    long startTime = System.currentTimeMillis();
    CompletableFuture
cf1 = CompletableFuture.supplyAsync(() -> "dev");
    CompletableFuture
cf2 = cf1.thenAccept(res -> System.out.println("上一个任务的返回结果为: " + res));
    cf2.get();
}

thenApply / thenApplyAsync – receives the previous result and returns a new value.

@Test
public void testCompletableThenApply() throws ExecutionException, InterruptedException {
    CompletableFuture
cf = CompletableFuture.supplyAsync(() -> "dev")
        .thenApply(a -> Objects.equals(a, "dev") ? "dev" : "prod");
    System.out.println("当前环境为:" + cf.get());
}

Exception Handling

whenComplete is invoked whether the stage completes normally or exceptionally.

@Test
public void testCompletableWhenComplete() throws ExecutionException, InterruptedException {
    CompletableFuture
future = CompletableFuture.supplyAsync(() -> {
        if (Math.random() < 0.5) { throw new RuntimeException("出错了"); }
        System.out.println("正常结束");
        return 0.11;
    }).whenComplete((value, ex) -> {
        System.out.println(value == null ? "whenComplete aDouble is null" : "whenComplete aDouble is " + value);
        System.out.println(ex == null ? "whenComplete throwable is null" : "whenComplete throwable is " + ex.getMessage());
    });
    System.out.println("最终返回的结果 = " + future.get());
}

Combining whenComplete with exceptionally allows a fallback value:

@Test
public void testWhenCompleteExceptionally() throws ExecutionException, InterruptedException {
    CompletableFuture
future = CompletableFuture.supplyAsync(() -> {
        if (Math.random() < 0.5) { throw new RuntimeException("出错了"); }
        System.out.println("正常结束");
        return 0.11;
    }).whenComplete((v, t) -> {
        System.out.println(v == null ? "whenComplete aDouble is null" : "whenComplete aDouble is " + v);
        System.out.println(t == null ? "whenComplete throwable is null" : "whenComplete throwable is " + t.getMessage());
    }).exceptionally(ex -> {
        System.out.println("exceptionally中异常:" + ex.getMessage());
        return 0.0;
    });
    System.out.println("最终返回的结果 = " + future.get());
}

Multi‑Task Composition

AND composition – thenCombine , thenAcceptBoth , runAfterBoth execute a third task after both preceding tasks finish. The difference lies in whether results are passed and whether a value is returned.

@Test
public void testCompletableThenCombine() throws ExecutionException, InterruptedException {
    ExecutorService executor = Executors.newFixedThreadPool(10);
    CompletableFuture
task1 = CompletableFuture.supplyAsync(() -> {
        System.out.println("异步任务1,当前线程是:" + Thread.currentThread().getId());
        return 2;
    }, executor);
    CompletableFuture
task2 = CompletableFuture.supplyAsync(() -> {
        System.out.println("异步任务2,当前线程是:" + Thread.currentThread().getId());
        return 2;
    }, executor);
    CompletableFuture
task3 = task1.thenCombineAsync(task2, (a, b) -> {
        System.out.println("执行任务3,当前线程是:" + Thread.currentThread().getId());
        System.out.println("任务1返回值:" + a);
        System.out.println("任务2返回值:" + b);
        return a + b;
    }, executor);
    System.out.println("最终结果:" + task3.get());
}

OR composition – applyToEither , acceptEither , runAfterEither trigger the third task as soon as any one of the two tasks finishes.

@Test
public void testCompletableEitherAsync() {
    ExecutorService executor = Executors.newFixedThreadPool(10);
    CompletableFuture
t1 = CompletableFuture.supplyAsync(() -> {
        System.out.println("异步任务1,当前线程是:" + Thread.currentThread().getId());
        return 2;
    }, executor);
    CompletableFuture
t2 = CompletableFuture.supplyAsync(() -> {
        System.out.println("异步任务2,当前线程是:" + Thread.currentThread().getId());
        Thread.sleep(3000);
        return 3;
    }, executor);
    t1.acceptEitherAsync(t2, res -> {
        System.out.println("执行任务3,当前线程是:" + Thread.currentThread().getId());
        System.out.println("上一个任务的结果为:" + res);
    }, executor);
}

allOf / anyOf – CompletableFuture.allOf waits for all supplied stages; anyOf completes when any stage finishes.

@Test
public void testCompletableAllOf() throws ExecutionException, InterruptedException {
    ExecutorService executor = Executors.newFixedThreadPool(10);
    CompletableFuture
t1 = CompletableFuture.supplyAsync(() -> 2, executor);
    CompletableFuture
t2 = CompletableFuture.supplyAsync(() -> { Thread.sleep(3000); return 3; }, executor);
    CompletableFuture
t3 = CompletableFuture.supplyAsync(() -> { Thread.sleep(4000); return 4; }, executor);
    CompletableFuture
all = CompletableFuture.allOf(t1, t2, t3);
    all.get();
    System.out.println("task1结果为:" + t1.get());
    System.out.println("task2结果为:" + t2.get());
    System.out.println("task3结果为:" + t3.get());
}

Practical Tips for Using CompletableFuture

To see exceptions you must call get() or join() on the CompletableFuture.

get() blocks; consider using the timed version get(long, TimeUnit) .

Avoid the default ForkJoinPool for heavy workloads; provide a custom thread pool sized appropriately for your CPU and workload.

When configuring a custom pool, prefer AbortPolicy over DiscardPolicy or DiscardOldestPolicy to avoid silent task loss.

By following these patterns, developers can write cleaner, more maintainable asynchronous code in Java backend services.

JavaConcurrencyException HandlingasynchronousCompletableFuturethread poolFuture
Selected Java Interview Questions
Written by

Selected Java Interview Questions

A professional Java tech channel sharing common knowledge to help developers fill gaps. Follow us!

0 followers
Reader feedback

How this landed with the community

login Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.