跪拜 Guibai
← Back to the summary

Future vs. CompletableFuture: When Java's Async Placeholder Isn't Enough

Java Future and CompletableFuture Practical Guide: From Asynchronous Results to Task Orchestration

Author: 唐青枫 Tags: Java

Introduction

Future and CompletableFuture both belong to the asynchronous result model in Java concurrent programming.

A simple understanding:

Future
  |
  v
A placeholder for the result of an asynchronous task
CompletableFuture
  |
  v
An asynchronous result that can be chained, composed, and safeguarded with exception handling

Future appeared starting from Java 5, mainly solving one problem:

After submitting a task to a thread pool, you can still get the task result later.

CompletableFuture appeared starting from Java 8, solving a further problem:

After an asynchronous task completes, you can continue to transform, consume, combine, and provide fallbacks, without relying solely on get() to block and wait.

Summarized in one sentence:

Future is suitable for receiving the result of a single asynchronous task, while CompletableFuture is more suitable for orchestrating a group of asynchronous tasks.

What Problem Does Future Solve

When a normal Thread executes a task, the result is not easily returned directly.

Thread thread = new Thread(() -> {
    int result = 100 + 200;
});

thread.start();

Here, result can only stay inside the thread.

If you want a task to execute in the background while the main flow can still get the result later, you can use ExecutorService and Future.

Execution flow:

Submit a Callable task
  |
  v
Thread pool executes asynchronously
  |
  v
Immediately returns a Future
  |
  v
Call get() when the result is needed

First Future Demo

import java.util.concurrent.Callable;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;

public class FutureFirstDemo {

    public static void main(String[] args) throws Exception {
        ExecutorService executor = Executors.newFixedThreadPool(2);

        Callable<Integer> task = () -> {
            Thread.sleep(1000);
            return 100 + 200;
        };

        Future<Integer> future = executor.submit(task);

        System.out.println("Task submitted");
        System.out.println("Main flow continues executing");

        Integer result = future.get();
        System.out.println("Asynchronous result: " + result);

        executor.shutdown();
    }
}

Output similar to:

Task submitted
Main flow continues executing
Asynchronous result: 300

submit immediately returns a Future.

future.get() blocks the current thread until the task completes.

Future Common Methods

Method Purpose
get() Blocks and waits for the task to complete and returns the result
get(timeout, unit) Waits for at most the specified time
isDone() Checks if the task is complete
cancel(mayInterruptIfRunning) Attempts to cancel the task
isCancelled() Checks if the task has been cancelled

Future Timeout and Cancellation

If get() never gets a result, the current thread will be stuck forever.

A safer approach is to use a timeout:

import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.Future;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.TimeoutException;

public class FutureTimeoutDemo {

    public static void main(String[] args) throws Exception {
        ExecutorService executor = Executors.newFixedThreadPool(1);

        Future<String> future = executor.submit(() -> {
            Thread.sleep(3000);
            return "Remote interface result";
        });

        try {
            String result = future.get(1, TimeUnit.SECONDS);
            System.out.println(result);
        } catch (TimeoutException e) {
            future.cancel(true);
            System.out.println("Task timed out, attempted cancellation");
        } finally {
            executor.shutdown();
        }
    }
}

cancel(true) attempts to interrupt the executing task.

If the task internally catches InterruptedException, it should restore the interrupt status:

try {
    Thread.sleep(3000);
} catch (InterruptedException e) {
    Thread.currentThread().interrupt();
    return "Task was cancelled";
}

Limitations of Future

Future can get the result of an asynchronous task, but its expressive power is basic.

Common pain points:

For example, an interface aggregation like this:

Query user
Query orders
Query account
Merge results

It can be written with Future, but the code will revolve around multiple get() calls.

The more tasks, the more scattered the flow.

What is CompletableFuture

CompletableFuture<T> implements both:

Future<T>
CompletionStage<T>

It can represent an asynchronous result like Future, and also orchestrate subsequent actions like a pipeline.

