从 Future 的阻塞说起
Future.get() 是阻塞的,想组合两个异步结果,只能一个接一个等,等于白异步。CompletableFuture 把回调、编排、异常处理和超时都补上了,写起来很顺,但它的坑也很集中:默认线程池、异常包装、allOf 的语义、超时不取消任务,每一个都能让线上出现“看起来并发了,实际上更慢”的现象。
默认线程池的坑
不传 Executor 的 supplyAsync、thenApplyAsync 会使用 ForkJoinPool.commonPool()。这个池的并行度默认是 CPU 核数减一,线程是守护线程,而且被整个 JVM 共享。在 2 核容器里并行度只有 1,几个阻塞调用就能把它占满;更糟的是,公共池里的任务如果阻塞等待另一个公共池任务,可能直接死锁。
结论很简单:业务里的异步任务一律传自己的线程池,并且线程池要有界、有名字、有拒绝策略。
public CompletableFuture<OrderVO> queryOrderAsync(Long orderId) {
return CompletableFuture
.supplyAsync(() -> orderClient.getById(orderId), bizExecutor)
.orTimeout(1500, TimeUnit.MILLISECONDS)
.exceptionally(throwable -> {
log.warn("查询订单失败, orderId={}", orderId, throwable);
return OrderVO.empty(orderId);
});
}
orTimeout 是 Java 9 加的方法,超时后以 TimeoutException 完成这个 future。但要注意:它只是把 future 标记为超时,不会中断底层任务。底层 HTTP 调用如果没配读超时,线程还会继续跑到天荒地老。所以 Feign/HTTP 客户端自己的超时仍然要配,orTimeout 只是最后一道闸。
异常:CompletionException 会包一层
supplyAsync 里抛出的异常,会被包成 CompletionException 传给下游。join() 抛 CompletionException,get() 抛 ExecutionException,两者都不会直接抛出原始异常。日志里看到 CompletionException: BizException,别以为多了一层业务异常,拆 cause 才是根因。
三个处理方法的语义要分清:
exceptionally:只在异常时执行,返回兜底值;handle:正常和异常都会执行,可以同时拿到结果和异常;whenComplete:正常和异常都会执行,但不改变结果,异常会继续往下传。
最常见的错误是 exceptionally 里返回 null,调用方拿到 null 继续往下走,最后在别处 NPE。兜底值要么是空对象,要么带明确的错误标记。
编排:allOf 不是万能的
allOf 返回 CompletableFuture<Void>,它在所有 future 完成后完成;只要有一个失败,它就异常完成,但其他任务不会停止。直接 join() 的后果是:一个子查询失败,整页数据都拿不到,其他已经查回来的结果也白费。
CompletableFuture<OrderVO> orderFuture =
CompletableFuture.supplyAsync(() -> orderClient.getById(orderId), bizExecutor);
CompletableFuture<List<ItemVO>> itemsFuture =
CompletableFuture.supplyAsync(() -> itemClient.listByOrder(orderId), bizExecutor);
CompletableFuture<UserVO> userFuture = orderFuture.thenCompose(order ->
CompletableFuture.supplyAsync(
() -> userClient.getById(order.getUserId()), bizExecutor));
return CompletableFuture.allOf(orderFuture, itemsFuture, userFuture)
.handle((ignored, throwable) -> {
if (throwable != null) {
log.warn("订单详情部分查询失败, orderId={}", orderId, throwable);
}
return new OrderDetailVO(
safeGet(orderFuture, null),
safeGet(itemsFuture, List.of()),
safeGet(userFuture, null));
})
.join();
private static <T> T safeGet(CompletableFuture<T> future, T defaultValue) {
return future.isCompletedExceptionally() ? defaultValue : future.getNow(defaultValue);
}
这里 thenCompose 用来把“先查订单、再用订单里的用户 ID 查用户”串起来。如果误用 thenApply,拿到的是 CompletableFuture<CompletableFuture<UserVO>>,类型对不上,还得多一层 join。
safeGet 不能省。getNow 在 future 异常完成时会抛 CompletionException,不是返回默认值,很多人栽在这个反直觉的行为上。
常见坑
坑一:在循环里 future.get()。 提交了 10 个任务,然后在循环里逐个 get,总耗时等于最慢的那个加上串行等待,并发等于没做。先 allOf 再统一取结果。
坑二:忘记 join,异常静默。 runAsync 提交后不等待,任务抛出的异常没有任何人处理,日志里干干净净,业务数据却少了。要么 join,要么在 future 上挂 exceptionally/whenComplete。
坑三:thenApply 里做阻塞调用。 回调默认在完成线程上执行,里面再发一个同步 HTTP 请求,会把线程池的线程占住,吞吐直线下降。阻塞逻辑要么异步化,要么显式切到 IO 线程池。
坑四:ThreadLocal 丢失。 异步线程拿不到 MDC 里的 traceId、请求头里的租户信息。用 TaskDecorator 或包装 Runnable,把上下文复制过去,执行完清理。
坑五:allOf 之后直接 join。 一个失败全盘失败,已经成功的结果也被丢弃。要么逐个 handle 收集,要么用 safeGet 保留部分结果。
坑六:以为 cancel 能停止任务。 CompletableFuture.cancel 对已经在执行的异步任务通常不会中断,底层调用该跑还是跑。要真正取消,得在任务内部检查中断标志,或者依赖 HTTP 客户端自己的超时。
排查步骤
jstack里搜commonPool-worker,如果大量线程卡在业务方法上,说明默认池被滥用。- 看业务线程池的活跃数和队列长度,确认是不是池太小或者任务里有阻塞。
- 看日志里的
CompletionException,拆cause找根因,别被包装层误导。 - 用 traceId 把异步前后的日志串起来,确认上下文有没有丢。
- 压测时对比“理论并发耗时”和实际耗时,差距大就检查是不是在某个
join上退化成串行了。
评论