CompletableFuture 是什么?怎么优雅地做异步编排?

2026年 阅读约 8 分钟 面试指南 · Java面试

深入解析CompletableFuture异步编排:Future的局限、链式调用与任务组合、thenApply与thenApplyAsync线程区别、异常处理、allOf聚合、commonPool陷阱与自定义线程池,分三层讲解。

一句话总结

CompletableFuture 是 Future 的增强:Future 只能 get() 阻塞等结果;CF 支持任务完成时自动触发回调、多任务链式编排与组合、异常沿链传播、超时控制。用法三原则:IO 密集必须传自定义线程池(默认 ForkJoinPool.commonPool 并行度只有 CPU核数-1)、区分 thenApply 与 thenApplyAsync 的执行线程、用 allOf + join 做并行聚合。

初级理解

Future 的痛点:submit 后只能不断 isDone() 轮询或 get() 死等,无法"完成后自动做某事",也无法组合多个任务——这就是 CompletableFuture 存在的意义。

// 创建异步任务 CompletableFuture<String> f1 = CompletableFuture.supplyAsync(() -> queryUser(id), bizPool); CompletableFuture<Void> f2 = CompletableFuture.runAsync(() -> sendNotify(), bizPool); // 无返回值 // 完成后回调(不阻塞主线程) f1.thenAccept(user -> render(user)); // 主线程需要最终结果时再 join(阻塞) User user = f1.join();
方法有入参有返回用途
thenApply✔✔转换结果(map)
thenAccept✔✘消费结果(forEach)
thenRun✘✘完成后执行动作
thenCompose✔✔(CF)串联下一个异步任务(flatMap)

中级深入

thenApply vs thenApplyAsync(追问重点):不带 Async 的版本"谁完成谁执行"——若任务已在调用方线程完成,回调就在调用方线程跑,否则在执行任务的线程跑;带 Async 的版本一定提交到线程池(不传池则用 commonPool)。回调里做重活必须 Async + 指定池,否则可能拖慢主线程或挤占公共池。

// 组合两个独立任务的结果 CompletableFuture<Order> o = supplyAsync(() -> queryOrder(id), pool); CompletableFuture<User> u = supplyAsync(() -> queryUser(oId), pool); o.thenCombine(u, (order, user) -> render(order, user)); // 串联依赖任务:thenCompose(第二个任务依赖第一个的结果) supplyAsync(() -> queryUser(id), pool) .thenCompose(user -> supplyAsync(() -> queryOrders(user), pool)) .thenAccept(orders -> log.info("{}", orders));

异常处理三件套:异常沿链条传播,直到被捕获为止(和同步代码 try-catch 一样)。

supplyAsync(() -> risky(), pool) .exceptionally(ex -> defaultValue) // 兜底返回默认值 .handle((v, ex) -> ex == null ? v : fallback) // 正常+异常都能处理,可改返回 .whenComplete((v, ex) -> log.info(...)); // 只观察不改变结果(类似 finally)
注意:whenComplete 不改变结果,exceptionally/handle 会——链上放错位置,后续拿到的值完全不同。

高级拓展

commonPool 陷阱(必须主动讲):ForkJoinPool.commonPool() 并行度 = CPU核数 - 1,全 JVM 共享,供 parallelStream 与未指定池的 CF 共用。把 IO 阻塞任务丢进去会饿死并行流和公共池的所有使用者。规范:CompletableFuture.supplyAsync(task, IO_POOL),IO 池线程数可开大些(如 2×CPU ~ 更高,按 RT 估算)。

并行聚合 + 超时(JDK9+):