It supports:

First CompletableFuture Demo

import java.util.concurrent.CompletableFuture;

public class CompletableFutureFirstDemo {

    public static void main(String[] args) {
        CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
            sleep(1000);
            return "Java";
        });

        String result = future
                .thenApply(String::toUpperCase)
                .thenApply(value -> "Hello " + value)
                .join();

        System.out.println(result);
    }

    private static void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException(e);
        }
    }
}

Output:

Hello JAVA

There is no manual get() in the intermediate steps here.

After the task completes, it automatically enters thenApply.

runAsync and supplyAsync

CompletableFuture commonly uses two static methods to create asynchronous tasks.

runAsync

Has no return value.

CompletableFuture<Void> future = CompletableFuture.runAsync(() -> {
    System.out.println("Refreshing cache");
});

Suitable for:

supplyAsync

Has a return value.

CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    return "Product details";
});

Suitable for:

Default Thread Pool

When no Executor is specified, runAsync and supplyAsync default to using ForkJoinPool.commonPool().

CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    return Thread.currentThread().getName();
});

Output similar to:

ForkJoinPool.commonPool-worker-1

The default pool is suitable for simple demos.

In business projects, it's more common to pass in a custom thread pool:

CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    return "Order data";
}, orderExecutor);

The reasons are simple:

Isolate tasks from different businesses
Control queue length
Set thread names
Observe rejection policies
Avoid all asynchronous tasks crowding into the commonPool

Custom Thread Pool

import java.util.concurrent.ArrayBlockingQueue;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.ThreadFactory;
import java.util.concurrent.ThreadPoolExecutor;
import java.util.concurrent.TimeUnit;
import java.util.concurrent.atomic.AtomicInteger;

public class AsyncExecutors {

    public static ExecutorService orderExecutor() {
        AtomicInteger counter = new AtomicInteger(1);

        ThreadFactory threadFactory = runnable -> {
            Thread thread = new Thread(runnable);
            thread.setName("order-async-" + counter.getAndIncrement());
            return thread;
        };

        return new ThreadPoolExecutor(
                8,
                16,
                60,
                TimeUnit.SECONDS,
                new ArrayBlockingQueue<>(500),
                threadFactory,
                new ThreadPoolExecutor.CallerRunsPolicy()
        );
    }
}

Usage:

ExecutorService executor = AsyncExecutors.orderExecutor();

CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    return "Order query result";
}, executor);

System.out.println(future.join());

executor.shutdown();

Thread pool parameters need to be adjusted based on business load testing.

If tasks are mainly I/O waiting, virtual threads can also be combined:

ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor();

CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    return callRemoteService();
}, executor);

get and join

Future commonly uses get().

CompletableFuture also has get(), but more code uses join().

Method Exception Form Description
get() InterruptedException, ExecutionException Checked exceptions, requires explicit handling
get(timeout, unit) Additionally throws TimeoutException With a wait time
join() CompletionException Unchecked exception, more common in chained code
getNow(defaultValue) Does not block Returns default value if not yet complete

Example:

CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> "OK");

String result = future.join();
System.out.println(result);

If the task encounters an exception, join() throws a CompletionException.

CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    throw new IllegalStateException("Inventory service exception");
});

try {
    future.join();
} catch (CompletionException e) {
    System.out.println(e.getCause().getMessage());
}

thenApply: Transform Results

thenApply is used to transform the previous step's result into another value.

CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> "java")
        .thenApply(String::toUpperCase)
        .thenApply(value -> "Language: " + value);

System.out.println(future.join());

Output:

Language: JAVA

Analogous synchronous writing:

String value = "java";
String upper = value.toUpperCase();
String result = "Language: " + upper;

thenApply is suitable for pure in-memory transformations, not for initiating new asynchronous tasks inside it.

thenAccept: Consume Results

thenAccept receives the previous step's result but does not return a new value.

CompletableFuture<Void> future = CompletableFuture.supplyAsync(() -> "Order created successfully")
        .thenAccept(message -> {
            System.out.println("Sending notification: " + message);
        });

