CompletableFuture 如何编排异步任务?常见陷阱有哪些?
简化版
CompletableFuture 用 CompletionStage 描述异步任务的依赖关系:转换结果用 thenApply,依赖另一个异步任务用 thenCompose,合并独立任务用 thenCombine。无后缀阶段不保证切换线程,Async 方法未指定 Executor 时通常使用 ForkJoinPool.commonPool(),生产代码应按任务类型显式治理线程池、异常、超时和取消。
详细版
runAsync 执行无返回值任务,supplyAsync 产生结果。串行转换用 thenApply;回调返回 CompletionStage 时用 thenCompose 展平嵌套;两个互不依赖的结果用 thenCombine,等待一组任务可用 allOf,但 allOf 自身不收集各任务结果。
exceptionally 只在异常时提供替代值,handle 无论成功失败都把结果转换成新值,whenComplete 更适合观察结果或记录日志而不改变正常结果。join() 把失败包装为未检查的 CompletionException,get() 则声明 InterruptedException 和 ExecutionException。
完整版教学
一、先画出依赖关系
假设用户和商品可以并行查询,而推荐结果依赖两者,可以这样表达:
CompletableFuture<User> userFuture =
CompletableFuture.supplyAsync(() -> loadUser(id), ioExecutor);
CompletableFuture<List<Product>> productFuture =
CompletableFuture.supplyAsync(this::loadProducts, ioExecutor);
CompletableFuture<Result> resultFuture = userFuture.thenCombine(
productFuture,
this::recommend
);
先分清任务是串行依赖还是彼此独立,比机械记忆 API 更重要。错误地先 join() 第一个任务再创建第二个任务,会把本可并行的调用重新串行化。
二、thenApply 与 thenCompose
thenApply 接收上一步结果并同步计算普通值,类似 Optional 的 map。如果下一步本身返回 CompletableFuture,使用 thenApply 会产生嵌套:
CompletableFuture<CompletableFuture<Order>> nested =
userFuture.thenApply(user -> loadOrderAsync(user.id()));
CompletableFuture<Order> flat =
userFuture.thenCompose(user -> loadOrderAsync(user.id()));
thenCompose 类似 flatMap,表达“下一阶段依赖上一阶段,并且下一阶段本身也是异步的”。
三、线程到底在哪里执行
不带 Async 的依赖阶段,可能由完成前一阶段的线程执行;如果前一阶段已经完成,也可能由注册阶段的调用线程执行,因此不能假定固定线程。带 Async 但不传 Executor 的方法,默认异步执行设施通常是 ForkJoinPool.commonPool()。
阻塞数据库或 HTTP 调用直接占用 common pool,可能拖慢同进程中无关的异步任务和并行流。应根据 CPU 计算、阻塞 I/O 和下游容量选择并显式传入 Executor,同时配置队列、拒绝策略、监控和上下文传递。线程池越大也不等于下游承载力越高。
四、异常处理方法的区别
CompletableFuture<User> safe = userFuture
.whenComplete((user, error) -> metrics.record(error))
.exceptionally(error -> fallbackUser());
whenComplete 能同时观察结果与异常,正常情况下保留原结果;如果回调本身抛异常,最终异常规则还需谨慎处理。exceptionally 只在上游异常完成时恢复为同类型结果。handle 总会执行并返回一个新结果,适合把成功和失败统一映射为业务响应。
异常往往包在 CompletionException 或 ExecutionException 中,记录和分类时要检查 cause。不要在链尾完全忽略返回的 Future,否则异常可能长期无人观察。
五、超时、取消与 allOf
现代 JDK 可用 orTimeout 让阶段超时异常完成,或用 completeOnTimeout 提供超时默认值。但让 Future 超时完成,不代表底层网络请求一定被物理中止;仍需给 HTTP、数据库等客户端设置自己的连接和读取超时。
cancel 也不会自动形成完整的结构化取消传播,组合链中的底层任务和兄弟任务是否停止需要单独设计。allOf(f1, f2) 只返回 CompletableFuture<Void>,成功后仍要从各 Future 获取结果;只要其中一个异常,组合结果也会异常完成,但其他任务不因此自动取消。
六、避免用异步外壳包住同步阻塞
调用 join() 会阻塞当前线程。如果业务每一步创建 Future 后立刻 join(),就没有获得异步组合的并发价值,只增加了包装和排错成本。应尽量把 Future 一直组合到系统边界,再由真正需要同步结果的位置等待。
对于大量采用顺序阻塞风格的 I/O 任务,Java 21 虚拟线程可能比复杂 CompletableFuture 链更易读;CompletableFuture 仍适合表达明确的数据流依赖和非阻塞 API 组合,两者应按代码模型选择。
七、用数字分析并行收益与线程池压力
假设查询用户和订单各需 200 ms,且二者互不依赖。先查用户再查订单的理论等待约 400 ms;同时启动后再 thenCombine,理想关键路径约为 max(200, 200) = 200 ms,再加调度和合并开销。若订单必须依赖用户 ID,就不能为了追求数字而伪造并行,应使用 thenCompose 表达真实依赖。
串行:user 200 ms → order 200 ms → combine,约 400 ms
并行:user 200 ms ┐
├→ combine,约 200 ms + 开销
order 200 ms┘
如果下游数据库连接池只有 50 个连接,却给 I/O Executor 配 500 个线程,同时到来的 500 个任务仍只有约 50 个能真正查询,其余线程只是在等待连接。Future 编排不能替代容量控制,线程池、队列、客户端超时和下游并发上限必须共同设计。
| 关系 | 合适方法 | 返回形态 | 是否天然并行 |
|---|---|---|---|
| 普通值转换 | thenApply | U | 否 |
| 下一步返回 Future | thenCompose | 展平为 Future<U> | 依赖上一步 |
| 两个独立结果汇合 | thenCombine | 合并值 | 前提是先独立启动 |
| 等待一组完成 | allOf | Future<Void> | 不负责收集结果 |
记忆钩子:先画依赖图,再选 API;先定义资源上限,再选 Executor。API 名字不能把串行依赖变成并行,也不能把 50 个数据库连接变成 500 个。
八、常见误区与追问
- 误区:所有
thenXxx回调都会自动切换到新线程。 无 Async 后缀的阶段可能由完成上游的线程或其他完成调用者执行,不能假定固定线程。 - 误区:带 Async 就一定使用业务专属线程池。 未传 Executor 时通常使用 common pool,可能与并行流和其他异步任务互相影响。
- 误区:
orTimeout会自动中止底层 HTTP 或数据库请求。 它让 CompletableFuture 超时异常完成,底层操作仍需客户端超时与取消机制。 - 误区:
cancel(true)一定会中断正在执行的底层任务。 CompletableFuture 不直接控制底层计算,取消传播和资源清理必须按任务实现设计。 - 追问:
join()与get()的异常有什么区别? join 抛未检查的CompletionException,get 声明InterruptedException和ExecutionException。 - 追问:
allOf如何收集结果? 它只表示全部完成,成功后仍需从各个 future 读取结果,并决定任一失败时的聚合策略。 - 追问:何时选虚拟线程而不是 CompletableFuture? 大量顺序阻塞 I/O 更看重可读性时可评估虚拟线程;明确的数据流依赖和已有异步 API 仍适合 CompletionStage。
九、加强记忆
普通值转换用 thenApply,异步依赖展平用 thenCompose,独立结果汇合用 thenCombine。牢记无后缀方法不承诺线程,未指定 Executor 的 Async 通常走 common pool;异常恢复、超时完成和 Future 取消也不等于底层任务自动停止。