2368 字
约 7 分钟
19
S05-X23 · CompletableFuture 的编排与陷阱

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)会带来两个问题:

  1. 并行度按 CPU 核数算,但阻塞任务是在等 IO,线程数远远不够;
  2. 共享意味着某个模块的慢任务会拖慢无关模块。
// 默认:与 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());

两个必须知道的点:

  1. 超时只影响 CompletableFuture 的状态,不影响底层任务。任务还在跑,还在占线程、占连接。真正的资源清理要在任务内部自己做(设置 socket 超时、检查中断标志)。
  2. 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. 自测

  1. thenApply 和 thenCompose 的选择依据是什么?用错会得到什么类型?
  2. 不传池的 thenApplyAsync 在哪个线程上执行?并行度由什么决定?
  3. whenComplete 与 handle 在处理异常上的本质差异是什么?
  4. allOf 在某个子任务失败后会不会立即返回?怎么发现失败?
  5. orTimeout 到点后,底层任务是否停止?资源怎么清理?
  6. 同一个池里提交依赖任务为什么会死锁?thenCompose 为什么能避免?
S05-X23 · CompletableFuture 的编排与陷阱
https://www.clxhxhhr.top/posts/5928/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。