future.join();

Suitable for:

thenRun: Only Care About Completion Signal

thenRun does not receive the previous step's result, nor does it return a new value.

CompletableFuture<Void> future = CompletableFuture.supplyAsync(() -> "Task result")
        .thenRun(() -> {
            System.out.println("Task has completed");
        });

future.join();

Suitable for scenarios where you only care about the "completed" event.

thenCompose: Serial Asynchronous Tasks

If the second task depends on the result of the first task, and the second task itself is also asynchronous, use thenCompose.

Example scenario:

First query user
Then query orders based on user ID
CompletableFuture<User> userFuture = findUser(1001L);

CompletableFuture<Order> orderFuture = userFuture
        .thenCompose(user -> findLatestOrder(user.id()));

Full Demo:

import java.util.concurrent.CompletableFuture;

public class ThenComposeDemo {

    public static void main(String[] args) {
        CompletableFuture<Order> future = findUser(1001L)
                .thenCompose(user -> findLatestOrder(user.id()));

        System.out.println(future.join());
    }

    private static CompletableFuture<User> findUser(Long userId) {
        return CompletableFuture.supplyAsync(() -> {
            sleep(300);
            return new User(userId, "Zhang San");
        });
    }

    private static CompletableFuture<Order> findLatestOrder(Long userId) {
        return CompletableFuture.supplyAsync(() -> {
            sleep(500);
            return new Order(9001L, userId, "PAID");
        });
    }

    private static void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException(e);
        }
    }

    record User(Long id, String username) {
    }

    record Order(Long id, Long userId, String status) {
    }
}

thenCompose avoids this kind of nesting:

CompletableFuture<CompletableFuture<Order>> nested = findUser(1001L)
        .thenApply(user -> findLatestOrder(user.id()));

thenCombine: Merge Two Independent Tasks

If two tasks are independent of each other, they can be initiated in parallel and then their results merged.

Example scenario:

Query user info
Query account balance
Merge into user homepage
CompletableFuture<User> userFuture = findUser(1001L);
CompletableFuture<Account> accountFuture = findAccount(1001L);

CompletableFuture<UserHome> homeFuture = userFuture.thenCombine(
        accountFuture,
        (user, account) -> new UserHome(user, account)
);

Full Demo:

import java.math.BigDecimal;
import java.util.concurrent.CompletableFuture;

public class ThenCombineDemo {

    public static void main(String[] args) {
        CompletableFuture<UserHome> future = findUser(1001L)
                .thenCombine(findAccount(1001L), UserHome::new);

        System.out.println(future.join());
    }

    private static CompletableFuture<User> findUser(Long userId) {
        return CompletableFuture.supplyAsync(() -> {
            sleep(500);
            return new User(userId, "Zhang San");
        });
    }

    private static CompletableFuture<Account> findAccount(Long userId) {
        return CompletableFuture.supplyAsync(() -> {
            sleep(500);
            return new Account(userId, new BigDecimal("99.80"));
        });
    }

    private static void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException(e);
        }
    }

    record User(Long id, String username) {
    }

    record Account(Long userId, BigDecimal balance) {
    }

    record UserHome(User user, Account account) {
    }
}

The merge function executes only after both tasks are complete.

allOf: Wait for All Tasks to Complete

allOf is suitable for waiting for a group of tasks to all complete.

import java.util.List;
import java.util.concurrent.CompletableFuture;

public class AllOfDemo {

    public static void main(String[] args) {
        List<Long> productIds = List.of(1L, 2L, 3L);

        List<CompletableFuture<Product>> futures = productIds.stream()
                .map(AllOfDemo::findProduct)
                .toList();

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

        List<Product> products = allFuture
                .thenApply(ignore -> futures.stream()
                        .map(CompletableFuture::join)
                        .toList())
                .join();

        System.out.println(products);
    }

