CompletableFuture · supplyAsync · thenApply / thenCompose / thenCombine · allOf · exceptionally / handle · CompletionException · orTimeout · completeOnTimeout · ForkJoinPool.commonPool · Completion 栈

CompletableFuture:把“做完以后干什么”挂在结果上

Future 只能 get() 干等。CompletableFuture 允许你提前把“结果出来以后做什么”挂上去,多个异步任务就能并行、合并、兜底,请求线程不用陪着等。它的精华和坑都在一句话里:回调由谁执行,取决于你调哪个方法、前一步是不是已经完成。实测 三条通知并行 320 ms,串行 603 ms;在 2 核机器上不传线程池,每个任务都新建一个线程;超时兜底拿到了默认值,卡住的那个任务却还在跑。

全景:一个下单请求在线程池里 · 第 10 站地图:J02 · CompletableFuture 与异步编排(第 8 个知识点)前置:线程池下一圈 · ThreadLocal 与内存泄漏实验:JDK 17.0.20,2 核 ARM 实测

①为什么需要:通知不该拖慢下单,三条通知也不该排队

在全景第 10 站,这一步要解决的是:订单 1024 抢到了,要发短信(300 ms)、站内信(100 ms)、加积分(200 ms)。下单接口不想等它们;三条通知之间也互不依赖,没必要一条发完再发下一条;全部发完要汇总记一笔,其中一条失败或卡住要兜底。

串行 短信 300 站内信 100 积分 200 603 ms(实测) 并行 + allOf 短信 300 · notify-1 站内信 积分 200 · notify-3 allOf汇总记一笔 320 ms(实测) 请求线程 exec-1 提交 → 立刻返回响应,不等任何一条通知(第 11 站) 0200400600 ms
横轴按比例画:并行后总耗时取决于最慢的那条(短信 300 ms);请求线程只花了“提交”的那一点时间。
需求地图上这一项的深度是“懂机制”:会用 CompletableFuture 把几件事并行、合并、兜底;说得清每个回调跑在哪个线程上、默认池是什么、异常和超时怎么处理;以及上一页 ThreadLocal 为什么带不过来。

②没有它会怎样:Future 只能干等

不懂这一点时的典型误用:把 supplyAsync 当成“开个线程跑一下”,不传线程池、不处理异常。实测 在 2 核机器上默认池会为每个任务新建一个线程;任务里抛的异常没人 join,就什么都看不到(⑤)。

③相邻方案:异步结果的几种拿法

做法拿结果组合说明
Future(JDK 5)阻塞 get不能上面的痛点
Guava ListenableFuture加监听器,必须给执行器Futures 工具类CompletableFuture 之前的事实标准
CompletableFuture(JDK 8)挂回调,或 jointhenCompose、thenCombine、allOf…JDK 自带,Spring 的 @Async 也能返回它
Reactor Mono / Flux订阅几百个操作符整条链都是响应式(WebFlux)时才划算
虚拟线程 + 同步写法(JDK 21)直接返回StructuredTaskScope(预览)阻塞变便宜了,“并行几件事”可以写成同步代码(第 9 个知识点)

选择规则:普通 Spring MVC 项目里“并行调几个下游、合并、兜底”,用 CompletableFuture 加自己的线程池;整条链都是响应式才用 Reactor;JDK 21 以上可以考虑虚拟线程的同步写法。本页学 CompletableFuture。

④原理:一个结果格子,一串等着它的回调

先知道这些
  • 线程池:任务交给池子里的线程执行,池子要按下游分、参数要算(线程池下一圈)。
  • CAS:比较并交换,失败就重试,不加锁(synchronized 与 CAS)。
  • ThreadLocal 挂在当前线程上,换了线程就是另一张表(上一页坑 4)。

④-1结构:result 和 stack 两个字段

