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)。下单接口不想等它们;三条通知之间也互不依赖,没必要一条发完再发下一条;全部发完要汇总记一笔,其中一条失败或卡住要兜底。
②没有它会怎样:Future 只能干等
- 线程池 + Future:
pool.submit()能并行,但拿结果只能future.get(),在请求线程里一等就是 300 ms,异步又变回了同步。 - 没法说“完了以后做什么”:想在三条都发完后汇总,只能一个个 get;想在失败时补发,只能 try/catch
ExecutionException;想“最多等 500 ms”,只能每个 get 都带超时。 - 自己写回调:任务里调 listener,listener 里再提交下一个任务,几层以后就是回调地狱,异常也不知道该往哪抛。
- 缺的那一块:一个结果格子,可以提前挂上“结果出来以后做什么”,还能把几个格子组合起来。这就是 CompletableFuture(JDK 8)。
不懂这一点时的典型误用:把 supplyAsync 当成“开个线程跑一下”,不传线程池、不处理异常。实测 在 2 核机器上默认池会为每个任务新建一个线程;任务里抛的异常没人 join,就什么都看不到(⑤)。
③相邻方案:异步结果的几种拿法
| 做法 | 拿结果 | 组合 | 说明 |
|---|---|---|---|
| Future(JDK 5) | 阻塞 get | 不能 | 上面的痛点 |
| Guava ListenableFuture | 加监听器,必须给执行器 | Futures 工具类 | CompletableFuture 之前的事实标准 |
| CompletableFuture(JDK 8) | 挂回调,或 join | thenCompose、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 两个字段
result 和 stack 都是 volatile,读写都靠 CAS,整个类里没有锁。回调要么在挂上去的那一刻发现结果已经有了、当场执行,要么等完成它的线程来执行。④-2回调在哪个线程跑:先猜再看
- 1
thenApplyAsync(fn, pool):一定交给 pool。 - 2
thenApplyAsync(fn):交给默认池(④-4)。 - 3
thenApply(fn):前一步已经完成,调用 thenApply 的线程当场执行;还没完成,挂到栈上,由完成前一步的那个线程执行。所以同一行代码,跑在哪个线程上取决于时机。
先猜: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 |
- 1异常会被包一层:任务里抛的
IllegalStateException,往下传的时候包成CompletionException(源码encodeThrowable)。在exceptionally里要看ex.getCause()。 - 2join 和 get:
join()抛不需要声明的CompletionException,适合写在 lambda 里;get()抛ExecutionException和InterruptedException,能带超时。 - 3异常沿链往下跳:前一步失败了,后面的 thenApply 都不执行,直到遇到 exceptionally / handle / whenComplete。链的末尾没有它们、又没人 join,异常就悄悄没了。
④-4默认池和超时:两个容易想错的地方
- 1默认池:不传池子时用
ForkJoinPool.commonPool(),并行度默认是“核数 − 1”。并行度不大于 1 时(比如 2 核机器),JDK 换成ThreadPerTaskExecutor:每个任务 new 一个线程,不复用、没有上限。全景第 10 站的Thread-552就是这么来的。commonPool 是整个 JVM 共用的,parallelStream 也用它。 - 2超时不是取消:
orTimeout/completeOnTimeout只是到点由一个叫CompletableFutureDelayScheduler的单线程定时器把 future 标成完成。原来那个任务还在跑,占着线程;cancel(true)也不会中断它(javadoc:参数 mayInterruptIfRunning 在这个类里不起作用)。
④-5为什么这样设计:谁完成,谁顺手执行
CompletableFuture:非 Async 就地执行另给一套 Async 方法让你选
- 很短的回调(转换一下格式)不用再切一次线程,省掉排队和上下文切换
- 无锁的回调栈:挂回调、完成、触发都是 CAS,没有锁竞争
- “跑在哪”取决于时机,可能占到请求线程、定时器线程、别人的池子
被放弃的:回调永远异步JavaScript Promise 的规定
- 行为可预测:then 里的函数永远不会在当前调用栈里执行
- 每个回调都要排一次队;在 Java 里就是每一节都交给线程池,短回调的开销比它本身还大
- 还得规定“交给哪个池”,JDK 也就无法提供一个不需要池子的版本
决定胜负的约束:链上大多数回调都很短,而 Java 的线程切换不便宜(线程页实测一次交接 3~4 µs)。所以 JDK 默认“就地执行”,把选择权交给你:回调慢、会阻塞,或者不能占用当前线程时,用 Async 并传自己的池。
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 个线程
- 12 核机器上默认池根本不是池:
Thread-0、Thread-1、Thread-2每次都是新线程。到了 8 核的生产机,就变成 7 个共享的 commonPool 线程。 - 2超时以后,非 Async 的回调跑在 JDK 内部那个单线程定时器上。在这里做慢操作,全 JVM 所有 future 的超时都会被拖晚。
- 3阻塞任务丢进 commonPool:线程数就是并行度,8 个 200 ms 的任务,2 个线程要 806 ms。生产上就是一个慢下游把整个 JVM 的 parallelStream 和异步任务都拖慢。
实验 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 清掉了,没串号)
- 1并行:总耗时约等于最慢的短信 300 ms,再加上线程调度的二十来毫秒。
- 2异常:exceptionally 拿到的 ex 是
CompletionException,真正的原因在getCause()里。 - 3超时:500 ms 拿到了兜底值,但短信任务跑满了 1500 ms,而且最后短信其实发出去了。所以补发前要先查状态,否则用户会收到两条。
- 4上下文:
withUser在提交时抓取 USER,执行前 set、finally 里恢复,就是上一页说的 TaskDecorator 思路。
⑥细节·规范·优化
注意事项:不知道就会出事故
- 现象
- 小机器上线程数暴涨(每个任务一个新线程);大机器上一个慢下游把 commonPool 占满,parallelStream、其他异步任务全跟着慢。⑤:并行度 2 时 8 个任务 806 ms
- 根源
- commonPool 是全 JVM 共享的、按 CPU 核数配的计算池,不是给阻塞 IO 用的;并行度 ≤ 1 时干脆不是池。
- 做法
- 每个
xxxAsync都传自己的池,按下游分池、按流量算参数(线程池下一圈 ④-1)。
- 现象
- 通知没发出去,日志里什么都没有。⑤ 场景 3:什么都没打印
- 根源
- 异常被存进 result,等着有人 join 或者有 exceptionally / handle 来取;没人取就一直留在那里,最后跟着 future 一起被回收。
- 做法
- 每条“发出去不管”的链,末尾都加
whenComplete或exceptionally记日志和指标;在 exceptionally 里看getCause()。
- 现象
- 接口按时返回了,但 notify 池的线程越来越少、排队越来越长;补发以后用户收到两条短信。⑤ 场景 4:第 1501 ms 才结束
- 根源
- orTimeout / completeOnTimeout 只改 future 的状态;cancel(true) 也不中断线程。
- 做法
- 真正的超时设在下游客户端上(HTTP 客户端的读超时、JDBC 的 queryTimeout);future 的超时只用来兜住响应。补发要幂等,或者先查状态。
- 现象
- 超时一多,所有超时都晚到;或者请求线程
complete(v)之后,响应被挂在后面的慢回调拖住。⑤ 实验 1 第 6 行:CompletableFutureDelayScheduler - 根源
- 非 Async 的回调由完成前一步的线程执行,可能是定时器线程、请求线程、Netty 的 IO 线程。
- 做法
- 回调里要做慢事(IO、加锁、大计算)就用
thenXxxAsync(fn, pool);非 Async 只留给几微秒的转换。
- 现象
- 异步里的日志没有 traceId,
UserContext.get()是 null。⑤ 场景 5:notify-4 里 USER=null - 根源
- ThreadLocal 挂在提交任务的线程上,执行任务的是另一个线程。
- 做法
- 包装 Supplier / Runnable:提交时抓取,执行前放入,finally 恢复;或者给池子统一套装饰器(Spring 的 TaskDecorator、阿里的 TTL)。
使用规范
代码评审时看到不带第二个参数的 supplyAsync、runAsync、thenXxxAsync 就打回。
坑 1。
要么有人 join(带超时),要么末尾有 whenComplete / exceptionally 记日志。
坑 2。
客户端超时真正停掉调用,completeOnTimeout 兜住响应时间。
坑 3。
要等结果就 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)。
- CompletableFuture 源码:
tryFire的 SYNC / ASYNC / NESTED 三种模式,postComplete 怎么避免长链把栈压爆 - StructuredTaskScope:虚拟线程下的“并行、统一取消”(第 9 个知识点)
- 异步调用链的 trace:traceId 怎么跨线程池、跨服务传下去(J13)
- ForkJoinPool 的工作窃取,和 ManagedBlocker 为什么能让 commonPool 在阻塞时临时多开线程
- 和 Reactor 对照:同一个“并行三个下游”在 Mono.zip 里怎么写,背压从哪来
↺回到⑤:改造你的案例
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 池加拒绝策略(线程池下一圈)。