    private static CompletableFuture<Product> findProduct(Long productId) {
        return CompletableFuture.supplyAsync(() -> {
            sleep(300);
            return new Product(productId, "Product-" + productId);
        });
    }

    private static void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException(e);
        }
    }

    record Product(Long id, String name) {
    }
}

allOf returns a CompletableFuture<Void>.

Therefore, after all are complete, you need to call join() on each of the original futures to retrieve the results.

anyOf: Wait for Any One Task to Complete

anyOf is suitable for "use whoever returns first" scenarios.

For example, the same configuration can be read from multiple centers:

import java.util.concurrent.CompletableFuture;

public class AnyOfDemo {

    public static void main(String[] args) {
        CompletableFuture<String> nacosFuture = loadFromNacos();
        CompletableFuture<String> apolloFuture = loadFromApollo();

        CompletableFuture<Object> fastest = CompletableFuture.anyOf(
                nacosFuture,
                apolloFuture
        );

        System.out.println(fastest.join());
    }

    private static CompletableFuture<String> loadFromNacos() {
        return CompletableFuture.supplyAsync(() -> {
            sleep(500);
            return "nacos-config";
        });
    }

    private static CompletableFuture<String> loadFromApollo() {
        return CompletableFuture.supplyAsync(() -> {
            sleep(300);
            return "apollo-config";
        });
    }

    private static void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException(e);
        }
    }
}

The return type is CompletableFuture<Object>.

When using it, you usually need to do a type cast, or uniformly wrap the results into the same type.

applyToEither: Use Whichever of Two Tasks is Faster

applyToEither is similar to anyOf, but it can continue to transform the result and retains generics.

CompletableFuture<String> primary = CompletableFuture.supplyAsync(() -> {
    sleep(500);
    return "primary";
});

CompletableFuture<String> backup = CompletableFuture.supplyAsync(() -> {
    sleep(300);
    return "backup";
});

CompletableFuture<String> result = primary.applyToEither(
        backup,
        value -> "Using result: " + value
);

System.out.println(result.join());

Suitable for scenarios like primary/backup interfaces, same-city dual-active reads, etc.

exceptionally: Exception Recovery

exceptionally only executes when the upstream encounters an exception.

import java.util.concurrent.CompletableFuture;

public class ExceptionallyDemo {

    public static void main(String[] args) {
        CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
            throw new IllegalStateException("Inventory service unavailable");
        }).exceptionally(ex -> {
            return "Inventory status unknown";
        });

        System.out.println(future.join());
    }
}

Output:

Inventory status unknown

Suitable for returning a fallback value.

handle: Handle Both Normal and Exceptional Cases

handle executes regardless of whether the upstream succeeds or fails.

import java.util.concurrent.CompletableFuture;

public class HandleDemo {

    public static void main(String[] args) {
        CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
            if (System.currentTimeMillis() > 0) {
                throw new IllegalStateException("Payment service exception");
            }
            return "Payment successful";
        }).handle((result, ex) -> {
            if (ex != null) {
                return "Payment status pending confirmation";
            }
            return result;
        });

        System.out.println(future.join());
    }
}

Suitable for uniformly converting both success and failure into the same return structure.

whenComplete: Observe Results After Completion

whenComplete can get the result or exception, but typically does not change the final result.

CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> "OK")
        .whenComplete((result, ex) -> {
            if (ex != null) {
                System.out.println("Logging exception: " + ex.getMessage());
            } else {
                System.out.println("Logging result: " + result);
            }
        });

System.out.println(future.join());

Suitable for:

If an exception is thrown inside whenComplete, the subsequent chain will also be affected.

Comparison of exceptionally, handle, whenComplete

Method Only Handles Exceptions? Can Change Result? Common Use Cases
exceptionally Yes Yes Exception fallback
handle No Yes Unified success/failure transformation
whenComplete No Usually not Logging, monitoring, resource release

Timeout Control

Starting from Java 9, CompletableFuture provides more convenient timeout methods.

orTimeout