CompletableFuture<String> sms volatile Object result null = 还没完成;值;AltResult(null 或异常) volatile Completion stack UniApply(thenApply) BiRelay(allOf) UniExceptionally 挂回调 = CAS 压栈 (Treiber 栈,不加锁) 某个线程 complete(v) CAS 把 v 写进 result,只有第一个成功 postComplete() 把栈里的回调一个个弹出来 tryFire 非 Async 的回调 就在这个线程上执行 Async 的回调 e.execute(...) 交给线程池 每个回调背后又是一个新的 CompletableFuture(thenApply 的返回值), 它完成时再弹它自己的栈:一条链就这样一节一节往下推。
按 JDK 17 源码:result 和 stack 都是 volatile,读写都靠 CAS,整个类里没有锁。回调要么在挂上去的那一刻发现结果已经有了、当场执行,要么等完成它的线程来执行。

④-2回调在哪个线程跑:先猜再看

调的方法
挂回调的那一刻,前一步
前一步是谁完成的
机器

先猜:fn 会在哪个线程上执行?

逻辑按 JDK 17 源码的 uniApplyStage(已完成就走 uniApplyNow)、postComplete、ASYNC_POOL 写成,在 node 里把所有组合跑过一遍;⑤ 的 WhichThread 在本机实测了其中六种。“还没完成”时有个竞态:挂回调的同时前一步刚好完成,回调可能由调用线程执行,结论是“说不准”,所以别依赖它。

④-3编排和异常:最常用的几个方法

想做的方法全景里的例子
起一个异步任务supplyAsync(supplier, pool)、runAsync发短信
拿到结果再变换thenApply(同步函数)把网关返回码翻成“成功 / 失败”
拿到结果再发起下一个异步调用thenCompose(函数返回 CompletableFuture,铺平,不会套成两层)先查用户手机号,再发短信
两个都好了合并thenCombine积分 + 等级算出权益
全部好了 / 任意一个好了allOf(返回 Void,结果要自己 join 各个)、anyOf三条通知都发完再汇总
出错时给个值exceptionally短信失败 → “记下来补发”
成功失败都处理handle(能改结果)、whenComplete(只看不改)记日志和指标
最多等多久(JDK 9)orTimeout(超时变异常)、completeOnTimeout(超时给默认值)短信网关卡住时最多等 500 ms

④-4默认池和超时:两个容易想错的地方

④-5为什么这样设计:谁完成,谁顺手执行

CompletableFuture:非 Async 就地执行另给一套 Async 方法让你选

  • 很短的回调(转换一下格式)不用再切一次线程,省掉排队和上下文切换
  • 无锁的回调栈:挂回调、完成、触发都是 CAS,没有锁竞争
  • “跑在哪”取决于时机,可能占到请求线程、定时器线程、别人的池子

被放弃的:回调永远异步JavaScript Promise 的规定

  • 行为可预测:then 里的函数永远不会在当前调用栈里执行
  • 每个回调都要排一次队;在 Java 里就是每一节都交给线程池,短回调的开销比它本身还大
  • 还得规定“交给哪个池”,JDK 也就无法提供一个不需要池子的版本

决定胜负的约束:链上大多数回调都很短,而 Java 的线程切换不便宜(线程页实测一次交接 3~4 µs)。所以 JDK 默认“就地执行”,把选择权交给你:回调慢、会阻塞,或者不能占用当前线程时,用 Async 并传自己的池。

API最少要会的几行(全景第 10 站)
CompletableFuture<String> sms = CompletableFuture
    .supplyAsync(() -> smsGateway.send(order), notifyPool)        // 一定传自己的池
    .completeOnTimeout("超时,进补发队列", 500, MILLISECONDS)       // JDK 9+
    .exceptionally(ex -> { retryLater(order); return "失败:" + ex.getCause(); });
CompletableFuture<String> inbox  = CompletableFuture.supplyAsync(() -> inbox.send(order), notifyPool);
CompletableFuture<String> points = CompletableFuture.supplyAsync(() -> points.add(order), notifyPool);

CompletableFuture.allOf(sms, inbox, points)
    .thenRun(() -> audit(order, sms.join(), inbox.join(), points.join()))   // 都完成了,join 不会等
    .whenComplete((v, ex) -> { if (ex != null) log.error("通知汇总失败", ex); });
