一个接口需要同时读取用户资料、订单摘要和积分信息时,串行调用会把各个耗时相加。Java 8 的 CompletableFuture 可以把相互独立的步骤并行执行,再在结果齐全后组合起来。但“用了异步”不等于“性能一定更好”:线程池混用、阻塞等待和异常吞掉,都会把局部优化变成全局风险。

本文讨论一个可落地的边界:哪些步骤适合并行、异步任务应运行在哪个线程池、调用失败后怎样保留可解释的结果。 示例基于 Java 8,不依赖高版本才引入的超时 API。

先判断是否真的能并行

只有没有数据依赖的调用才适合同时发起。以下三个查询互不依赖:

1
2
3
用户资料 ─┐
订单摘要 ─┼─> 组合为页面 DTO
积分信息 ─┘

如果“查订单明细”必须先得到订单 ID,它就应当接在前一步之后,用 thenCompose 串联;强行并行只会增加空参数、重试和错误处理的复杂度。

CompletableFuture 实现了 CompletionStage,用于表达“一个阶段完成后触发下一个阶段”。其 API 与执行策略可参阅 Oracle 的 CompletableFuture 文档。

不显式传 Executor 的代价

下面的写法很短:

1
2
CompletableFuture<User> userFuture =
CompletableFuture.supplyAsync(() -> userClient.query(userId));

但 Java 8 中,不传 Executor 的异步方法默认使用 ForkJoinPool.commonPool()(在不满足最小并行度条件时有例外行为)。这是 JDK 已定义的执行策略,不应把它误认为是“业务专用线程池”。

公共线程池被其他模块共享。若任务内部包含 HTTP、数据库或文件 I/O 等阻塞操作,排队和阻塞可能互相影响,定位问题时也很难判断是哪类业务耗尽了容量。因此,面向服务端 I/O 聚合时,通常应显式传入按业务隔离的 Executor。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
ThreadPoolExecutor profileExecutor = new ThreadPoolExecutor(
8, // corePoolSize:示例值,需按压测和依赖能力调整
16, // maximumPoolSize
60, TimeUnit.SECONDS,
new ArrayBlockingQueue<Runnable>(200),
new ThreadFactory() {
private final AtomicInteger seq = new AtomicInteger(1);
@Override
public Thread newThread(Runnable r) {
return new Thread(r, "profile-io-" + seq.getAndIncrement());
}
},
new ThreadPoolExecutor.CallerRunsPolicy()
);

这里的线程数和队列长度只是示例,不是通用推荐值。它们至少应受下游连接池、第三方限流、机器资源和请求峰值约束。JDK 的 ThreadPoolExecutor 文档 明确了当任务无法接收时会由拒绝策略处理;选择 CallerRunsPolicy 会把任务交回提交线程执行,能形成一定背压,但也可能拉长接口响应时间,必须结合调用入口评估。

用 allOf 并行,用 thenCompose 表达依赖

下面是一个并行聚合的最小示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
CompletableFuture<User> userFuture = CompletableFuture.supplyAsync(
() -> userClient.query(userId), profileExecutor);

CompletableFuture<OrderSummary> orderFuture = CompletableFuture.supplyAsync(
() -> orderClient.summary(userId), profileExecutor);

CompletableFuture<Points> pointsFuture = CompletableFuture.supplyAsync(
() -> pointsClient.query(userId), profileExecutor);

CompletableFuture<PageDto> pageFuture = CompletableFuture
.allOf(userFuture, orderFuture, pointsFuture)
.thenApply(ignored -> new PageDto(
userFuture.join(),
orderFuture.join(),
pointsFuture.join()));

allOf 完成时,三个 future 都已正常完成;因此在其后的 thenApply 中读取 join() 不会再等待。这种写法的关键在于 join 只出现在“已经完成”的位置。不要在任务刚创建后立即 join,否则调用线程仍会同步阻塞,失去并行意义。

若后一步依赖前一步的结果,应使用 thenCompose,避免返回嵌套的 CompletableFuture<CompletableFuture<T>>:

1
2
3
CompletableFuture<OrderDetail> detailFuture =
orderFuture.thenCompose(order -> CompletableFuture.supplyAsync(
() -> orderClient.detail(order.getId()), profileExecutor));

超时与异常必须成为业务设计的一部分

Java 8 的 CompletableFuture 没有后续版本提供的 orTimeout。如果需要超时边界,可以把超时任务放在单独的 ScheduledExecutorService 中,使目标 future 异常完成:

1
2
3
4
5
6
7
8
9
10
11
12
13
static <T> CompletableFuture<T> withTimeout(
CompletableFuture<T> future,
long timeout,
TimeUnit unit,
ScheduledExecutorService scheduler) {

final ScheduledFuture<?> timeoutTask = scheduler.schedule(
() -> future.completeExceptionally(
new TimeoutException("remote call timed out")),
timeout, unit);

return future.whenComplete((result, error) -> timeoutTask.cancel(false));
}

这是应用层的等待边界,不会自动中断已经开始的底层 HTTP 请求。实际项目仍应在 HTTP 客户端、数据库驱动或 RPC 框架中配置连接、读取和总调用超时;否则线程可能仍被下游阻塞占用。

对于可降级的数据,可在单个 future 上处理异常:

1
2
3
CompletableFuture<Points> pointsFuture = CompletableFuture
.supplyAsync(() -> pointsClient.query(userId), profileExecutor)
.exceptionally(error -> Points.empty());

对于不可缺少的数据,则应让异常向上传播,并在接口边界统一转换为可观测的错误响应。不要把所有异常都 exceptionally 成空对象,否则调用方无法区分“确实没有数据”和“下游调用失败”。

常见误区

  • 把 thenApplyAsync 当成必需品:带 Async 仅表示后续动作会异步调度;如果不传 Executor,仍会使用默认执行策略。轻量级的内存转换往往可用 thenApply。
  • 在线程池任务里再等待同一线程池的 future:当池容量小、任务互相等待时可能造成线程饥饿。保持调用链非阻塞,或将不同资源类型隔离到不同 Executor。
  • 只设置线程池,不设置下游超时:队列可以满,线程也可以满;没有下游超时,释放资源的时机不可控。
  • 忽略取消语义:CompletableFuture.cancel 会以取消异常的方式完成 future,但不等价于一定终止底层 I/O。需要由具体客户端确认取消和中断行为。

以可观测性收尾

线程池应至少暴露活跃线程数、队列长度、已完成任务数、拒绝次数与任务耗时分布;异步调用还应传递请求关联 ID,方便把一次页面聚合中的多个下游调用关联起来。JDK 的线程池提供了部分基础统计,但业务指标与告警阈值仍需由应用补齐。

CompletableFuture 的价值不在于把每段代码都改成异步,而在于清晰表达依赖关系和资源边界:并行执行独立步骤,显式指定执行器,在每个下游调用处配置超时与异常策略。这样才能在吞吐、延迟和故障可控之间取得可验证的平衡。