Completes exceptionally after a timeout.

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;

public class OrTimeoutDemo {

    public static void main(String[] args) {
        CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
            sleep(3000);
            return "Remote interface result";
        }).orTimeout(1, TimeUnit.SECONDS)
          .exceptionally(ex -> "Interface timeout");

        System.out.println(future.join());
    }

    private static void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException(e);
        }
    }
}

Output:

Interface timeout

completeOnTimeout

Completes with a default value after a timeout.

CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    sleep(3000);
    return "Slow interface result";
}).completeOnTimeout("Default result", 1, TimeUnit.SECONDS);

System.out.println(future.join());

completeOnTimeout is suitable for scenarios where a default value is acceptable.

orTimeout is suitable for scenarios where you need to explicitly enter exception handling.

Manually Completing a CompletableFuture

A CompletableFuture can be created first, and then another thread fills in the result later.

import java.util.concurrent.CompletableFuture;

public class ManualCompleteDemo {

    public static void main(String[] args) {
        CompletableFuture<String> future = new CompletableFuture<>();

        Thread thread = new Thread(() -> {
            try {
                Thread.sleep(500);
                future.complete("Callback result");
            } catch (InterruptedException e) {
                Thread.currentThread().interrupt();
                future.completeExceptionally(e);
            }
        });

        thread.start();

        System.out.println(future.join());
    }
}

A more common approach is to put completeExceptionally in callback bridging code:

CompletableFuture<String> future = new CompletableFuture<>();

legacyClient.callAsync(new LegacyCallback() {
    @Override
    public void onSuccess(String result) {
        future.complete(result);
    }

    @Override
    public void onError(Throwable throwable) {
        future.completeExceptionally(throwable);
    }
});

This approach is suitable for wrapping old-style callback APIs into CompletableFuture.

Practical Example: User Homepage Aggregation Interface

Scenario:

The user homepage needs to display simultaneously:
User info
Account balance
Recent orders
Coupon count

This data comes from different services with no dependencies between them, suitable for concurrent querying.

Domain model:

import java.math.BigDecimal;
import java.util.List;

public record UserInfo(Long userId, String username) {
}

public record AccountInfo(Long userId, BigDecimal balance) {
}

public record OrderInfo(Long orderId, String title) {
}

public record CouponInfo(Integer count) {
}

public record UserHomeResponse(
        UserInfo user,
        AccountInfo account,
        List<OrderInfo> orders,
        CouponInfo coupon
) {
}

Remote service simulation:

import java.math.BigDecimal;
import java.util.List;

public class RemoteClients {

    public UserInfo findUser(Long userId) {
        sleep(300);
        return new UserInfo(userId, "Zhang San");
    }

    public AccountInfo findAccount(Long userId) {
        sleep(500);
        return new AccountInfo(userId, new BigDecimal("88.60"));
    }

    public List<OrderInfo> findRecentOrders(Long userId) {
        sleep(700);
        return List.of(
                new OrderInfo(9001L, "Java Concurrent Programming"),
                new OrderInfo(9002L, "Spring Boot in Action")
        );
    }

    public CouponInfo findCoupon(Long userId) {
        sleep(200);
        return new CouponInfo(3);
    }

    private void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException(e);
        }
    }
}

Aggregation service:

import java.util.List;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.TimeUnit;

public class UserHomeService {

    private final RemoteClients remoteClients = new RemoteClients();
    private final ExecutorService executor = Executors.newFixedThreadPool(8);