// 请求线程到这里就返回响应了,不 join

ThreadLocal 怎么带、JDK 8 没有 orTimeout 怎么办、Spring 的 @Async、虚拟线程,放到第⑥步。

⑤亲眼看到:回调跑在哪,以及第 10 站的五个场景

两个程序,JDK 17 运行。orTimeout / completeOnTimeout 是 JDK 9 的 API,所以这个实验用 --release 11 编译,JDK 8 的写法见⑥第三圈。完整代码:study-labs/java/juc/completablefuture-orchestration →

实验 1:同一个 thenApply,跑在哪个线程

$ java -cp … WhichThread                      # 本机 2 核
CPU 核数 2,commonPool 并行度 1
1 supplyAsync 默认池              → Thread-0
2 thenApply,前一步还没完成        → Thread-1(完成前一步的线程)
3 thenApply,前一步已经完成        → main(调用 thenApply 的线程)
4 thenApplyAsync                   → Thread-2
5 thenApplyAsync(自己的池)         → notify-1
6 orTimeout 超时后的 exceptionally → CompletableFutureDelayScheduler(TimeoutException)

$ java -Djava.util.concurrent.ForkJoinPool.common.parallelism=4 … WhichThread
1 supplyAsync 默认池              → ForkJoinPool.commonPool-worker-1
4 thenApplyAsync                   → ForkJoinPool.commonPool-worker-1
(3、5、6 不变)

$ java … WhichThread block                  # 8 个阻塞 200 ms 的任务丢进默认池
并行度 1(2 核默认):204 ms,用了 8 个线程(每个任务 new 一个)
并行度 2:            806 ms,用了 2 个线程
并行度 4:            405 ms,用了 4 个线程

实验 2:第 10 站的通知,五个场景

$ java -cp … Notify
1 串行:短信✓ 站内信✓ 积分✓,603 ms
2 并行 allOf:短信✓ 站内信✓ 积分✓,320 ms
3 runAsync 里抛了异常,没人 join:上面什么都没打印
  加 exceptionally:短信✗ 记下来补发(CompletionException: 短信网关 503)
  join 抛的是 CompletionException,cause 才是 IllegalStateException
4 completeOnTimeout:第 503 ms 拿到 "短信⏳ 超时,进补发队列"
  …第 1501 ms:卡住的短信任务其实还在跑,现在才结束(短信✓)
5 主线程 USER=1024,异步线程 notify-4 里 USER=null
  包一层 withUser:异步线程 notify-2 里 USER=1024
  包装用完后同一个池里 USER=null(finally 清掉了,没串号)
到这里已经能交差了:你能用 supplyAsync 加自己的池把几个下游并行起来,用 allOf 汇总,用 exceptionally 和 completeOnTimeout 兜底,说得出每个回调跑在哪个线程。
已知还没处理的细节:默认池(⑥ 坑 1)、被吞的异常(⑥ 坑 2)、超时不取消(⑥ 坑 3)、回调占了不该占的线程(⑥ 坑 4)、上下文丢失(⑥ 坑 5)。

⑥细节·规范·优化

第一圈

注意事项:不知道就会出事故