List<CompletableFuture<Price>> fs = suppliers.stream() .map(s -> CompletableFuture.supplyAsync(() -> s.query(sku), pool) .orTimeout(200, TimeUnit.MILLISECONDS) // 超时异常完成 .completeOnTimeout(Price.EMPTY, 200, TimeUnit.MILLISECONDS)) // 超时给默认值 .toList(); CompletableFuture<List<Price>> all = CompletableFuture.allOf(fs.toArray(new CompletableFuture[0])) .thenApply(v -> fs.stream().map(CompletableFuture::join).toList());

与 Spring @Async / 线程池的关系:@Async 底层也是线程池 + Future/void,但没有编排能力;CF 适合"多下游依赖聚合"这类有拓扑关系的异步。另一个高频坑:ThreadLocal 上下文丢失——CF 回调可能跑在别的线程,MDC 的 traceId、登录态取不到,方案:TransmittableThreadLocal(阿里 TTL)、手动快照传递,或统一在链路上传参。

死锁陷阱:在 commonPool 的线程里调用另一个 CF 的 join(),且该 CF 依赖 commonPool 其他线程完成 → 池内线程全在等 → 死锁。规则:不要在池线程里 join 其他池任务,聚合放在最外层调用线程。

实战场景

场景一:详情页聚合三个下游(串行 900ms → 并行 320ms)

// 反例:串行,总 RT = 300+300+300 // 正例: CompletableFuture<Item> a = supplyAsync(() -> itemRpc.get(id), pool); CompletableFuture<Stock> b = supplyAsync(() -> stockRpc.get(id), pool); CompletableFuture<Promo> c = supplyAsync(() -> promoRpc.get(id), pool); return a.thenCombine(b, ItemStock::new) .thenCombine(c, Detail::new) // 总 RT ≈ 最慢的下游 300ms .orTimeout(500, MILLISECONDS) .exceptionally(ex -> degradeDetail(id)); // 任一下游挂了走降级

场景二:事务方法里用 CF 导致"看似回滚了其实没回滚"

// @Transactional 方法内把 DB 写丢给别的线程 —— 事务上下文(连接绑定 ThreadLocal)不跨线程 // 线程里的写走了"另一个连接、另一个事务",主事务回滚它不跟 // 正解:事务内只做同步写;CF 只用于事务外(发消息、刷缓存、通知)

场景三:回调里 MDC traceId 丢失,日志串不起来

// A 服务的 traceId 放在 ThreadLocal/MDC,CF 回调线程里为空 // 方案一:TransmittableThreadLocal + TtlExecutors 包装线程池 // 方案二:任务参数里显式带 ctx,回调里 MDC.put 还原(finally 里 remove)

面试模拟

Q:CompletableFuture 和 Future 的区别?

A:Future 是"未来结果的凭证",只有阻塞 get/轮询,任务完成无法触发后续动作,多任务编排无从谈起。CompletableFuture 实现了 Future + CompletionStage:① 回调式非阻塞消费;② 链式转换(thenApply/thenCompose)与组合(thenCombine/allOf/anyOf);③ 异常沿链传播与捕获;④ 支持手动完成(complete/completeExceptionally)与超时(orTimeout)。它把"异步任务"建模成可以像 Stream 一样编排的 Stage。

Q:为什么 CF 默认线程池不适合 IO 任务?

A:默认 ForkJoinPool.commonPool 按并行度 = CPU 核数-1 设计,面向CPU 密集的分解计算;IO 任务大部分时间在阻塞等响应,占着池线程不干活,很快把几个线程占满——既拖垮自己,还连累 parallelStream 等公共池使用者。所以 IO 场景必须显式传独立线程池,线程数按"并发数 × 平均耗时"估算,可比 CPU 密集池大得多。

Q:allOf 之后为什么要 join 每个子任务?

A:allOf 的返回类型是 CompletableFuture<Void>——它只表示"全部完成"这个事件,不汇总结果(Java 泛型无法按变长参数生成元组类型)。所以标准写法是 allOf(...).thenApply(v -> list.stream().map(CompletableFuture::join)...):此时 all 已完成,join 不会阻塞,只负责取值;任何一个子任务异常会在 join 时抛出(CompletionException 包裹)。