    public UserHomeResponse loadHome(Long userId) {
        CompletableFuture<UserInfo> userFuture = CompletableFuture.supplyAsync(
                () -> remoteClients.findUser(userId),
                executor
        );

        CompletableFuture<AccountInfo> accountFuture = CompletableFuture.supplyAsync(
                () -> remoteClients.findAccount(userId),
                executor
        );

        CompletableFuture<List<OrderInfo>> orderFuture = CompletableFuture.supplyAsync(
                () -> remoteClients.findRecentOrders(userId),
                executor
        );

        CompletableFuture<CouponInfo> couponFuture = CompletableFuture.supplyAsync(
                () -> remoteClients.findCoupon(userId),
                executor
        ).completeOnTimeout(new CouponInfo(0), 300, TimeUnit.MILLISECONDS);

        return CompletableFuture
                .allOf(userFuture, accountFuture, orderFuture, couponFuture)
                .thenApply(ignore -> new UserHomeResponse(
                        userFuture.join(),
                        accountFuture.join(),
                        orderFuture.join(),
                        couponFuture.join()
                ))
                .orTimeout(2, TimeUnit.SECONDS)
                .join();
    }

    public void shutdown() {
        executor.shutdown();
    }
}

Test entry point:

public class UserHomeDemo {

    public static void main(String[] args) {
        UserHomeService service = new UserHomeService();

        try {
            long start = System.currentTimeMillis();
            UserHomeResponse response = service.loadHome(1001L);
            long cost = System.currentTimeMillis() - start;

            System.out.println(response);
            System.out.println("Time taken: " + cost + " ms");
        } finally {
            service.shutdown();
        }
    }
}

Here, the four queries execute concurrently, and the total time mainly depends on the slowest task, not the sum of the four tasks' times.

The coupon interface uses completeOnTimeout. If the coupon service is slow, the homepage can still return, just with the coupon count treated as 0.

Practical Example: Order Placement Flow with Serial and Parallel Combination

Not all tasks can be concurrent.

An order placement flow typically has both dependencies and independent queries.

Example flow:

Validate order
  |
  v
Concurrently query user, inventory, price
  |
  v
Deduct inventory
  |
  v
Create order

Code example:

import java.math.BigDecimal;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class OrderCreateService {

    private final ExecutorService executor = Executors.newFixedThreadPool(8);

    public CompletableFuture<OrderCreateResult> create(OrderCreateCommand command) {
        return validate(command)
                .thenCompose(validCommand -> {
                    CompletableFuture<UserInfo> userFuture = findUser(validCommand.userId());
                    CompletableFuture<StockInfo> stockFuture = checkStock(validCommand.productId());
                    CompletableFuture<PriceInfo> priceFuture = findPrice(validCommand.productId());

                    return CompletableFuture
                            .allOf(userFuture, stockFuture, priceFuture)
                            .thenCompose(ignore -> deductStock(stockFuture.join()))
                            .thenApply(stockResult -> new OrderCreateResult(
                                    validCommand.userId(),
                                    validCommand.productId(),
                                    priceFuture.join().price(),
                                    stockResult.success()
                            ));
                });
    }

    private CompletableFuture<OrderCreateCommand> validate(OrderCreateCommand command) {
        return CompletableFuture.supplyAsync(() -> {
            if (command.count() <= 0) {
                throw new IllegalArgumentException("Product quantity must be greater than 0");
            }
            return command;
        }, executor);
    }

    private CompletableFuture<UserInfo> findUser(Long userId) {
        return CompletableFuture.supplyAsync(() -> new UserInfo(userId, "Zhang San"), executor);
    }

    private CompletableFuture<StockInfo> checkStock(Long productId) {
        return CompletableFuture.supplyAsync(() -> new StockInfo(productId, 100), executor);
    }

    private CompletableFuture<PriceInfo> findPrice(Long productId) {
        return CompletableFuture.supplyAsync(() -> new PriceInfo(productId, new BigDecimal("59.90")), executor);
    }

    private CompletableFuture<StockResult> deductStock(StockInfo stockInfo) {
        return CompletableFuture.supplyAsync(() -> {
            if (stockInfo.available() <= 0) {
                throw new IllegalStateException("Insufficient inventory");
            }
            return new StockResult(true);
        }, executor);
    }

    public void shutdown() {
        executor.shutdown();
    }

    public record OrderCreateCommand(Long userId, Long productId, Integer count) {
    }

    public record StockInfo(Long productId, Integer available) {
    }

    public record PriceInfo(Long productId, BigDecimal price) {
    }

    public record StockResult(Boolean success) {
    }

    public record OrderCreateResult(
            Long userId,
            Long productId,
            BigDecimal price,
            Boolean stockDeducted
    ) {
    }
}