坑 1不传线程池,用默认池做阻塞 IO← ④-4 默认池
现象
小机器上线程数暴涨(每个任务一个新线程);大机器上一个慢下游把 commonPool 占满,parallelStream、其他异步任务全跟着慢。⑤:并行度 2 时 8 个任务 806 ms
根源
commonPool 是全 JVM 共享的、按 CPU 核数配的计算池,不是给阻塞 IO 用的;并行度 ≤ 1 时干脆不是池。
做法
每个 xxxAsync 都传自己的池,按下游分池、按流量算参数(线程池下一圈 ④-1)。
坑 2异常被吞← ④-3 异常沿链往下跳
现象
通知没发出去,日志里什么都没有。⑤ 场景 3:什么都没打印
根源
异常被存进 result,等着有人 join 或者有 exceptionally / handle 来取;没人取就一直留在那里,最后跟着 future 一起被回收。
做法
每条“发出去不管”的链,末尾都加 whenComplete 或 exceptionally 记日志和指标;在 exceptionally 里看 getCause()。
坑 3以为超时会停掉任务← ④-4 超时不是取消
现象
接口按时返回了,但 notify 池的线程越来越少、排队越来越长;补发以后用户收到两条短信。⑤ 场景 4:第 1501 ms 才结束
根源
orTimeout / completeOnTimeout 只改 future 的状态;cancel(true) 也不中断线程。
做法
真正的超时设在下游客户端上(HTTP 客户端的读超时、JDBC 的 queryTimeout);future 的超时只用来兜住响应。补发要幂等,或者先查状态。
坑 4非 Async 回调占了不该占的线程← ④-2 谁完成谁执行
现象
超时一多,所有超时都晚到;或者请求线程 complete(v) 之后,响应被挂在后面的慢回调拖住。⑤ 实验 1 第 6 行:CompletableFutureDelayScheduler
根源
非 Async 的回调由完成前一步的线程执行,可能是定时器线程、请求线程、Netty 的 IO 线程。
做法
回调里要做慢事(IO、加锁、大计算)就用 thenXxxAsync(fn, pool);非 Async 只留给几微秒的转换。
坑 5ThreadLocal、MDC 带不过去← 上一页 坑 4
现象
异步里的日志没有 traceId,UserContext.get() 是 null。⑤ 场景 5:notify-4 里 USER=null
根源
ThreadLocal 挂在提交任务的线程上,执行任务的是另一个线程。
做法
包装 Supplier / Runnable:提交时抓取,执行前放入,finally 恢复;或者给池子统一套装饰器(Spring 的 TaskDecorator、阿里的 TTL)。
第二圈

使用规范

每个 Async 都传池

代码评审时看到不带第二个参数的 supplyAsync、runAsync、thenXxxAsync 就打回。

坑 1。

每条链都有结尾

要么有人 join(带超时),要么末尾有 whenComplete / exceptionally 记日志。

坑 2。

每个下游调用有两层超时

客户端超时真正停掉调用,completeOnTimeout 兜住响应时间。

坑 3。

请求线程里不无限期 join

要等结果就 get(超时) 或 orTimeout 后再 join;不需要结果就别等。

异步又变回同步,还可能永远卡住一个 Tomcat 线程。

第三圈

深处:版本差异、Spring、虚拟线程

JDK 8 / 17 / 21

8 没有 orTimeout、completeOnTimeout、delayedExecutor、copy,这些是 JDK 9 加的。JDK 8 上要超时,就自己起一个 ScheduledExecutorService,到点 completeExceptionally(new TimeoutException()),或者 applyToEither 一个定时完成的 future。17 和 9 一样。JDK 19 起 Future 接口多了 state()、resultNow()、exceptionNow(),21 里也有。commonPool 的“并行度 ≤ 1 就每任务一个线程”在这几个版本里都一样。

Spring 里

@Async 方法可以直接返回 CompletableFuture,任务进的是 Spring 的执行器(@Async 页),不是 commonPool,配了 TaskDecorator 就能带 MDC。Spring MVC 的 Controller 也能返回 CompletableFuture:请求线程先还给 Tomcat,结果出来后再写响应(Servlet 异步)。

虚拟线程:编排可以写回同步代码

CompletableFuture 存在的一大原因是“线程很贵,不能阻塞着等”。JDK 21 的虚拟线程让阻塞变便宜,“并行发三条通知”可以在虚拟线程里写成三个普通调用,用 StructuredTaskScope(JDK 21 预览,JEP 453)统一等待、统一取消:一个失败,另外两个会被真正中断,这正好补上坑 3。第 9 个知识点细讲。

面试官会怎么问

thenApply 和 thenApplyAsync 有什么区别?

thenApplyAsync 交给线程池(不传就是默认池);thenApply 在前一步已完成时由调用线程执行,未完成时由完成前一步的线程执行(④-2)。

