REJAVA / LEARNING NOTES
CompletableFuture(可编排的异步结果)
并行发出多个查询,用自己的线程池,每项单独超时与兜底。
根据课程材料整理
本次更新说明
- 整理为公开学习笔记:保留编排、线程池、异常、超时与取消、详情页示例、练习与隔日复测。
- 示例运行耗时注明示例环境与 JDK 版本;附两个可下载示例程序。
热身 · 一 为什么需要 · 二 编排方法 · 三 在哪执行 · 四 异常 · 五 超时与取消 · 六 实战详情页 · 七 检验 · 八 讲出来 · 九 追问 · 十 隔日复测
学完这一课,你能做到
能用 CompletableFuture(可编排的异步结果) 把几个互不依赖的远程查询并行发出、汇总结果,给每一项配上超时和失败兜底;能说清 thenApply(结果变换方法) 与 thenCompose(异步衔接方法) 的区别、为什么生产环境要传自己的线程池,以及 join(等待完成方法)、get(获取结果方法)、cancel、orTimeout(超时失败方法) 各自管什么、不管什么。
和前几课的联系: 这是并发部分的最后一课,主题是异步编排。它把第5课的线程池和 Future(异步结果句柄)、第7课的有界队列串在一起,而且很容易落到实际业务:一个页面要调好几个子系统的情况很常见。
主读: Java 21 CompletableFuture(可编排的异步结果) 官方文档。读开头的类说明(由谁执行、默认线程池、cancel、get(获取结果方法) 与 join(等待完成方法)),再读 CompletionStage(完成阶段接口) 的类说明(then/combine、异常怎么往后传)。相关原文见官方摘录。
热身:先回忆前几课(4 分钟)
先凭记忆回答,再展开核对。答不上来就记一句,接着往下学,这不是进入新课的门槛。前两题是第7课的隔日复测。
第7课:3 个消费者共用一个阻塞队列。生产者放完任务后,应该再做什么?如果少做了,会发生什么?
再放 3 个 结束标记,然后生产者线程自己结束。生产者和消费者的代码里都不写挂起、唤醒,put(写入方法) 和 take(阻塞取出方法) 内部已经做了。少放一个标记,就有一个消费者永远卡在 take(阻塞取出方法) 上;它是非守护线程,程序退不出去。对照第7课第六节。
第7课:队列已满。put(e)、offer(e)、offer(e, 1, SECONDS)、add(e) 各会怎样?
put(写入方法) 一直等到有空位;offer(e) 立即返回 false;offer(e, 1, SECONDS) 最多等 1 秒,仍没空位就返回 false;add(e) 抛 IllegalStateException。带超时的只有 offer(尝试放入方法),没有带超时的 put(写入方法)。
第7课:ArrayBlockingQueue(数组阻塞队列) 和 LinkedBlockingQueue(链式阻塞队列) 各用几把锁?为什么?
数组版一把:放入、取出共用元素个数和同一块数组。链表版两把:放入只动尾、取出只动头,拆成 putLock 和 takeLock,元素个数用 AtomicInteger(原子整数类)。
第5课:往线程池 submit(提交并返回结果句柄的方法) 一个任务,拿到 Future(异步结果句柄)。马上调用 future.get(),调用线程会怎样?任务抛了异常,get(获取结果方法) 会抛什么?
调用线程会一直等,直到任务完成。任务抛了异常,get(获取结果方法) 抛 ExecutionException(任务执行异常),原始异常在 getCause() 里。本课第一节就从“get(获取结果方法) 会等”讲起。
一、为什么需要它:Future(异步结果句柄) 能并行,但不好“接着做”
有了 Future(异步结果句柄),为什么还要 CompletableFuture(可编排的异步结果)?它解决了什么问题?
场景:办件详情页要显示三块内容,分别来自三个服务:申请人信息约 300 毫秒,材料列表约 200 毫秒,审批记录约 250 毫秒。三块互不依赖。一个接一个查,页面要等 300 + 200 + 250 = 750 毫秒。同时查,理想情况下只要等最慢的那个,约 300 毫秒。
第5课的办法已经能并行:把三个查询 submit(提交并返回结果句柄的方法) 给线程池,拿到三个 Future(异步结果句柄),再分别 get(获取结果方法)。但 Future(异步结果句柄) 有两个不方便的地方[JG]:
- 只能等,不能“接着做”。 想在结果出来后自动做下一步,比如拿申请人编号再去查征信,只能自己先 get(调用线程在那里等),再手动提交下一步。
- 不好组合。 “三个都完成后组装页面”“任何一个失败就用默认值”,都得自己写一堆 get(获取结果方法) 和 try-catch。
CompletableFuture(可编排的异步结果) 同时实现了 Future(异步结果句柄) 和 CompletionStage(完成阶段接口) 两个接口[1]。Future(异步结果句柄) 那一半和第5课一样:能等结果、能取消。CompletionStage(完成阶段接口) 那一半是新的:它表示异步计算中的一个“阶段”, 上一个阶段完成时,自动触发下一个阶段 [2]。所以可以写成一条链:查完 → 变换 → 合并 → 兜底,不用自己守着 get(获取结果方法)。
CompletableFuture<String> applicant = CompletableFuture.supplyAsync(() -> queryApplicant(), pool);
CompletableFuture<String> materials = CompletableFuture.supplyAsync(() -> queryMaterials(), pool);
CompletableFuture<String> records = CompletableFuture.supplyAsync(() -> queryRecords(), pool);
CompletableFuture.allOf(applicant, materials, records).join(); // 等三个都完成
String page = applicant.join() + materials.join() + records.join();
验证:示例程序 B 段用 sleep 模拟三个查询(300/200/250 毫秒):在示例环境中,串行约 760 毫秒,用上面的写法约 310 毫秒,JDK 17/21 均通过。你自己运行时的毫秒数会略有不同。pool 是自己创建的线程池,第三节讲为什么要传。
supplyAsync(异步取值方法) 提交一个有返回值的任务, 立即 返回一个 CompletableFuture(可编排的异步结果),任务在线程池里跑。没有返回值的任务用 runAsync(异步执行方法)。allOf(全部完成方法) 在所有给定的任务都完成时才完成;它本身不带各个结果,结果要逐个 join(等待完成方法) 取出[3]。join(等待完成方法) 和 get(获取结果方法) 一样会等,区别在第四节讲。
先自己说一遍,再看参考回答
Future(异步结果句柄) 能拿到异步结果,但 get(获取结果方法) 是阻塞的,而且不支持编排:想在结果出来后接着做下一步、合并几个结果、统一处理异常,都得自己写。CompletableFuture(可编排的异步结果) 同时实现了 Future(异步结果句柄) 和 CompletionStage(完成阶段接口),上一个阶段完成会自动触发下一个阶段,所以能写成链式调用。典型用法是几个互不依赖的远程查询用 supplyAsync(异步取值方法) 同时发出,再用 allOf(全部完成方法) 等全部完成后组装结果,总耗时约等于最慢的那个,而不是全部相加。
二、编排方法:变换、衔接、合并、等全部
thenApply(结果变换方法) 和 thenCompose(异步衔接方法) 有什么区别?几个异步任务怎么合并?
CompletionStage(完成阶段接口) 的方法名是有规律的[2]:
- 名字带 apply :用上一步的结果 算出新结果 ;带 accept :拿结果 做件事 ,不产生新结果;带 run : 不用 上一步的结果,只是在它之后执行。
- 前缀 then :依赖 一个 阶段; combine :依赖 两个 阶段都完成; either :两个中 任一个 完成。
| 方法 | 什么时候用 | 例子 |
|---|---|---|
thenApply(fn) | 上一步的结果要变换一下,变换本身很快、不是异步的 | 把查到的材料列表转成页面需要的格式 |
thenAccept(fn) | 拿结果做件事,后面不需要新结果 | 把查到的结果写进缓存 |
thenCompose(fn) | 下一步本身也是异步的,并且 依赖 上一步的结果 | 先查办件拿到申请人编号,再异步查申请人 |
thenCombine(other, fn) | 两个 互不依赖 的异步结果,都完成后合成一个 | 材料数和审批记录数合成一行摘要 |
allOf(...) | 三个及以上,等全部完成 | 详情页三块内容都到了再组装 |
anyOf(...) | 任意一个完成就行,结果和那一个相同 | 同一份数据问两个副本,谁先回用谁 |
thenApply(结果变换方法) 和 thenCompose(异步衔接方法) 的区别
假设已经有一个异步方法 queryOwnerAsync(id),它返回 CompletableFuture<String>。现在要“先拿到办件编号,再用编号去查申请人”:
// 用 thenApply 接:函数返回的 CompletableFuture 被当成“结果”,得到结果里套结果
CompletableFuture<CompletableFuture<String>> nested = getApplyNo().thenApply(id -> queryOwnerAsync(id));
// 用 thenCompose 接:直接得到里面的结果
CompletableFuture<String> owner = getApplyNo().thenCompose(id -> queryOwnerAsync(id));
验证:示例程序 C 段:thenApply(结果变换方法) 版要 join(等待完成方法) 两次才拿到“A-001的申请人”,thenCompose(异步衔接方法) 版 join(等待完成方法) 一次。getApplyNo 在示例中写作 supplyAsync(() -> “A-001”, pool)。
官方文档的类比是:thenApply(结果变换方法) 像 Stream 的 map,thenCompose(异步衔接方法) 像 flatMap[3]。判断方法很简单: 传进去的函数返回普通值,用 thenApply(结果变换方法);返回 CompletableFuture(可编排的异步结果),用 thenCompose(异步衔接方法)。
还有一点:依赖关系只决定“谁触发谁”,不保证别的执行顺序[2]。两个互不依赖的阶段,谁先完成都有可能。
先自己说一遍,再看参考回答
thenApply(结果变换方法) 用上一步的结果算出一个新值,函数返回普通值;thenCompose(异步衔接方法) 用于下一步本身也是异步、并且依赖上一步结果的情况,函数返回 CompletableFuture(可编排的异步结果),thenCompose(异步衔接方法) 会把它摊平,不会出现结果里套结果。类比就是 map 和 flatMap。两个互不依赖的结果用 thenCombine(结果合并方法) 合并;三个以上用 allOf(全部完成方法) 等全部完成,再逐个 join(等待完成方法) 取结果;只要任意一个用 anyOf(任一完成方法)。
三、在哪个线程执行:默认公共池,生产要传自己的线程池
supplyAsync(异步取值方法) 不传线程池会怎样?为什么生产环境建议自定义线程池?
官方文档写明:所有不带 Executor(执行器接口) 参数的 async 方法,都在 ForkJoinPool.commonPool() 里执行[1]。(如果公共池的并行度小于 2,会改为每个任务新建一个线程。)Executor(执行器接口) 是“把任务交给某个线程执行”的接口,第5课的 ThreadPoolExecutor(线程池执行器) 就是它的实现。
公共池有三个特点,都对业务不利:
- 线程少。 没有另行设置时,并行度是“处理器数 − 1”,至少 1[4]。8 核机器上就是 7 个线程(示例程序 A 段在 8 核的示例环境中打印出 7,它会按你机器的处理器数核对这个公式)。
- 整个应用共享。 它是一个全局的静态实例,不会因为调用 shutdown(有序关闭) 而关闭[4]。别的模块、第三方库只要用了默认实现,都在抢这几个线程[JG]。
- 工作线程是守护线程 [4]。第7课讲过,所有非守护线程结束后,JVM(Java 虚拟机) 就开始关闭,不会等守护线程。所以写个 main 方法测试,不 join(等待完成方法) 就退出,异步任务可能来不及跑完。
远程查询的大部分时间是在 等 网络返回。几个慢查询就能把这 7 个线程全部占住,其他业务的异步任务只能排队。所以生产环境要给每类业务传自己的线程池,做到隔离、可控[JG]:
ThreadPoolExecutor queryPool = new ThreadPoolExecutor(
8, 8, 60, TimeUnit.SECONDS,
new ArrayBlockingQueue<>(200), // 有界队列(第7课)
namedThreads("detail-query-"), // 线程带名字,排查时一眼看出是谁
new ThreadPoolExecutor.CallerRunsPolicy()); // 满了由提交者自己执行(第5课)
CompletableFuture.supplyAsync(() -> queryApplicant(), queryPool);
验证:DetailPageExample.java 用的就是这个线程池,JDK 17/21 运行通过;namedThreads 是示例里的一个小方法,给线程按前缀加序号命名。示例程序 A 段验证:传入线程池时,任务在名为 query-1 的线程里执行。
注意:JavaGuide 的同类示例用了不传容量的 LinkedBlockingQueue(链式阻塞队列)[JG],那就是第7课讲的无界队列隐患。参数怎么定,按第5课:先估算,再压测。
带 Async 和不带 Async 的区别
thenApply(结果变换方法) 和 thenApplyAsync 只差一个后缀,执行位置却不同[1]:
- 不带 Async: 可能由“完成上一步的那个线程”执行,也可能由“调用这个方法的线程”执行。示例程序 A 段观察到:上一步已经完成时,thenApply(结果变换方法) 直接在调用它的 main 线程里执行。
- 带 Async、不传线程池: 放到公共池执行。 带 Async、传线程池: 放到你给的线程池执行。
所以,后续步骤很快(比如格式转换),用不带 Async 的就行;后续步骤也要调远程服务,就用带 Async 的版本,并且传自己的线程池。
先自己说一遍,再看参考回答
不传线程池时,async 方法用 ForkJoinPool.commonPool()。它默认只有处理器数减一个线程,整个应用共享,而且是守护线程。远程查询大部分时间在等网络,几个慢查询就能把公共池占满,影响其他业务。所以生产环境要按业务传自己的 ThreadPoolExecutor(线程池执行器):有界队列、线程命名、明确的拒绝策略,做到隔离和可控。另外,不带 Async 的 thenApply(结果变换方法) 可能在完成上一步的线程或调用方线程里执行;后续步骤也是慢操作时,用带 Async 的版本并传自己的线程池。
四、异常:沿着链往后传,在链上兜底
CompletableFuture(可编排的异步结果) 里的异常怎么处理?join(等待完成方法) 和 get(获取结果方法) 有什么区别?
异常会沿着链往后传。 某一步抛了异常,依赖它的后续步骤(thenApply(结果变换方法) 等) 不再执行 ,直接以异常完成,异常被包在 CompletionException(完成异常) 里,原始异常在它的 getCause() 里[2]。示例程序 D 段验证:第一步抛 IllegalStateException,后面的 thenApply(结果变换方法) 一次也没执行。
取结果时:
get()抛 ExecutionException(受检异常,必须 try-catch 或声明),原始异常在 getCause()[1]。和第5课一样。join()抛 CompletionException(非受检异常,不强制捕获),原始异常也在 getCause()[3]。所以在 Lambda 链里用 join(等待完成方法) 更顺手。
在链上兜底,有三个方法 [2]:
| 方法 | 什么时候执行 | 结果 |
|---|---|---|
exceptionally(ex -> 默认值) | 只在出错时 | 用返回值替代异常,像 catch |
handle((v, ex) -> ...) | 成功、失败都执行 | 同时拿到结果和异常,算出新结果 |
whenComplete((v, ex) -> ...) | 成功、失败都执行 | 不改变 原来的结果或异常,适合记日志 |
CompletableFuture<String> materials = CompletableFuture
.supplyAsync(() -> queryMaterials(), pool)
.exceptionally(ex -> {
log.warn("材料服务失败", ex.getCause()); // ex 可能是包了一层的 CompletionException
return "暂不可用";
});
验证:示例程序 D 段:exceptionally(异常兜底方法) 回调里拿到的 ex 是 CompletionException(完成异常),getCause() 才是原始的 IllegalStateException;handle(结果与异常处理方法) 在成功时 ex 为 null(空值);whenComplete(完成回调方法) 之后 join(等待完成方法) 仍然抛异常。log 是示意的日志对象,片段不能直接编译。
allOf(全部完成方法) 遇到部分失败: 只要有一个任务异常完成,allOf(全部完成方法) 也异常完成;但其他成功的任务,结果仍然可以单独 join(等待完成方法) 取出[3]。所以详情页的常见做法是: 每一项先各自用 exceptionally(异常兜底方法) 兜底,再交给 allOf(全部完成方法) ,一项出错只显示“暂不可用”,不会整页报错。第六节就是这样写的。
最容易犯的错:既不兜底,也不取结果。 异常只是存进了 CompletableFuture(可编排的异步结果) 里,没人 join(等待完成方法)、get(获取结果方法),也没有 exceptionally(异常兜底方法) 之类的处理,就没人知道它失败过[JG]。
先自己说一遍,再看参考回答
异常会沿着链往后传:某一步抛异常,后续依赖它的 thenApply(结果变换方法) 等不再执行,异常被包成 CompletionException(完成异常)。取结果时,get(获取结果方法) 抛受检的 ExecutionException(任务执行异常),join(等待完成方法) 抛非受检的 CompletionException(完成异常),原始异常都在 getCause(获取原始原因方法) 里。链上兜底用三个方法:exceptionally(异常兜底方法) 只在出错时执行,返回替代值;handle(结果与异常处理方法) 成功失败都执行,能同时拿到结果和异常;whenComplete(完成回调方法) 也都执行,但不改变结果,适合记日志。用 allOf(全部完成方法) 时,一个失败 allOf(全部完成方法) 就失败,所以要给每一项先单独兜底。不兜底也不取结果,异常就悄悄丢了。
五、超时与取消:都不会停掉正在跑的任务
CompletableFuture(可编排的异步结果) 怎么设超时?cancel(true) 能停掉正在执行的任务吗?
超时有三种写法:
get(1, SECONDS):和第5课一样, 调用线程 最多等 1 秒,超时抛 TimeoutException(等待超时异常)。orTimeout(1, SECONDS)(Java 9 起):到时还没完成,这个 CompletableFuture(可编排的异步结果) 就以 TimeoutException(等待超时异常) 异常完成 [3],后面可以接 exceptionally(异常兜底方法) 兜底。completeOnTimeout(默认值, 1, SECONDS)(Java 9 起):到时还没完成,就用默认值 正常完成 [3]。
关键:这三种都只是“不再等这个结果”,不会停掉正在执行的任务。 示例程序 G 段:任务要跑 400 毫秒,orTimeout(超时失败方法) 设 100 毫秒;100 毫秒时调用方拿到 TimeoutException(等待超时异常),但那个任务没有被停止,之后照常执行完。
cancel 也一样。 官方文档说,CompletableFuture(可编排的异步结果) 并不直接控制产生结果的那段计算,所以取消只是“以 CancellationException(取消异常) 异常完成”的另一种方式;cancel(true) 的参数 mayInterruptIfRunning 在这个实现里 没有效果 ,因为它不用中断来控制执行[1][3]。对比第5课:线程池 submit(提交并返回结果句柄的方法) 返回的 Future(异步结果句柄),cancel(true) 会尝试中断执行任务的线程。示例程序 F 段验证:cancel(true) 后 join(等待完成方法) 抛 CancellationException(取消异常),但任务照常执行完,从没收到中断。
那慢查询怎么办?要让任务 自己 早点结束:给远程调用本身设超时,比如 HTTP 客户端的读超时、数据库查询超时。这样线程才会被及时还回线程池。orTimeout(超时失败方法)、completeOnTimeout(超时默认值方法) 负责让 页面 不再等;客户端超时负责让 线程 不被占住,两者都要有。
先自己说一遍,再看参考回答
超时可以用 get(获取结果方法) 带超时,让调用线程最多等一会儿;也可以用 Java 9 起的 orTimeout(超时失败方法),到时异常完成,或者 completeOnTimeout(超时默认值方法),到时用默认值完成。但这些都只是不再等结果,不会停掉正在执行的任务。cancel(true) 在 CompletableFuture(可编排的异步结果) 里也不会中断线程,官方说这个参数没有效果,取消只是让结果以 CancellationException(取消异常) 完成。所以真正要释放线程,还得给远程调用本身设超时,比如 HTTP 读超时、SQL 超时。
六、实战:办件详情页并行查询 + 单项降级
实际业务里怎么用 CompletableFuture(可编排的异步结果)?用一个具体例子讲清楚。
把前五节合在一起:三个查询同时发出;每一项各自设超时、各自兜底;全部有结果后组装页面。代码里的编号对应前面各节。
static String loadDetail(String applyNo) {
CompletableFuture<String> applicant = CompletableFuture
.supplyAsync(() -> queryApplicant(applyNo), QUERY_POOL) // ① 自己的线程池(第三节)
.completeOnTimeout("暂不可用", 500, TimeUnit.MILLISECONDS) // ② 超时给默认值(第五节)
.exceptionally(ex -> "暂不可用"); // ③ 出错给默认值(第四节)
CompletableFuture<String> materials = CompletableFuture
.supplyAsync(() -> queryMaterials(applyNo), QUERY_POOL)
.completeOnTimeout("暂不可用", 500, TimeUnit.MILLISECONDS)
.exceptionally(ex -> "暂不可用");
CompletableFuture<String> records = CompletableFuture
.supplyAsync(() -> queryRecords(applyNo), QUERY_POOL)
.completeOnTimeout("暂不可用", 300, TimeUnit.MILLISECONDS)
.exceptionally(ex -> "暂不可用");
CompletableFuture.allOf(applicant, materials, records).join(); // ④ 等三项都有结果(第一节)
return "申请人:" + applicant.join() + " | 材料:" + materials.join()
+ " | 审批记录:" + records.join();
}
验证:完整版本是 DetailPageExample.java,下载后用 Java 17 或 21 运行 java DetailPageExample.java。示例里材料服务 200 毫秒后抛异常,审批服务要 600 毫秒(超过 300 毫秒的设定)。输出“申请人:张三 | 材料:暂不可用 | 审批记录:暂不可用”,最后一行“检查通过”;示例环境中 Java 17、21 的耗时约 320–330 毫秒,你自己运行时会略有不同。
讲这个例子时,按这个顺序说四件事: 为什么并行 (三块互不依赖,串行要相加); 用的哪个线程池 (独立线程池,有界队列,为什么不用公共池); 失败和超时怎么办 (每项单独降级,一项出错不影响整页); 还有什么限制 (completeOnTimeout(超时默认值方法) 不停止原任务,所以远程调用本身也设了超时)。
注意:这里的场景是教学例子,用来演示设计思路。第九节末尾还列了几个可能用到它的候选场景。
先自己说一遍,再看参考回答
(示例说法,以本节的办件详情页为例)办件详情页要展示申请人、材料、审批记录三块,分别来自三个服务,互不依赖。串行要把三次耗时加起来,所以我用 CompletableFuture.supplyAsync 把三个查询同时发出,传的是专门的查询线程池,有界队列,没用公共池。每一项各自用 completeOnTimeout(超时默认值方法) 和 exceptionally(异常兜底方法) 兜底,超时或出错就显示“暂不可用”,再用 allOf(全部完成方法) 等三项都有结果后组装页面。这样总耗时约等于最慢的那一项,一个服务出问题也不会让整页报错。另外 completeOnTimeout(超时默认值方法) 不会停止原任务,所以远程调用本身也配了超时。
七、检验:先关掉上面,凭记忆做
6 道题:先回答,再展开核对
CompletableFuture.supplyAsync(task)不传线程池,任务在哪执行?
- A. 调用方的当前线程
- B. 公共的分治线程池
- C. 每次新建一个线程
查看答案与解释
答案:B
不带 Executor(执行器接口) 的 async 方法用 ForkJoinPool.commonPool()。只有公共池并行度小于 2 时,才改为每个任务新建线程。
- 先异步查到办件编号,再用编号调用一个返回
CompletableFuture<String>的方法。用哪个方法接?
- A. 用结果变换方法
- B. 用结果合并方法
- C. 用异步衔接方法
查看答案与解释
答案:C
函数返回 CompletableFuture(可编排的异步结果),又依赖上一步结果,用 thenCompose(异步衔接方法)。用 thenApply(结果变换方法) 会得到结果里套结果;thenCombine(结果合并方法) 用于两个互不依赖的结果。
- 链中第一步抛了异常,后面接着一个
thenApply(结果变换方法)。这个 thenApply(结果变换方法) 会怎样?
- A. 不执行直接失败
- B. 收到空值照常跑
- C. 收到异常照常跑
查看答案与解释
答案:A
异常沿链往后传,依赖它的 thenApply(结果变换方法) 不执行,直接以 CompletionException(完成异常) 完成。能拿到异常的是 exceptionally(异常兜底方法)、handle(结果与异常处理方法)、whenComplete(完成回调方法)。
- 任务失败后调用
join(),抛出的是?
- A. 执行异常且必须捕获
- B. 完成异常且无须捕获
- C. 直接抛原始那个异常
查看答案与解释
答案:B
join(等待完成方法) 抛非受检的 CompletionException(完成异常),原始异常在 getCause() 里。get(获取结果方法) 才抛受检的 ExecutionException(任务执行异常)。
- 对一个正在执行的 CompletableFuture(可编排的异步结果) 调用
cancel(true),执行任务的线程会怎样?
- A. 被中断后立即停止
- B. 被线程池直接回收
- C. 不受影响照常执行
查看答案与解释
答案:C
官方说 mayInterruptIfRunning 在这个实现里没有效果。结果变成已取消,任务照常跑完。第5课线程池返回的 Future(异步结果句柄) 才会尝试中断。
- 三个任务交给
allOf(全部完成方法),其中一个抛异常。另外两个成功任务的结果呢?
- A. 仍可单独取出来
- B. 跟着一起都丢失
- C. 会被自动重新跑
查看答案与解释
答案:A
allOf(全部完成方法) 会异常完成,但成功的任务各自 join(等待完成方法) 仍能拿到结果。所以常见做法是每项先单独兜底,再交给 allOf(全部完成方法)。
八、用完整回答检验理解
连续回答下面四问,可以写下来,也可以先口述再整理成文字。写完再对照各节的参考回答。
- 有了 Future(异步结果句柄),为什么还要 CompletableFuture(可编排的异步结果)?thenApply(结果变换方法) 和 thenCompose(异步衔接方法) 有什么区别?
- supplyAsync(异步取值方法) 不传线程池会怎样?你会怎么配线程池?
- CompletableFuture(可编排的异步结果) 的异常怎么处理?三个任务有一个失败,怎么让页面还能显示?
- 举一个适合用它的场景。其中一个下游服务很慢,你怎么设超时?超时之后,那个慢任务还在跑吗?
第四问可以参考第九节末尾的应用场景候选。
练习:请在自己的笔记中写出答案,再展开参考内容核对。
九、常见追问
每问先自己答,再展开核对。答案都在前面各节里。
关于编排(第一、二节)
allOf(全部完成方法) 返回什么?怎么拿到每个任务的结果?
返回 CompletableFuture<Void>,本身不带结果。等它完成后,对每个任务分别 join(等待完成方法) 取结果。
两个互不依赖的结果要合并,用什么?前后有依赖的两个异步步骤呢?
互不依赖用 thenCombine(结果合并方法);前后有依赖、下一步也是异步的,用 thenCompose(异步衔接方法)。
关于线程(第三节)
公共池默认有多少线程?为什么不适合放远程调用?
没有另行设置时是处理器数减一,至少一个。整个应用共享,远程调用大部分时间在等网络,几个慢调用就能占满,影响其他业务。
写个 main 方法测试 supplyAsync(异步取值方法),没调用 join(等待完成方法) 就结束了,为什么任务好像没执行?
公共池的工作线程是守护线程。main 线程结束后,如果没有别的非守护线程,JVM(Java 虚拟机) 就开始关闭,不会等守护线程把任务跑完。
thenApply(结果变换方法) 和 thenApplyAsync 有什么区别?
thenApply(结果变换方法) 可能在完成上一步的线程或调用方线程里执行;thenApplyAsync 放到公共池执行,传了线程池就放到那个线程池。后续步骤是慢操作时用 Async 版本并传自己的线程池。
关于异常与超时(第四、五节)
exceptionally(异常兜底方法)、handle(结果与异常处理方法)、whenComplete(完成回调方法) 怎么选?
只想在出错时给个替代值,用 exceptionally(异常兜底方法);成功失败都要处理、并算出新结果,用 handle(结果与异常处理方法);只想记录、不改变结果,用 whenComplete(完成回调方法)。
exceptionally(异常兜底方法) 回调里拿到的异常,为什么有时不是我抛的那个?
它可能被包了一层 CompletionException(完成异常),原始异常在 getCause() 里。判断异常类型前先拆一层。
orTimeout(超时失败方法) 到时之后,原来的任务还在跑吗?要真正释放线程怎么办?
还在跑。orTimeout(超时失败方法) 只让这个结果以 TimeoutException(等待超时异常) 完成,不停止任务;cancel(true) 也不会中断。要释放线程,得给远程调用本身设超时,比如 HTTP 读超时、SQL 超时。
应用场景:可能用在哪里(候选)
四个候选场景,和两个必须会说的限制
- 办件详情页、首页工作台聚合: 一个页面要从办件、电子证照、审批流程、待办等多个子系统取数据,互不依赖,可以并行查询、单项降级。
- 多部门联审的信息汇总: 同一件事要分别查几个部门的接口(比如市场监管、税务、社保的核验结果),全部返回后再判断能否进入下一环节。
- 先查后查的依赖链: 先查办件拿到申请人证件号,再用证件号查电子证照库,就是 thenCompose(异步衔接方法) 的场景。
- 批量导出前的数据准备: 导出前要从几张大表分别取数,可以并行取数,再合并生成文件。注意它和第7课“后台排队处理”的区别:这里是为了让一次请求更快,不是为了削峰。
限制一: 并行只能缩短“等待”的总时间,下游服务的压力并没有减少,反而是同一时刻打过去更多请求。下游扛不住时,要配合限流。
限制二: CompletableFuture(可编排的异步结果) 里的任务跑在别的线程上。一些依赖“当前线程”的东西,比如 ThreadLocal 里存的登录用户、日志追踪编号,默认不会自动带过去,需要自己传。ThreadLocal 按线程保存数据,换了线程就取不到。(本课没有展开,先知道有这个问题即可。)
十、隔日复测:明天换条件再答(约 5 分钟)
展开题目
- 两个互不依赖的查询,一个 400 毫秒、一个 150 毫秒,用 CompletableFuture(可编排的异步结果) 并行查再合并,理想耗时约多少?用哪个方法合并?
- CompletableFuture(可编排的异步结果) 失败后,join(等待完成方法) 和 get(获取结果方法) 分别抛什么?哪个必须写 try-catch?
- 第7课:2 个消费者,生产者放完任务后只放了 1 个结束标记。会发生什么?
- 第5课:corePoolSize(核心线程数) 是 6,池里现在有 5 个工作线程,其中 2 个空闲。新任务来了,线程池先做什么?
主来源与引用
[1] CompletableFuture(可编排的异步结果) 类说明: 官方文档,包括非 async 方法由谁执行、不带 Executor(执行器接口) 的 async 方法用公共池、cancel 等同异常完成、get(获取结果方法) 与 join(等待完成方法) 的异常类型。原文见官方摘录 A 节。
[2] CompletionStage(完成阶段接口) 类说明: 官方文档,包括 apply/accept/run、then/combine/either、依赖不保证其他顺序、异常沿链传递并包成 CompletionException(完成异常)、handle(结果与异常处理方法) 与 whenComplete(完成回调方法) 的写法。摘录 B 节。
[3] 单个方法说明: thenApply(类比 map)、thenCompose(类比 flatMap)、thenCombine(结果合并方法)、handle(结果与异常处理方法)、exceptionally(异常兜底方法)、whenComplete(完成回调方法)、supplyAsync(异步取值方法)、join(等待完成方法)、allOf(全部完成方法)、cancel、orTimeout(超时失败方法)。摘录 C 节。
[4] ForkJoinPool(分治线程池) 公共池: 官方文档的公共池说明、commonPool() 方法说明;默认并行度表达式与工作线程守护属性来自 Java 17/21 源码。摘录 D 节。
[JG] JavaGuide: CompletableFuture(可编排的异步结果) 详解,用于 Future(异步结果句柄) 局限的概括、自定义线程池与异常处理建议。其线程池示例的无界队列不采纳。
本课速查表 · 完整术语表 · 第7课 · 第5课 · 第6课
学完后,记下判断理由和仍不确定的地方。隔日回看时先尝试回答,再核对课程里的解释。