In this code:

validate -> serial prerequisite
findUser / checkStock / findPrice -> parallel
deductStock -> depends on inventory check result
thenApply -> assemble final result

Returning CompletableFuture in Spring Boot

A Spring MVC Controller can directly return CompletableFuture<T>.

Maven dependency:

<dependency>
    <groupId>org.springframework.boot</groupId>
    <artifactId>spring-boot-starter-web</artifactId>
</dependency>

Asynchronous thread pool:

import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.scheduling.annotation.EnableAsync;
import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor;

import java.util.concurrent.Executor;

@Configuration
@EnableAsync
public class AsyncConfig {

    @Bean("orderAsyncExecutor")
    public Executor orderAsyncExecutor() {
        ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor();
        executor.setCorePoolSize(8);
        executor.setMaxPoolSize(16);
        executor.setQueueCapacity(500);
        executor.setThreadNamePrefix("order-async-");
        executor.initialize();
        return executor;
    }
}

Service:

import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;

import java.util.concurrent.CompletableFuture;

@Service
public class SpringOrderService {

    @Async("orderAsyncExecutor")
    public CompletableFuture<String> createOrder(Long userId, Long productId) {
        try {
            Thread.sleep(500);
            return CompletableFuture.completedFuture(
                    "Order created successfully, userId=" + userId + ", productId=" + productId
            );
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            return CompletableFuture.failedFuture(e);
        }
    }
}

Controller:

import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;

import java.util.concurrent.CompletableFuture;

@RestController
@RequestMapping("/api/orders")
public class SpringOrderController {

    private final SpringOrderService orderService;

    public SpringOrderController(SpringOrderService orderService) {
        this.orderService = orderService;
    }

    @PostMapping
    public CompletableFuture<String> create(
            @RequestParam Long userId,
            @RequestParam Long productId
    ) {
        return orderService.createOrder(userId, productId);
    }
}

Access:

POST http://localhost:8080/api/orders?userId=1001&productId=2001

CompletableFuture and Virtual Threads

After Java 21, I/O-intensive asynchronous tasks can consider using a virtual thread executor.

import java.util.concurrent.CompletableFuture;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;

public class CompletableFutureVirtualThreadDemo {

    public static void main(String[] args) {
        try (ExecutorService executor = Executors.newVirtualThreadPerTaskExecutor()) {
            CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
                sleep(500);
                return "Virtual thread execution result: " + Thread.currentThread();
            }, executor);

            System.out.println(future.join());
        }
    }

    private static void sleep(long millis) {
        try {
            Thread.sleep(millis);
        } catch (InterruptedException e) {
            Thread.currentThread().interrupt();
            throw new RuntimeException(e);
        }
    }
}

It should be noted that CompletableFuture is responsible for task orchestration, while virtual threads are responsible for carrying blocking tasks.

The two solve different problems:

CompletableFuture: Expresses asynchronous dependency relationships
Virtual Threads: Reduce the thread cost of blocking tasks

For ordinary synchronous code that just wants to run multiple blocking tasks concurrently, you can also directly use a virtual thread executor and Future.

If complex orchestration, exception fallback, and task combination are needed, CompletableFuture is still very suitable.

Common Usage Recommendations

Specify an Executor for Asynchronous Tasks

In business code, it is recommended to explicitly pass in a thread pool.

CompletableFuture.supplyAsync(() -> loadData(), executor);

This avoids multiple businesses sharing the default ForkJoinPool.commonPool() and affecting each other.

Use Bounded Queues in Thread Pools

More asynchronous tasks are not always better.

Bounded queues and rejection policies allow the system to expose problems earlier when under excessive pressure.