CompletableFuture 默认用什么线程池?有什么问题?

ForkJoinPool.commonPool(),并行度核数 − 1,全 JVM 共享,适合计算不适合阻塞 IO;并行度 ≤ 1 时每个任务新建线程。所以要传自己的池(④-4、坑 1)。

thenCompose 和 thenCombine 的区别?

thenCompose 是先后依赖:拿前一个的结果发起下一个异步调用,结果铺平;thenCombine 是两个独立的 future 都完成后合并(④-3)。

异常怎么处理?join 和 get 有什么区别?

exceptionally 兜底、handle 成功失败都处理、whenComplete 只观察;异常会被包成 CompletionException。join 抛非受检的 CompletionException,get 抛 ExecutionException 并且能带超时(④-3)。

下一圈

↺回到⑤:改造你的案例

1. 加一个依赖:先查手机号再发短信

在 Notify 里加一个 100 ms 的 queryPhone,用 thenCompose 接上 300 ms 的短信,和另外两条一起 allOf。先算总耗时,再跑。

用到:④-3

2. 超时以后真的停下来

把场景 4 的“卡住 1500 ms”改成可中断的 sleep,超时后对执行它的线程发中断(提示:自己拿到 Future 再 cancel(true),或者用 pool.submit),看它是不是在 500 ms 附近结束。

用到:④-4、坑 3

3. 给池子统一套装饰器

写一个 Executor 包装器:execute(r) 时把 USER 和 MDC 抓下来包进 r。把 POOL 换成它,去掉 withUser,场景 5 还能拿到 1024 吗?

用到:坑 5、上一页

自测

1. 用一句话说出这一页的精华。

CompletableFuture 是一个能挂回调、能组合的结果格子;回调由谁执行取决于是不是 Async 和前一步完成的时机,所以要传自己的池、每条链要有结尾、超时只兜响应不停任务。

2. 只用 Future 做第 10 站,疼在哪?

只能阻塞 get,没法说“完了以后做什么”,组合、兜底、超时都要手写,在请求线程里 get 就又变成了同步(②)。

3. 2 核机器上 supplyAsync 不传池,为什么每次都是新线程?

④-4:commonPool 并行度 = 核数 − 1 = 1,CompletableFuture 判断并行度 ≤ 1 时用 ThreadPerTaskExecutor,每个任务 new Thread。

4. 预测:CompletableFuture.supplyAsync(() -> 1, pool).thenApply(x -> slowIo(x)),slowIo 跑在哪?如果 supplyAsync 的任务非常快,在 thenApply 被调用之前就完成了呢?

一般跑在 pool 的线程上(由完成前一步的线程执行);如果前一步在调用 thenApply 之前就完成了,就在调用者线程(比如请求线程)上执行,slowIo 会卡住请求。所以慢操作要用 thenApplyAsync(fn, pool)(④-2)。

5. 预测:给短信加了 orTimeout(500 ms),后面接 thenApply(x -> 写库 200 ms)。短信超时时,写库在哪个线程?同一时刻还有 100 个 future 也该超时了,会怎样?

超时后链是异常完成的,thenApply 不执行,直接跳到后面的 exceptionally;如果 exceptionally 里写库,它跑在 CompletableFutureDelayScheduler 这个单线程上,100 个 × 200 ms,后面的超时要晚到 20 秒(④-2、④-4、坑 4)。

6. 放回全景:第 10 站用默认池发通知,大促时短信网关变慢。第 2 站的请求线程、第 10 站的通知、同一个 JVM 里的 parallelStream 各会怎样?

在 8 核机器上,commonPool 的 7 个线程被慢短信占满:通知排队、parallelStream 一起变慢;请求线程只提交不等,暂时不受影响,但如果有人在请求线程里 join,第 2 站的 Tomcat 线程会被一个个卡住,最后整个接口超时。在 2 核机器上则是线程数一路涨。换成按下游分的 notify 池加拒绝策略(线程池下一圈)。