【多线程】CompletableFuture使用
CompletableFuture:别再拿凭证死等了,让异步任务自动接力
文章目录
- 【多线程】CompletableFuture使用
-
- 引言
- 一、场景故事:它在解决什么问题?
- 二、核心机制:从代码看原理
-
- [2.1 方法族谱:一张表记全](#2.1 方法族谱:一张表记全)
- [2.2 基本链式:回调接力、全程无阻塞](#2.2 基本链式:回调接力、全程无阻塞)
- [2.3 组合编排:双路合并与多路聚合](#2.3 组合编排:双路合并与多路聚合)
- [2.4 异常处理:沿链传播,链尾兜底](#2.4 异常处理:沿链传播,链尾兜底)
- 三、理解偏差:这些坑别踩
-
- [偏差 1:supplyAsync 提交了,任务就一定会执行完](#偏差 1:supplyAsync 提交了,任务就一定会执行完)
- [偏差 2:thenApply 和 thenApplyAsync 只是写法不同](#偏差 2:thenApply 和 thenApplyAsync 只是写法不同)
- [偏差 3:每个 then 环节都要 try/catch](#偏差 3:每个 then 环节都要 try/catch)
- [偏差 4:allOf 会把所有结果收集好交给我](#偏差 4:allOf 会把所有结果收集好交给我)
- 易混淆点:一组"长得像"的方法
- [四、记忆卡片:核心 QA 对](#四、记忆卡片:核心 QA 对)
- 五、知识网络:相似、互斥与学习路径
-
- [🔗 相似知识(同类不同派):Future(第 8 篇)](#🔗 相似知识(同类不同派):Future(第 8 篇))
- [⚔️ 互斥/替代知识(选型二选一):异步方案选型](#⚔️ 互斥/替代知识(选型二选一):异步方案选型)
- [📚 前置与后续(学习路径)](#📚 前置与后续(学习路径))
- 六、配套代码运行指南
- 结语

引言
上一篇的 Future 解决了"任务异步执行"的问题,但它本质上只是一张取货凭证 :想拿结果,只能守在 get() 上死等;想对结果做下一步处理,必须等亲手拿到结果之后;想把多个异步任务编排起来?Future 完全没有这个能力------一切协调都得靠主线程手工搭。
这三大局限,正好对应本文主角 CompletableFuture 的三大能力:
| 第 8 篇 Future 的局限 | CompletableFuture 的答案 |
|---|---|
get() 阻塞死等 |
回调接力:结果就绪的瞬间自动触发下一步,主线程全程零阻塞 |
| 拿到结果才能做下一步 | 链式转换 :thenApply / thenAccept / thenCompose 声明式搭出流水线 |
| 无法编排多个任务 | 组合编排 :thenCombine 双路合并、allOf / anyOf 多路聚合 |
从"一张凭证"到"一条流水线",这是 Future → CompletableFuture 的本质跃迁。本文讲透四件事:方法族谱 → 回调接力怎么跑 → 组合怎么接 → 异常怎么兜。
一、场景故事:它在解决什么问题?
📖 故事:只对接一个人的婚礼策划师
你要办一场婚礼。你是那个"只关心最终结果"的人。
先看没有策划师 的世界(第 8 篇):每家供应商只塞给你一张凭证 ------"机票已订,凭此单取票"。于是你只能亲自跑流程:攥着订票凭证冲到酒店柜台,把凭证递进去,站着死等确认单 ;再攥着确认单冲到车队柜台等派车单......每一环都要亲自跑腿、当场干等,还得不时折回去问"好了吗?好了吗?"。更糟的是,一沓凭证全在你手里,任何一环出错(机票被取消),你都是最后一个知道的人。
再看有策划师 的世界(本篇):你把需求一次性交给策划师,然后该干嘛干嘛去。策划师手里有一条自动接力线 :机票一订好,自动 启动订酒店;酒店一确认,自动 安排接送机------每个环节的"下一棒"早就登记好了,前一棒完成的瞬间自动开跑,全程没人催问、没人干等。两件互不依赖的事(订蛋糕、印请帖)被同时外包 出去,两件都完成 才自动合并成一份仪式清单。任何一环出问题,策划师自动启动备用方案并通知你------你不需要盯每个环节,只需在约定时间听最终通知。
你是主线程,供应商是工作线程,而那位策划师,就是 CompletableFuture。
💡 对应关系:
| 故事中 | 技术中 |
|---|---|
| 攥着凭证跑柜台、站着死等 | future.get() 阻塞等待 |
| 供应商塞给你的"凭证" | Future 对象(只能查状态 + 取结果) |
| 婚礼策划师 | CompletableFuture 异步流水线 |
| 机票订好 → 自动启动订酒店(接力) | thenApply / thenAccept 回调链 |
| 订蛋糕与印请帖并行、两件完成才合并清单 | thenCombine 双路合并 |
| 所有供应商到位才开最终协调会 | allOf 多路聚合 |
| 航班取消 → 自动启用备用方案 | exceptionally / handle 异常兜底 |
| 你全程只等最终通知,不用每步催问 | 回调驱动、主线程非阻塞 |
基于这个直觉,我们来看正式机制。
二、核心机制:从代码看原理
2.1 方法族谱:一张表记全
CompletableFuture 有五十多个方法,看着吓人;但按"流水线的五个动作"分组,一张表就能记全:
| 动作 | 方法 | 干什么 | 关键记忆点 |
|---|---|---|---|
| 创建 | supplyAsync(fn) |
发起有返回值的异步任务 | 默认跑在 ForkJoinPool.commonPool,可传自定义线程池 |
| 创建 | runAsync(action) |
发起无返回值的异步任务 | 返回 CompletableFuture<Void> |
| 转换 | thenApply(fn) |
结果 → 映射成新值 | 类似 Stream.map |
| 转换 | thenAccept(consumer) |
只消费结果,不返回新值 | 常用于链的终点(打印 / 入库) |
| 转换 | thenRun(action) |
不关心结果,前一步完成后跑一段代码 | 拿不到上一步的值 |
| 拼接 | thenCompose(fn) |
拿结果再发起下一步异步任务 | 类比 flatMap,避免"套娃" |
| 合并 | thenCombine(other, fn) |
两个独立异步结果合并成一个 | 两路并行,总耗时 ≈ 最慢一路 |
| 聚合 | allOf(...) / anyOf(...) |
全部 完成 / 任一完成后继续 | allOf 返回 Void,结果要自己取 |
| 回调 | whenComplete / handle |
完成后的收尾动作 | 前者不改变结果;后者可以修正/兜底 |
其中 thenApply 与 thenCompose 的区别值得单独一看------它是"多级依赖编排"(查 A 的结果去查 B)的关键:
java
// ❌ 错误:thenApply 里再发异步请求,会得到"套娃"泛型
CompletableFuture<CompletableFuture<Double>> nested =
idFuture.thenApply(id -> queryPrice(id)); // queryPrice 本身返回 CompletableFuture<Double>
// ✅ 正确:thenCompose 把两层拍平成一层(类比 flatMap)
CompletableFuture<Double> flat = idFuture.thenCompose(id -> queryPrice(id));
💡 代码要点:
- 经验法则:下一步返回的是普通值用
thenApply,下一步本身又是异步任务用thenCompose------和Optional.map/flatMap、Stream.map/flatMap是同一套心智。 - "多级依赖编排"(先查用户 ID → 再用 ID 查订单 → 再用订单查物流)就是
thenCompose一路串下去,代码是平的,回调不嵌套。
2.2 基本链式:回调接力、全程无阻塞
Demo01 用一条"下单流水线"演示最核心的四件套------发起、转换、消费、兜底:
java
// Demo01_Basic.java
CompletableFuture<Void> orderFuture = CompletableFuture
// 第 1 棒:异步查价格。supplyAsync 把任务提交到 ForkJoinPool.commonPool
.supplyAsync(() -> {
sleep(300); // 模拟调用商品服务(RPC / DB)
System.out.println("查到原价 99.0 元");
return 99.0;
})
// 第 2 棒:价格一出来就自动执行------加运费算总价(业务上的「计价」环节)
.thenApply(price -> price + 10)
// 第 3 棒:总价一出来就自动打印订单------只消费、不返回新值,所以用 thenAccept
.thenAccept(total -> System.out.println("订单创建成功,应付 " + total + " 元"))
// 兜底保险:链上任何一环抛异常都会流到这里,一次兜底覆盖全链
.exceptionally(ex -> {
System.out.println("下单失败:" + ex.getMessage());
return null;
});
// main 提交完立刻往下走,流水线执行期间零阻塞;join() 只在最后等一次
orderFuture.join();
流水线的执行视图------每个环节的"下一棒"在提交时就已登记,前一棒完成的瞬间自动触发:
main 线程 commonPool 工作线程
│
│─ supplyAsync(查价格) ──▶ ┌────────────────────────────────┐
│─ thenApply(加运费) ──▶ │ 阶段1: 查价格 99.0 (300ms) │
│─ thenAccept(打印) ──▶ │ ↓ 完成瞬间自动触发下一棒 │
│─ exceptionally(兜底)─▶ │ 阶段2: +10 → 109.0 │
│ │ ↓ │
│◀─ 提交完立刻返回 │ 阶段3: 打印订单 │
│ (全程非阻塞) │ ↓ 任一环节抛异常则短路到 │
│ │ 阶段4: exceptionally 兜底 │
│─ join()(只在终点等一次)▶ └────────────────────────────────┘
💡 代码要点:
thenXxx是"注册下一棒",不是立刻执行------回调接力:前一阶段完成的那一瞬间,自动触发后一阶段,像多米诺骨牌。- main 提交完就返回,流水线执行期间没有任何阻塞 ;与第 8 篇"每一步都
get()死等"的最大区别就在这里。 - 最后仍要
join()一次:不是为了等结果做下一步,而是防止 main 先退场把任务杀掉(见三、偏差 1)。 - 回调默认由"完成上一步的那个线程"执行------日志里的线程名不是 main,详见 Demo03。
2.3 组合编排:双路合并与多路聚合
真实业务里异步任务很少单打独斗。Demo02 演示两种最常用的接线方式------thenCombine 双路合并、allOf 多路聚合,并打印总耗时验证"并行":
java
// Demo02_Combine.java ------ thenCombine:两路独立查询并行跑,结果都就绪才合并
CompletableFuture<Double> priceFuture = CompletableFuture.supplyAsync(() -> { sleep(300); return 199.0; }); // 查商品价
CompletableFuture<Double> couponFuture = CompletableFuture.supplyAsync(() -> { sleep(500); return 30.0; }); // 查优惠券
CompletableFuture<String> orderFuture = priceFuture.thenCombine(
couponFuture,
(price, coupon) -> "折后价 " + (price - coupon) + " 元"); // 169.0 元
java
// Demo02_Combine.java ------ allOf:三路服务调用并行,全部完成后统一汇总
CompletableFuture<Void> allDone =
CompletableFuture.allOf(userFuture, orderFuture, stockFuture);
// 走到这里时三路必然都已完成,join() 只是把值取出来,立即返回、不再阻塞
allDone.thenAccept(v -> System.out.println("[allOf] 汇总 → "
+ userFuture.join() + " | " + orderFuture.join() + " | " + stockFuture.join())).join();
组合编排的"电路图"------总耗时取决于最慢那一路,而不是各路相加:
查商品价(300ms) ──────┐
├──▶ thenCombine ──▶ 折后价 169 元 总耗时 ≈ 500ms(而非 800ms)
查优惠券(500ms) ──────┘
查用户(300ms) ────────┐
查订单(500ms) ────────┼──▶ allOf ──▶ 三路全就绪 ──▶ 汇总 总耗时 ≈ 500ms(而非 1200ms)
查库存(400ms) ────────┘
💡 代码要点:
- 并行是耗时打印出来的铁证:300 + 500 两路并行,总耗时 ≈ 500ms 而非 800ms------运行 Demo02 亲眼验证。
thenCombine的两路查询互不依赖 ;如果第二步依赖第一步的结果,那该用的是thenCompose(2.1 节)。allOf返回CompletableFuture<Void>,不携带结果 ------聚合值要在 all 完成后从各路 future 上join()取(此刻必已完成,立即返回)。- 这是微服务"聚合接口"的标准姿势:第 8 篇 Future 版要按依赖顺序逐个
get(),这里把"等齐 + 汇总"整个交给回调。
2.4 异常处理:沿链传播,链尾兜底
CompletableFuture 的异常处理哲学:异常沿流水线向后传播,中间环节被直接跳过------不需要每层 try/catch,在链尾兜底一次即可:
java
// Demo01_Basic.java ------ 流水线 2:查询环节抛异常
CompletableFuture.supplyAsync(() -> {
sleep(600);
throw new IllegalStateException("商品已下架"); // 第 1 棒就翻了车
})
.thenApply(price -> price + 10) // 被「跳过」,不会执行
.thenAccept(total -> System.out.println("不可能打印")) // 也被「跳过」
.exceptionally(ex -> { // 异常沿链传播的第一站:统一兜底
System.out.println("exceptionally 兜底:中间环节已被跳过,向用户返回兜底文案");
return null;
});
而当你真的要在链外拿结果时,就绕不开 get() 与 join() 的异常差异(Demo03 坑 3):
java
// Demo03_Pitfalls.java ------ get() 抛受检异常,编译器强制 try/catch
try {
failed.get();
} catch (InterruptedException | ExecutionException e) {
// ExecutionException 只是「快递盒」,真实异常包在 cause 里
e.getCause().getMessage(); // 下游服务超时
}
// join() 抛非受检 CompletionException,不写 try/catch 也能编译通过
failed.join();
💡 代码要点:
- 异常像流水线上的"次品":翻车环节之后的所有环节都被跳过,异常一路流到最近的兜底点------中间环节干净得一行 try/catch 都不用写。
- 兜底二选一:
exceptionally只在出错时给备用值;handle无论成败都执行,还能顺手修正结果;whenComplete只旁观、不改变结果。 get()/join()抛出的都是包装壳 ,真实原因在getCause()里------排障时别盯着外壳异常类型发呆。
三、理解偏差:这些坑别踩
偏差 1:supplyAsync 提交了,任务就一定会执行完
❌ 常见误解:异步任务提交之后,程序自然会等它跑完再退出。
✅ 正确理解 :supplyAsync 默认把任务交给 ForkJoinPool.commonPool,而这个池里的线程全是守护线程------main 一结束,JVM 立刻退出,还在执行的守护线程任务被"连坐杀掉"。Demo03 坑 1 里那个耗时 1 秒的任务,永远等不到打印"执行完毕",进程就已经没了。
🧠 为什么容易错:"提交了就是自己的了"是单线程世界的直觉;开发机上任务通常几十毫秒就跑完,守护线程语义被完全掩盖,直到某天任务变重才暴露。
🧪 自测方法 :运行 Demo03,数一数输出里有没有"坑1 异步任务执行完毕"------没有,就说明你亲眼见过这个坑。生产环境的做法:显式传入自定义线程池 ,并确保外层逻辑 join()/allOf() 等待任务完成。
偏差 2:thenApply 和 thenApplyAsync 只是写法不同
❌ 常见误解 :带不带 Async 后缀只是命名风格差异,行为一样。
✅ 正确理解 :区别在回调由哪个线程执行 ------thenApply(fn) 由"完成上一步的那个线程"顺手执行,不换线程(可能是 commonPool 工作线程,也可能是调用线程);thenApplyAsync(fn, pool) 把回调重新提交到指定线程池执行。回调里做什么活、有没有共享状态,都和"在哪个线程跑"强相关。
🧠 为什么容易错:多数入门示例的回调只是打一行日志,在哪个线程跑结果都一样,差异被无声掩盖。
🧪 自测方法 :跑 Demo03 坑 2,对照两行日志的线程名------thenApply 打印 ForkJoinPool-commonPool-worker-N,thenApplyAsync 打印 自定义池-1。
偏差 3:每个 then 环节都要 try/catch
❌ 常见误解:流水线每一环都可能出错,那就每一环都包上 try/catch。
✅ 正确理解 :异常会沿流水线向后传播 ,翻车环节之后的所有环节被直接跳过,异常一路流到链尾的 exceptionally/handle------整条链兜底一次就够。
🧠 为什么容易错 :传统代码里异常沿调用栈逐层抛出,"哪层调用哪层处理"的直觉被平移过来;而 CompletableFuture 把"调用栈"换成了"回调链",异常传播的通道也换成了链。
🧪 自测方法 :跑 Demo01 流水线 2,确认 thenApply/thenAccept 没有任何输出(被跳过),只有兜底日志出现。
偏差 4:allOf 会把所有结果收集好交给我
❌ 常见误解 :CompletableFuture.allOf(f1, f2, f3) 完成后,直接从它身上 join() 出一个"结果列表"。
✅ 正确理解 :allOf 只负责"等齐 ",返回 CompletableFuture<Void>,不携带任何结果 。聚合值要在 all 完成后,从各路 future 上分别 join() 取------此刻它们必然已完成,join() 立即返回、不会阻塞。
🧠 为什么容易错 :名字长得像 Stream.collect,让人期待一个"聚合结果"。
🧪 自测方法 :看 Demo02 的写法------allDone.thenAccept(v -> userFuture.join() + orderFuture.join() + ...),取值动作是手动逐个 join 的。
易混淆点:一组"长得像"的方法
| 易混淆点 | 一句话区分 |
|---|---|
get() vs join() |
get() 抛受检 异常(强制 try/catch);join() 抛非受检 CompletionException;真实原因都在 getCause() |
thenApply vs thenCompose |
thenApply 下一步返回普通值 (map);thenCompose 下一步本身是异步任务(flatMap),避免套娃 |
thenApply vs thenApplyAsync |
前者完成线程顺手执行 (不换线程);后者重新提交线程池执行(可指定池) |
exceptionally vs handle |
exceptionally 只在出错时 兜底;handle 无论成败都执行,还能修正结果 |
whenComplete vs handle |
whenComplete 只旁观、不改变 结果;handle 可以替换结果 |
allOf vs anyOf |
allOf 全部 完成才继续;anyOf 任一完成就继续(多路竞速、兜底容灾) |
四、记忆卡片:核心 QA 对
| # | 问题 | 答案 |
|---|---|---|
| 1 | 定义:CompletableFuture 是什么? | Future 的增强版:既是异步任务的容器 ,更是可编排的回调流水线------支持链式转换、组合、异常兜底,实现了 Future 接口。 |
| 2 | 机制:为什么说它"非阻塞"? | thenXxx 只是注册回调 ;前一阶段完成的瞬间,由执行线程接力触发下一阶段------主线程不轮询、不 get 死等。 |
| 3 | 场景:典型使用场景? | 并行 RPC 聚合 (thenCombine/allOf)、多级依赖编排 (thenCompose:"查 A 的结果去查 B")。 |
| 4 | 权衡:默认线程池能用吗? | commonPool 是全局共享 的守护线程池(线程数 ≈ CPU 核数 - 1),IO 密集任务会占满它;生产应显式传自定义线程池,且 main 要等任务完成。 |
| 5 | 区分 :get() 和 join()? |
get() 抛受检 异常必须 try/catch;join() 抛非受检 CompletionException;两者外层都是包装壳,真实原因用 getCause() 取。 |
| 6 | 区分 :thenApply 与 thenApplyAsync? |
前者由完成上一步的线程顺手执行 ;后者把回调重新提交到线程池(可指定池),会换线程。 |
| 7 | 边界:它什么时候会"悄悄失效"? | main 提前退出杀死 commonPool 守护线程任务;allOf 结果为 Void 需手动收集;自定义池太小、回调里又提交任务时可能池饥饿死锁。 |
五、知识网络:相似、互斥与学习路径
🔗 相似知识(同类不同派):Future(第 8 篇)
CompletableFuture 直接实现了 Future 接口------它是同一问题的"下一级答案":
| 对比维度 | Future(第 8 篇) | CompletableFuture(本篇) |
|---|---|---|
| 本质 | 一次异步调用的"取货凭证" | 可编排的异步流水线 |
| 拿结果 | get() 阻塞死等(或轮询 isDone) |
回调自动接力,链尾才 join() 一次 |
| 组合编排 | 不支持,靠主线程手工协调 | thenCombine/allOf/thenCompose 原生支持 |
| 异常处理 | 藏在 ExecutionException 里,get 时才炸 |
沿流水线传播,链尾统一兜底 |
| 回调 | 无 | thenXxx/whenComplete/handle 全家桶 |
⚔️ 互斥/替代知识(选型二选一):异步方案选型
| 方案 | 风格 | 适用场景 |
|---|---|---|
Future + 线程池 |
拉模型(主动 get()) |
简单的一次异步调用,不需要编排 |
CompletableFuture |
回调链(推模型) | Java 8+ 异步编排的默认选择:聚合、编排、兜底 |
| 响应式流(Reactor/RxJava) | 发布订阅 + 背压 | 高并发流式处理、复杂异步拓扑(第三方库) |
| 虚拟线程(JDK 21+) | 同步写法、异步执行 | IO 密集场景,想回到"一行一行写"的心智 |
选型依据:JDK 17 标准库内做异步编排,CompletableFuture 是默认答案;追求极致流式能力才考虑响应式框架;JDK 21 之后,"大量阻塞 IO"场景也可以直接用虚拟线程降维。
📚 前置与后续(学习路径)
- 前置知识 :
- Future 与线程池(第 8 篇):理解"凭证模型"与它的三大局限,本篇是对它的全面升级
- Lambda 与函数式接口:
thenXxx的参数全是函数式接口(Function/Consumer/Runnable/BiFunction)
- 后续知识 :
- 第 10 篇 Volatile:回调跑在别的线程上,一旦读写共享可变状态,可见性问题立刻上桌
- 响应式编程(Reactor/WebFlux):
CompletableFuture是理解响应式流"回调链 + 异常传播"的最佳跳板
- 最佳搭档 :
- 自定义线程池 :
supplyAsync(fn, pool)------摆脱全局 commonPool,隔离业务、可控资源(生产标配) thenCompose:多级依赖编排(查 A → 用 A 查 B → 用 B 查 C)的专属工具,让回调链保持"平"的形态
- 自定义线程池 :
六、配套代码运行指南
a09CompletableFuture/
├── Demo01_Basic.java 回调接力基本链:supplyAsync → thenApply → thenAccept → exceptionally
├── Demo02_Combine.java 组合编排:thenCombine 双路合并 + allOf 三路聚合,打印耗时验证并行
└── Demo03_Pitfalls.java 三大易错点:守护线程池被 main 连坐、thenApply(Async) 线程差异、get vs join
每个类都含 main 方法,直接运行即可(JDK 17)。建议重点观察:
- Demo01:main 打印"该干嘛干嘛去"时流水线还没跑------这就是非阻塞 ;流水线 2 里
thenApply/thenAccept没有任何日志(被异常跳过),只有兜底日志出现; - Demo02:两段打印的总耗时都 ≈ 500ms(最慢一路),而不是 800/1200ms------并行的铁证;
- Demo03:①最后一行"坑1 异步任务执行完毕"永远不会出现 (守护线程被连坐);②
thenApply与thenApplyAsync两行日志的线程名 对比;③把join()外面的 try/catch 删掉照样能编译,get()则不行------受检与非受检的直观感受。
结语
CompletableFuture 的精髓是一个视角转换:从"我守着凭证等结果",变成"结果就绪后该干什么,提前登记好" 。第 8 篇的 Future 只回答了"怎么发起异步",本篇回答的是"异步之后呢"------转换、拼接、合并、兜底,全部声明式接线,主线程全程零阻塞。这也意味着:你的业务逻辑将在别的线程 上分头执行,一旦回调里读写共享可变状态,"改了却看不见""算着算着被覆盖"就会找上门。下一课我们研究并发可见性的第一嫌疑人------第 10 篇 Volatile。