Understanding and Using CompletableFuture in Java for Asynchronous Programming

This article introduces Java's CompletableFuture, compares it with the older Future API, demonstrates how to create, combine, and handle asynchronous tasks with code examples, and highlights best practices and pitfalls for effective concurrent programming.

Code Ape Tech Column
Code Ape Tech Column
Code Ape Tech Column
Understanding and Using CompletableFuture in Java for Asynchronous Programming

Hello everyone, I'm Chen. Recently I optimized my project code using CompletableFuture , so I want to share what I learned.

Reviewing Future

Because CompletableFuture implements the Future interface, we first review Future.

Future, added in Java 5, provides asynchronous parallel computation. It lets a time‑consuming task run in another thread while the main thread continues other work, and later retrieves the result.

Example: two services – one fetching basic user info, another fetching user medals.

public class UserInfoService {
    public UserInfo getUserInfo(Long userId) throws InterruptedException {
        Thread.sleep(300); // simulate delay
        return new UserInfo("666", "捡田螺的小男孩", 27);
    }
}

public class MedalService {
    public MedalInfo getMedalInfo(long userId) throws InterruptedException {
        Thread.sleep(500); // simulate delay
        return new MedalInfo("666", "守护勋章");
    }
}

Using Future in the main thread:

public class FutureTest {
    public static void main(String[] args) throws ExecutionException, InterruptedException {
        ExecutorService executorService = Executors.newFixedThreadPool(10);
        UserInfoService userInfoService = new UserInfoService();
        MedalService medalService = new MedalService();
        long userId = 666L;
        long startTime = System.currentTimeMillis();
        FutureTask<UserInfo> userInfoFutureTask = new FutureTask<>(() -> userInfoService.getUserInfo(userId));
        executorService.submit(userInfoFutureTask);
        Thread.sleep(300); // simulate other work
        FutureTask<MedalInfo> medalInfoFutureTask = new FutureTask<>(() -> medalService.getMedalInfo(userId));
        executorService.submit(medalInfoFutureTask);
        UserInfo userInfo = userInfoFutureTask.get();
        MedalInfo medalInfo = medalInfoFutureTask.get();
        System.out.println("总共用时" + (System.currentTimeMillis() - startTime) + "ms");
    }
}

Result: 总共用时806ms. Compared with sequential execution (~1100 ms), the asynchronous approach is faster.

However, Future requires blocking get() or polling, which contradicts asynchronous design.

Introducing CompletableFuture

CompletableFuture provides an observer‑like mechanism that notifies when a task completes.

Creating Asynchronous Tasks

Two main methods: supplyAsync – returns a value. runAsync – returns no value.

public static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier)
public static <U> CompletableFuture<U> supplyAsync(Supplier<U> supplier, Executor executor)
public static CompletableFuture<Void> runAsync(Runnable runnable)
public static CompletableFuture<Void> runAsync(Runnable runnable, Executor executor)

Example with custom thread pool:

public class FutureTest {
    public static void main(String[] args) {
        ExecutorService executor = Executors.newCachedThreadPool();
        CompletableFuture<Void> runFuture = CompletableFuture.runAsync(() -> System.out.println("run,捡田螺的小男孩"), executor);
        CompletableFuture<String> supplyFuture = CompletableFuture.supplyAsync(() -> {
            System.out.print("supply,捡田螺的小男孩");
            return "捡田螺的小男孩";
        }, executor);
        System.out.println(runFuture.join()); // prints null
        System.out.println(supplyFuture.join());
        executor.shutdown();
    }
}

Task Callbacks

thenRun / thenRunAsync – execute a second task after the first finishes, without passing a result.

public CompletableFuture<Void> thenRun(Runnable action);
public CompletableFuture<Void> thenRunAsync(Runnable action);

When using a custom thread pool, thenRun shares the same pool, while thenRunAsync uses the common ForkJoin pool.

thenAccept / thenAcceptAsync – receive the previous result as a parameter, but return no value.

thenApply / thenApplyAsync – receive the previous result and produce a new result.