new ThreadPoolExecutor(
        8,
        16,
        60,
        TimeUnit.SECONDS,
        new ArrayBlockingQueue<>(500),
        threadFactory,
        new ThreadPoolExecutor.CallerRunsPolicy()
);

Distinguish Between thenApply and thenCompose

Use thenApply when returning a plain value.

CompletableFuture<String> nameFuture = userFuture
        .thenApply(UserInfo::username);

Use thenCompose when returning a CompletableFuture.

CompletableFuture<List<OrderInfo>> orderFuture = userFuture
        .thenCompose(user -> findRecentOrders(user.userId()));

Place join at the Boundary

join() blocks the current thread.

A clearer approach is:

Intermediate flow continues to return CompletableFuture
Call join at the outermost boundary

For example:

public CompletableFuture<UserHomeResponse> loadHomeAsync(Long userId) {
    return buildHomeFuture(userId);
}

public UserHomeResponse loadHome(Long userId) {
    return loadHomeAsync(userId).join();
}

Exceptions Must Enter the Chain

Swallowing exceptions inside an asynchronous task will make the caller mistakenly believe the task succeeded.

A more common approach is to throw the exception, causing the CompletableFuture to complete exceptionally:

CompletableFuture<String> future = CompletableFuture.supplyAsync(() -> {
    if (remoteFailed()) {
        throw new IllegalStateException("Remote service failed");
    }
    return "OK";
});

Then handle it uniformly later in the chain:

future.exceptionally(ex -> "fallback");

Set Timeouts

Remote interfaces, databases, and RPC calls are all suitable for setting timeouts.

CompletableFuture<String> future = CompletableFuture
        .supplyAsync(() -> callRemoteService(), executor)
        .orTimeout(2, TimeUnit.SECONDS);

Common API Summary

API Purpose
ExecutorService.submit(...) Submit a task and return a Future
Future.get() Block and wait for the result
Future.get(timeout, unit) Wait for the result with a timeout
Future.cancel(true) Attempt to cancel the task
CompletableFuture.runAsync(...) Asynchronously execute a task with no return value
CompletableFuture.supplyAsync(...) Asynchronously execute a task with a return value
CompletableFuture.completedFuture(value) Create an already-completed result
CompletableFuture.failedFuture(error) Create an already-failed result
complete(value) Manually complete with a result
completeExceptionally(error) Manually complete exceptionally
thenApply(...) Transform the result
thenAccept(...) Consume the result
thenRun(...) Execute an action after completion
thenCompose(...) Serial asynchronous orchestration
thenCombine(...) Merge two independent tasks
allOf(...) Wait for all tasks to complete
anyOf(...) Wait for any one task to complete
applyToEither(...) Use whichever of two tasks succeeds first
exceptionally(...) Exception fallback
handle(...) Unified handling of success and exception
whenComplete(...) Observe the result after completion
orTimeout(...) Complete exceptionally after a timeout
completeOnTimeout(...) Return a default value after a timeout
join() Block to get the result, throws an unchecked exception
getNow(defaultValue) Get the result without blocking

Summary

Both Future and CompletableFuture revolve around "asynchronous task results".

Future is more basic:

Submit a task
Get a Future
Call get when the result is needed

CompletableFuture is more suitable for task orchestration:

Asynchronous execution
Result transformation
Serial dependencies
Parallel merging
Exception fallback
Timeout control

For a single ordinary asynchronous task, Future is sufficient.

If an interface needs to query multiple services simultaneously, combine multiple results, and set fallbacks and timeouts, CompletableFuture is more convenient.

When it comes down to actual engineering, the focus is not on changing all code to be asynchronous, but on extracting I/O tasks suitable for concurrent waiting, and then properly configuring thread pools, timeouts, exception handling, and monitoring.

Comments

Top 1 of 2 from juejin.cn, machine-translated. The original thread is authoritative.

徐嘉迪

I feel like it's kind of like Promise in JS

唐青枫

The ideas are pretty much the same; once you get one, you get them all