Future 的问题是它只能"取",不能"然后"。想串三个异步调用,要么嵌套回调,要么阻塞 get() 把线程耗在等待上。CompletableFuture 把"等结果"改成"声明依赖":结果到了自动触发下一步,线程不必占着不动。
但也正因为 API 表面太顺,用错的地方极难发现——submit 的任务没 join、异常被吃在 Future 里、allOf 明明有子任务失败却返回成功。这篇按"编排语义 → 线程模型 → 异常与超时 → 死锁与收集"拆开。
1. 三类方法的返回形态
| 方法 | 签名形态 | 什么时候用 | 典型错用 |
|---|---|---|---|
thenApply |
T → U,返回 CF<U> |
结果转换,纯内存计算 | 里面做远程调用 |
thenCompose |
T → CF<U>,返回 CF<U> |
下一步本身是异步的 | 用 thenApply 套出 CF<CF<U>> |
thenCombine |
(T, CF<U>) → V |
两个独立结果汇合 | 用 thenCompose 硬拼 |
CompletableFuture<Order> order = loadAsync(id);
// 转换:值 → 值
CompletableFuture<String> code = order.thenApply(Order::getCode);
// 组合:值 → 另一个 Future(自动展平,不会出现 CF<CF<...>>)
CompletableFuture<Receipt> receipt = order.thenCompose(o -> payAsync(o.getCode()));
// 汇合:两个独立来源
CompletableFuture<Summary> sum =
order.thenCombine(loadUserAsync(id), (o, u) -> new Summary(o, u));
判断规则很简单:下一步的返回值本身是 CompletableFuture,就用 thenCompose;否则 thenApply。
Async 后缀是一组独立的陷阱:
| 形态 | 执行线程 | 后果 |
|---|---|---|
thenApply |
完成上游的那个线程(可能是主线程、可能是池线程) | 快,但把工作压在别人的线程上 |
thenApplyAsync |
默认 ForkJoinPool.commonPool() |
与业务池抢线程 |
thenApplyAsync(fn, pool) |
指定池 | 生产必须显式指定 |
不带 Async 的方法会在"上游完成时所在的线程"里同步执行。如果上游是 main 里 join() 完成的,那你的"异步"任务其实跑在主线程上;如果是 IO 线程完成的,长时间计算就阻塞了 IO 线程。
2. 默认线程池:commonPool 不是你的池
ForkJoinPool.commonPool() 的并行度默认是 Runtime.getRuntime().availableProcessors() - 1,且整个 JVM 共享。用它跑阻塞任务(DB、HTTP)会带来两个问题:
- 并行度按 CPU 核数算,但阻塞任务是在等 IO,线程数远远不够;
- 共享意味着某个模块的慢任务会拖慢无关模块。
// 默认:与 parallelStream、其他 CompletableFuture 共用 commonPool
CompletableFuture.supplyAsync(() -> queryDb(id));
// 正确:独立业务池
ExecutorService ioPool = new ThreadPoolExecutor(
16, 32, 60L, TimeUnit.SECONDS, new ArrayBlockingQueue<>(500));
CompletableFuture.supplyAsync(() -> queryDb(id), ioPool);
一条硬性纪律:CompletableFuture 的所有 Async 版本,都显式传池。不传就是在赌 commonPool 里没人排队。
3. 异常处理:三个方法,三种语义
| 方法 | 入参 | 返回 | 异常是否继续传播 |
|---|---|---|---|
exceptionally |
仅 Throwable |
兜底值 | 否,链路降级为正常 |
handle |
(结果, 异常) |
任意值 | 否,可同时处理成败 |
whenComplete |
(结果, 异常) |
原样的结果/异常 | 是,除非在里面再抛 |
CompletableFuture<String> f = loadAsync(id)
.exceptionally(ex -> "fallback") // 吃掉异常,返回兜底值
.thenApply(String::trim);
CompletableFuture<String> g = loadAsync(id)
.whenComplete((v, ex) -> log.info("done, ex={}", ex)) // 只观察
.thenApply(String::trim); // ex 非空时这里不会执行,异常继续传
whenComplete 是最容易被误用的:它看着像 finally,但不改变结果。要"记录日志并降级",必须用 handle:
.handle((v, ex) -> {
if (ex != null) { log.warn("load failed", ex); return "fallback"; }
return v;
});
还有一个盲区:thenApply 里抛出的异常会被包成 CompletionException 塞进下游的 Future。如果最后没人 join,这个异常不会打印到任何地方。
4. allOf / anyOf 与"不会快速失败"
| 方法 | 语义 | 返回值 | 陷阱 |
|---|---|---|---|
allOf |
全部完成 | CF<Void>,拿不到结果 |
不快速失败;失败也不告诉你是谁 |
anyOf |
任一完成 | CF<Object>,不知道是谁 |
拿到的可能是失败的那个 |
allOf 最重要的性质是:它不是快速失败的。某个子任务一失败,它不会立刻返回,而是等全部结束才汇总;汇总时若有失败,它自己也以 CompletionException 异常完成,但 cause 只带其中一个——你无从知道是哪几个失败。
CompletableFuture<Void> all = CompletableFuture.allOf(a, b, c);
try {
all.join(); // 全部结束后才返回;有失败则抛 CompletionException
} catch (CompletionException ex) {
// 只有 cause,信息量不足以定位是 a、b 还是 c
}
// 正确姿势:用 handle(无论整体成败都会执行),再逐个兜底后取值
CompletableFuture<List<Result>> results = all.handle((v, ex) ->
Stream.of(a, b, c).map(f -> f.exceptionally(e -> Result.failed(e)))
.map(CompletableFuture::join).toList());
anyOf 的返回类型是 Object,因为参与者的类型可能不同。要拿到具体类型得自己转,且第一个完成的可能是失败的那个——anyOf 不区分成功与异常,谁先完成返回谁。
5. 超时与取消
| 方式 | 行为 | 后续状态 |
|---|---|---|
orTimeout(t, unit) |
超时后以 TimeoutException 异常完成 |
上游任务继续跑 |
completeOnTimeout(v, t, unit) |
超时后用默认值完成 | 上游任务继续跑 |
cancel(true) |
标记取消 | 不中断正在执行的任务 |
CompletableFuture<Data> f = supplyAsync(() -> slowCall(), ioPool)
.orTimeout(800, TimeUnit.MILLISECONDS)
.exceptionally(ex -> Data.empty());
两个必须知道的点:
- 超时只影响
CompletableFuture的状态,不影响底层任务。任务还在跑,还在占线程、占连接。真正的资源清理要在任务内部自己做(设置 socket 超时、检查中断标志)。 cancel不中断执行中的线程。CompletableFuture.cancel只是把状态置为 cancelled,Thread.interrupt不会发出。想真正停下,任务本身必须响应中断,或者用Future.cancel(true)的语义自己实现。
6. 同一个池里提交依赖任务 = 死锁
这是最典型的自伤:外层任务占着线程,等内层任务;内层任务排在队列里等不到线程。
// 危险:外层占用 ioPool 的线程,内层排在同一队列后面
CompletableFuture<A> outer = CompletableFuture.supplyAsync(() -> {
CompletableFuture<B> inner = CompletableFuture.supplyAsync(this::loadB, ioPool);
return combine(loadA(), inner.join()); // 线程在这里阻塞
}, ioPool);
当 ioPool 的线程全被外层任务占满,inner 永远拿不到线程,join() 永不返回。解法有三:
| 解法 | 做法 | 代价 |
|---|---|---|
| 拆池 | 内层用另一个池 | 多一组线程 |
| 不阻塞 | 用 thenCompose 串联,全程不 join |
需要改写代码结构 |
| 分阶段 | 外层只做编排,实际计算提交到池 | 需要两段式设计 |
thenCompose 是根本解:它把"等"变成"依赖",线程在等待期间被释放,不会占着不走。
7. 结果收集的顺序与失败隔离
allOf 只保证"都完成",不保证顺序。要保持提交顺序,必须在集合上按下标取:
List<CompletableFuture<Item>> futures = ids.stream()
.map(id -> CompletableFuture.supplyAsync(() -> load(id), ioPool))
.toList();
CompletableFuture<List<Item>> ordered = CompletableFuture
.allOf(futures.toArray(CompletableFuture[]::new))
.thenApply(v -> futures.stream()
.map(f -> f.exceptionally(ex -> Item.empty())) // 单点失败不拖垮整体
.map(CompletableFuture::join)
.toList());
注意 futures.toArray(CompletableFuture[]::new):allOf 是可变参数,直接传 List 不编译;传 futures.toArray() 得到 Object[] 会 ClassCastException。这两处是踩过就忘不掉的地方。
8. 排错:六种"看起来正常"的失败
| 现象 | 根因 | 处理 |
|---|---|---|
| 方法返回了,但数据没写完 | 忘了 join,主线程直接退出 |
出口处统一 join 或用 allOf 汇合 |
| 日志里没有任何异常 | 异常在 Future 里没人取 |
whenComplete 打日志或出口 join |
allOf 抛异常但定不了位 |
cause 只带一个子任务的失败 | 逐个 join 并单独 exceptionally |
| RT 抖动、线程数打满 | thenApply 里做了阻塞 IO |
换 thenApplyAsync(fn, ioPool) |
| 偶发死锁 | 同一池内提交依赖任务 | 拆池或改 thenCompose |
| 超时后连接泄漏 | orTimeout 不取消底层任务 |
任务内部设 socket 超时 |
9. 边界:什么情况别用 CompletableFuture
| 场景 | 首选 | 理由 |
|---|---|---|
| 复杂分支与重试 | 响应式(Reactor/RxJava) | 有操作符与背压 |
| 需要结构化并发 | StructuredTaskScope(JDK 21+ 预览) |
作用域内自动取消 |
| 简单并行计算 | parallelStream / ForkJoinPool |
代码更短 |
| 请求级编排 | 虚拟线程 + 阻塞写法 | 可读性优先 |
| 跨服务长流程 | 工作流引擎 | 需要持久化与重放 |
CompletableFuture 的定位是单进程内的异步编排。一旦需要背压、重试、熔断、持久化,它的 API 就开始别扭;一旦需要跨进程,它根本不适用。
10. 自测
thenApply和thenCompose的选择依据是什么?用错会得到什么类型?- 不传池的
thenApplyAsync在哪个线程上执行?并行度由什么决定? whenComplete与handle在处理异常上的本质差异是什么?allOf在某个子任务失败后会不会立即返回?怎么发现失败?orTimeout到点后,底层任务是否停止?资源怎么清理?- 同一个池里提交依赖任务为什么会死锁?
thenCompose为什么能避免?