Backend Development 20 min read

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

This article explains Java asynchronous programming by first showing the limitations of the JDK5 Future interface, then demonstrating how CountDownLatch can coordinate tasks, and finally presenting CompletableFuture creation, result retrieval, callback chaining, exception handling, and multi‑task composition with practical code examples.

Java Architect Essentials
Java Architect Essentials
Java Architect Essentials
Using CompletableFuture for Asynchronous Programming in Java: Examples and Best Practices

In this tutorial the author, a Java architect, introduces asynchronous programming in Java, starting with the JDK5 Future interface, its usage, and its drawbacks such as blocking Future.get() and the need for polling with Future.isDone() .

Example 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");
}

To handle task dependencies the article shows using 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 500ms query
        Thread.sleep(500);
        downLatch.countDown();
        return "用户A";
    });
    Future
goodsFuture = executorService.submit(() -> {
        // simulate 400ms query
        Thread.sleep(400);
        downLatch.countDown();
        return "商品A";
    });
    downLatch.await();
    // simulate main thread work
    Thread.sleep(600);
    System.out.println("获取用户信息:" + userFuture.get());
    System.out.println("获取商品信息:" + goodsFuture.get());
    System.out.println("总共用时" + (System.currentTimeMillis() - startTime) + "ms");
}

Running results show that asynchronous execution reduces total time from 1500 ms to about 1110 ms.

Since Java 8, CompletableFuture provides a more elegant solution. The article presents a complete rewrite of the previous example using 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());
    // main thread work
    Thread.sleep(600);
    System.out.println("总共用时" + (System.currentTimeMillis() - startTime) + "ms");
}

The article then details the four static creation methods of CompletableFuture ( supplyAsync and runAsync , with optional custom executors) and explains their differences.

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) { ... }

Result‑retrieval methods are compared:

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

Example demonstrating these methods and exception behavior:

@Test
public void testCompletableGet() throws InterruptedException, ExecutionException {
    CompletableFuture
cp1 = CompletableFuture.supplyAsync(() -> {
        try { Thread.sleep(1000); } catch (InterruptedException e) { e.printStackTrace(); }
        return "商品A";
    });
    // getNow
    System.out.println(cp1.getNow("商品B"));
    // join (exception wrapped in CompletionException)
    CompletableFuture
cp2 = CompletableFuture.supplyAsync(() -> 1 / 0);
    System.out.println(cp2.join());
    // get (throws ExecutionException)
    CompletableFuture
cp3 = CompletableFuture.supplyAsync(() -> 1 / 0);
    System.out.println(cp3.get());
}

Callback chaining methods are introduced:

thenRun / thenRunAsync : execute a second task after the first, no return value.

thenAccept / thenAcceptAsync : consume the result of the first task, no return value.

thenApply / thenApplyAsync : transform the result of the first task and return a new value.

Example of thenRunAsync :

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

Similar examples are provided for thenAccept and thenApply , showing how to pass results between stages.

Exception handling with whenComplete and exceptionally is covered. The article shows that whenComplete runs for both normal and exceptional completions, while exceptionally can provide a fallback value.

@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((aDouble, throwable) -> {
        if (aDouble == null) {
            System.out.println("whenComplete aDouble is null");
        } else {
            System.out.println("whenComplete aDouble is " + aDouble);
        }
        if (throwable == null) {
            System.out.println("whenComplete throwable is null");
        } else {
            System.out.println("whenComplete throwable is " + throwable.getMessage());
        }
    });
    System.out.println("最终返回的结果 = " + future.get());
}

Multi‑task composition is discussed with AND and OR relationships:

thenCombine / thenAcceptBoth / runAfterBoth – execute a third task after both tasks finish (with or without returning a value).

applyToEither / acceptEither / runAfterEither – execute a third task as soon as either of two tasks finishes.

allOf – wait for all tasks; anyOf – continue when any task finishes.

Example of thenCombineAsync :

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

Example of anyOf :

@Test
public void testCompletableAnyOf() throws ExecutionException, InterruptedException {
    ExecutorService executorService = Executors.newFixedThreadPool(10);
    CompletableFuture
task = CompletableFuture.supplyAsync(() -> 1 + 1, executorService);
    CompletableFuture
task2 = CompletableFuture.supplyAsync(() -> 1 + 2, executorService);
    CompletableFuture
task3 = CompletableFuture.supplyAsync(() -> 1 + 3, executorService);
    CompletableFuture
anyOf = CompletableFuture.anyOf(task, task2, task3);
    Object o = anyOf.get();
    System.out.println("完成的任务的结果:" + o);
}

Finally, the article lists practical tips when using CompletableFuture :

Always call get() or join() to surface exceptions.

Be aware that get() blocks; consider using timed versions.

Avoid the default ForkJoinPool for high‑load scenarios; provide a custom thread pool.

Choose an appropriate rejection policy (e.g., AbortPolicy ) to avoid silent task loss.

By the end of the article the reader should be able to replace raw Future usage with more expressive and efficient CompletableFuture patterns in Java backend development.

JavaConcurrencythreadpoolasynchronousCompletableFutureFutureExceptionHandling
Java Architect Essentials
Written by

Java Architect Essentials

Committed to sharing quality articles and tutorials to help Java programmers progress from junior to mid-level to senior architect. We curate high-quality learning resources, interview questions, videos, and projects from across the internet to help you systematically improve your Java architecture skills. Follow and reply '1024' to get Java programming resources. Learn together, grow together.

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.