exceptionally – handle exceptions and provide a fallback value.

whenComplete – runs after completion, receives result and exception, returns the original result.

handle – similar to whenComplete but can transform the result.

Combining Multiple Tasks

AND composition (both tasks must finish): thenCombine, thenAcceptBoth, runAfterBoth. The first returns a value, the others do not.

public class ThenCombineTest {
    public static void main(String[] args) throws Exception {
        CompletableFuture<String> first = CompletableFuture.completedFuture("第一个异步任务");
        ExecutorService executor = Executors.newFixedThreadPool(10);
        CompletableFuture<String> future = CompletableFuture
            .supplyAsync(() -> "第二个异步任务", executor)
            .thenCombineAsync(first, (s, w) -> {
                System.out.println(w);
                System.out.println(s);
                return "两个异步任务的组合";
            }, executor);
        System.out.println(future.join());
        executor.shutdown();
    }
}

OR composition (any task finishes): applyToEither, acceptEither, runAfterEither. The first returns a value, the others do not.

public class AcceptEitherTest {
    public static void main(String[] args) {
        CompletableFuture<String> first = CompletableFuture.supplyAsync(() -> {
            try { Thread.sleep(2000L); } catch (Exception e) {}
            return "第一个异步任务";
        });
        ExecutorService executor = Executors.newSingleThreadExecutor();
        CompletableFuture<Void> future = CompletableFuture
            .supplyAsync(() -> {
                System.out.println("执行完第二个任务");
                return "第二个任务";
            }, executor)
            .acceptEitherAsync(first, System.out::println, executor);
        executor.shutdown();
    }
}

allOf – runs after *all* supplied futures complete; any exception propagates.

public class allOfFutureTest {
    public static void main(String[] args) throws Exception {
        CompletableFuture<Void> a = CompletableFuture.runAsync(() -> System.out.println("我执行完了"));
        CompletableFuture<Void> b = CompletableFuture.runAsync(() -> System.out.println("我也执行完了"));
        CompletableFuture<Void> all = CompletableFuture.allOf(a, b).whenComplete((v, ex) -> System.out.println("finish"));
    }
}

anyOf – runs after *any* future completes; exception in any propagates.

public class AnyOfFutureTest {
    public static void main(String[] args) throws Exception {
        CompletableFuture<Void> a = CompletableFuture.runAsync(() -> {
            try { Thread.sleep(3000L); } catch (InterruptedException e) {}
            System.out.println("我执行完了");
        });
        CompletableFuture<Void> b = CompletableFuture.runAsync(() -> System.out.println("我也执行完了"));
        CompletableFuture<Object> any = CompletableFuture.anyOf(a, b).whenComplete((v, ex) -> System.out.println("finish"));
        any.join();
    }
}

thenCompose – flattens nested futures; the result of the first task is used to start a second asynchronous task.

public class ThenComposeTest {
    public static void main(String[] args) throws Exception {
        CompletableFuture<String> f = CompletableFuture.completedFuture("第一个任务");
        ExecutorService executor = Executors.newSingleThreadExecutor();
        CompletableFuture<String> future = CompletableFuture
            .supplyAsync(() -> "第二个任务", executor)
            .thenComposeAsync(data -> {
                System.out.println(data);
                return f;
            }, executor);
        System.out.println(future.join());
        executor.shutdown();
    }
}

Practical Tips and Pitfalls

Future only surfaces exceptions when you call get() (or join()). CompletableFuture.get() is blocking; use the timed version get(timeout, TimeUnit) to avoid indefinite waits.

The default ForkJoinPool size is CPU‑cores‑1; heavy workloads may need a custom thread pool.

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

By understanding these methods and best‑practice guidelines, developers can write cleaner, more efficient asynchronous Java code.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JavaconcurrencyThreadPoolAsynchronousCompletableFutureFuture
Code Ape Tech Column
Written by

Code Ape Tech Column

Former Ant Group P8 engineer, pure technologist, sharing full‑stack Java, job interview and career advice through a column. Site: java-family.cn

0 followers
Reader feedback

How this landed with the community

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.