【多线程】CompletableFuture使用

【多线程】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 多路聚合

从"一张凭证"到"一条流水线",这是 FutureCompletableFuture 的本质跃迁。本文讲透四件事:方法族谱 → 回调接力怎么跑 → 组合怎么接 → 异常怎么兜


一、场景故事:它在解决什么问题?

📖 故事:只对接一个人的婚礼策划师

你要办一场婚礼。你是那个"只关心最终结果"的人。

先看没有策划师 的世界(第 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 完成后的收尾动作 前者不改变结果;后者可以修正/兜底

其中 thenApplythenCompose 的区别值得单独一看------它是"多级依赖编排"(查 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));

💡 代码要点

  1. 经验法则:下一步返回的是普通值用 thenApply,下一步本身又是异步任务用 thenCompose ------和 Optional.map/flatMapStream.map/flatMap 是同一套心智。
  2. "多级依赖编排"(先查用户 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()(只在终点等一次)▶ └────────────────────────────────┘

💡 代码要点

  1. thenXxx 是"注册下一棒",不是立刻执行------回调接力:前一阶段完成的那一瞬间,自动触发后一阶段,像多米诺骨牌。
  2. main 提交完就返回,流水线执行期间没有任何阻塞 ;与第 8 篇"每一步都 get() 死等"的最大区别就在这里。
  3. 最后仍要 join() 一次:不是为了等结果做下一步,而是防止 main 先退场把任务杀掉(见三、偏差 1)。
  4. 回调默认由"完成上一步的那个线程"执行------日志里的线程名不是 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) ────────┘

💡 代码要点

  1. 并行是耗时打印出来的铁证:300 + 500 两路并行,总耗时 ≈ 500ms 而非 800ms------运行 Demo02 亲眼验证。
  2. thenCombine 的两路查询互不依赖 ;如果第二步依赖第一步的结果,那该用的是 thenCompose(2.1 节)。
  3. allOf 返回 CompletableFuture<Void>不携带结果 ------聚合值要在 all 完成后从各路 future 上 join() 取(此刻必已完成,立即返回)。
  4. 这是微服务"聚合接口"的标准姿势:第 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();

💡 代码要点

  1. 异常像流水线上的"次品":翻车环节之后的所有环节都被跳过,异常一路流到最近的兜底点------中间环节干净得一行 try/catch 都不用写。
  2. 兜底二选一:exceptionally 只在出错时给备用值;handle 无论成败都执行,还能顺手修正结果;whenComplete 只旁观、不改变结果。
  3. 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-NthenApplyAsync 打印 自定义池-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 区分thenApplythenApplyAsync 前者由完成上一步的线程顺手执行 ;后者把回调重新提交到线程池(可指定池),会换线程。
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 异步任务执行完毕"永远不会出现 (守护线程被连坐);②thenApplythenApplyAsync 两行日志的线程名 对比;③把 join() 外面的 try/catch 删掉照样能编译,get() 则不行------受检与非受检的直观感受。

结语

CompletableFuture 的精髓是一个视角转换:从"我守着凭证等结果",变成"结果就绪后该干什么,提前登记好" 。第 8 篇的 Future 只回答了"怎么发起异步",本篇回答的是"异步之后呢"------转换、拼接、合并、兜底,全部声明式接线,主线程全程零阻塞。这也意味着:你的业务逻辑将在别的线程 上分头执行,一旦回调里读写共享可变状态,"改了却看不见""算着算着被覆盖"就会找上门。下一课我们研究并发可见性的第一嫌疑人------第 10 篇 Volatile

相关推荐
血小板要健康40 分钟前
链表 阶段算法总结
java·数据结构·笔记·算法·leetcode·链表
CHPCWWHSU1 小时前
DeepSeek-Harness-Qt将浏览器端Agent装入桌面应用
开发语言·qt
m0_587383001 小时前
点餐预约核销系统的架构脉络
java·架构·系统架构·需求分析
前端 贾公子1 小时前
第10章:RAG(2)
开发语言·python
代码方舟1 小时前
O2O二手车交易平台B端车商管理后台:基于天远车信盟出险构建自动化车况评估网关
java·运维·人工智能·自动化
爱学堂IT分享1 小时前
图灵Java互联网架构师六期
java·开发语言
坐吃山猪2 小时前
【多线程】Semaphore使用
java·数据库·oracle·多线程
传奇开心果编程2 小时前
【Rust入门知识点学与练】第3课:String 与 &str 的区别
开发语言·学习·rust
秋名RG2 小时前
Java 核心特性一览
java·开发语言