读完这篇,你能做什么
能组合两个独立调用与一个依赖调用,并明确失败和超时如何传播。
学习理由、准备要求与知识点
为什么学它
异步组合容易混淆展开、汇合和取消,正确的完成状态不等于底层任务已经停止。
先读这些内容
知识点清单
thenApplythenComposethenCombineallOf- 专用执行器
- 异常传播
- 超时完成
- 取消边界
运行前先确认JDK17 CompletableFuture与CompletionStage,源码固定17.0.12+7。示例只运行本地任务与完成阶段,不调用远端服务;取消和超时不被解释为远端副作用已停止。
01先弄懂它是什么
Future 代表稍后才得到的结果,CompletableFuture 还能写出结果之间怎样接续。先查用户再查他的订单,是前后依赖;同时查商品和库存再一起展示,是两个独立结果汇合。把关系写清后,让前一步完成再触发后一步;如果刚提交就立即 get 等待,当前线程仍会被堵住。
三个常用方法分别对应三种关系:thenApply 把已有值转成另一个值;thenCompose 在已有值上发起新的异步操作,并接到它的最终结果;thenCombine 等两个独立结果都完成后合并。若返回 Future 的函数用了 thenApply,就会得到一层套一层的 Future,外层完成不表示内层工作也已结束。
异步API不自动提供无限并发或可靠取消。工作仍由某个线程或执行器执行,任务可能排队、失败、超时和保留大量上下文。Future完成状态与底层业务动作也不总是同步:把Future标成取消,不代表发送到支付服务的请求可以被撤回。因此必须同时设计执行资源、结果语义和业务恢复方式。
文中这几个词,先认一认
- 完成阶段 CompletionStage
- 一次计算,以及等它完成后才继续做的后续计算,可以表达值如何沿步骤流动。
- 组合 Compose
- 前一步得到值后再发起异步任务,并把内层结果接成一个最终结果,避免 Future 套 Future。
- 异常完成
- 这个阶段以失败结束,依赖正常结果的后续转换通常不会再执行。
- 汇合 Fan-in
- 等多个独立任务都给出所需结果,再按约定合成一个结果。
02看看它怎么工作
非Async回调可能由完成前一阶段的线程执行,也可能在注册时因前一阶段已完成而立即执行。带Async但未指定执行器通常使用默认异步设施,不能假设它就是某个业务专用线程池。耗时阻塞工作应进入受控执行器,短小转换可以沿完成线程运行,避免无意义线程切换。
异常处理也会改变图的语义。whenComplete主要观察结果,但观察回调自身抛错也可能影响依赖阶段;handle同时接收结果和异常并返回新值,exceptionally只在异常路径恢复。用exceptionally返回空列表会把失败变成正常值,调用方若无法区分就可能把“查询失败”解释成“没有订单”。
allOf 要等所有输入阶段都结束,包括正常结束和异常结束。它返回的 Void 只表示没有汇总值,各个结果仍要分别取出;某个任务先失败,也不代表 allOf 会立刻结束。anyOf 则跟随任意一个先完成的阶段,这个阶段也可能失败;没有输入时,它会一直保持未完成。
给 Future 设置超时,改变的是它对外报告的完成状态,底层工作未必停下。若多个分支共用同一个 Future,直接给原对象加超时,也可能影响其他分支。
正在加载流程图…
查看流程图源码(Mermaid)
flowchart LR
U["异步取得用户"] --> C["thenCompose 查询该用户订单"]
P["异步取得商品"] --> B["thenCombine 组合商品与库存"]
S["异步取得库存"] --> B
C --> H1["handle 形成成功或失败结果"]
B --> H2["handle 形成成功或失败结果"]
H1 --> A["allOf等待全部转换后阶段"]
H2 --> A
A --> R["按原请求顺序汇总"]按顺序看,每一步做了什么
- 画出任务依赖图,区分串行依赖与可并行部分。
- 为阻塞任务指定有界执行资源和总时间预算。
- 用Apply、Compose、Combine表达相应依赖。
- 明确成功、失败、取消与降级结果的类型。
- 在系统边界等待或返回异步结果,并观察全部异常与资源清理。
03对照实现代码
OpenJDK17.0.12+7 · CompletableFuture#uniComposeStage / UniCompose / UniRelay
原创公开API组合模型。真实源码用完成节点与结果编码连接依赖,模型不复刻其无锁节点管理、执行器调度或内部异常编码。
import java.util.concurrent.*;
import java.util.function.Function;
final class ComposeModel {
static <T,U> CompletableFuture<U> flatten(CompletableFuture<T> first,
Function<T,CompletableFuture<U>> next) {
CompletableFuture<U> result=new CompletableFuture<>();
first.whenComplete((value,error) -> {
if (error!=null) { result.completeExceptionally(error); return; }
// 前段失败时不执行依赖它的正常业务函数。
try {
next.apply(value).whenComplete((second,failure) -> {
if (failure!=null) result.completeExceptionally(failure);
else result.complete(second); // 结果跟随内层阶段,不返回嵌套Future。
});
} catch(Throwable failure) { result.completeExceptionally(failure); }
// 函数本身抛异常也要使最终阶段结束。
});
return result;
}
}uniComposeStage先创建目标阶段d,再检查源result是否已完成。尚未完成时注册UniCompose节点,已经完成时可以沿快速路径处理。这个分支说明回调执行时机依赖注册与完成的竞态,不能假设所有回调都一定稍后由另一线程执行。
正常结果交给函数 f 后得到内层阶段 g。g 已完成就把结果接到目标 d;还没完成就注册 UniRelay,等它完成后再传过去。Compose 连接的是完成关系,没有在里面调用 g.get 阻塞当前线程,这也是少量线程能管理许多等待任务的原因之一。
源阶段异常会传播到目标,函数调用本身抛异常也会编码成异常完成。正常null值在内部有专门表示,不能把内部result为null与业务结果为null混同。应用最好通过类型区分“缺数据”“失败”和合法空值,减少调用链中含糊的null约定。
完成节点触发后会清掉部分引用,但没完成的阶段,或外部一直保存的 Future,仍可能留住 Lambda 捕获的大对象。排查长链路内存时,除了看线程,也要找谁一直持有未完成的起点。节点栈是实现细节,应用应通过公开 API 组合结果,不依赖内部布局。
代码先调用谁,再调用谁
thenCompose选择同步或异步执行参数uniComposeStage创建目标阶段并检查源状态- 源成功后调用函数取得内层阶段
- 内层未完成则注册Relay,完成后转接结果
- 任一对应异常按完成协议传播到目标阶段
04动手写一个例子
保持输入顺序的全结果异步汇总
输入若干CompletableFuture,等待全部结束后按原输入顺序返回Outcome列表,分别保留正常值和失败原因。完成顺序可以任意,正常null值合法,空列表立即完成,输入Future本身不能为null。
- 输入 1
A后完成值10,B先失败,C完成null- 输出 1
[成功10,失败原因,成功null]
- 输入 2
空Future列表- 输出 2
立即完成的空结果列表
为每个输入阶段注册handle,将成功或异常统一转换为一个正常完成的Outcome对象。转换后的阶段即便原任务失败也能正常结束,allOf因此只负责等待全部结果容器就绪。这里的恢复只是把错误作为数据保存,没有把业务失败冒充成功。
保留转换阶段列表的输入顺序。allOf完成后再按这个列表依次读取结果,输出位置就由请求顺序决定,而不是由线程调度决定。此时这些阶段都已完成,join用于读取已经就绪的结果,不会形成等待尚未完成子任务的池内阻塞。
处理异常时只剥离CompletionException和ExecutionException等包装层,保留真正业务异常对象。取消也是异常路径的一种,可在Outcome中识别CancellationException。该算法不主动取消输入,也没有给输入设置期限,调用者需要在上游明确总截止时间,否则一个永不结束的输入会让聚合一直等待。
为什么这样写是对的第i个转换阶段只代表第i个输入的最终结果;所有转换阶段完成后,按索引读取可得到完整且顺序稳定的结果列表。
每个输入创建一个结果容器和转换阶段,最终扫描一次;异常包装剥离还需与包装层数成正比的工作,不包含底层任务自身耗时。
保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。
展开收起代码 · Main.java · 完整可运行程序
长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。
import java.util.*;
import java.util.concurrent.*;
public class Main {
record Outcome<T>(T value, Throwable error) { boolean success() { return error==null; } }
static Throwable unwrap(Throwable error) {
while ((error instanceof CompletionException || error instanceof ExecutionException)
&& error.getCause()!=null) error=error.getCause();
return error;
}
static <T> CompletableFuture<List<Outcome<T>>> collect(List<CompletableFuture<T>> input) {
List<CompletableFuture<T>> snapshot=List.copyOf(input); // 固定输入次序并拒绝null阶段。
List<CompletableFuture<Outcome<T>>> outcomes=new ArrayList<>();
for (CompletableFuture<T> future:snapshot)
outcomes.add(future.handle((value,error) -> new Outcome<>(value,unwrap(error))));
// 正常null值合法;失败保留为显式结果,避免丢失异常。
CompletableFuture<?>[] all=outcomes.toArray(new CompletableFuture<?>[0]);
return CompletableFuture.allOf(all).thenApply(ignored -> {
List<Outcome<T>> result=new ArrayList<>(outcomes.size());
for (CompletableFuture<Outcome<T>> item:outcomes) result.add(item.join());
// allOf已确认每个转换阶段完成,按输入顺序读取就绪结果。
return List.copyOf(result);
});
}
static void check(boolean ok) { if (!ok) throw new AssertionError(); }
public static void main(String[] args) throws Exception {
CompletableFuture<Integer> a=new CompletableFuture<>(), b=new CompletableFuture<>();
CompletableFuture<List<Outcome<Integer>>> batch=collect(List.of(a,b));
b.complete(20); check(!batch.isDone()); a.complete(10);
List<Outcome<Integer>> ordered=batch.get(5,TimeUnit.SECONDS);
check(ordered.get(0).value()==10 && ordered.get(1).value()==20);
IllegalStateException error=new IllegalStateException("failed");
Outcome<Integer> failed=collect(List.of(CompletableFuture.<Integer>failedFuture(error))).join().get(0);
check(!failed.success() && failed.error()==error);
check(collect(List.of(CompletableFuture.<Integer>completedFuture(null))).join().get(0).success());
check(collect(List.<CompletableFuture<Integer>>of()).join().isEmpty());
CompletableFuture<Integer> cancelled=new CompletableFuture<>(); cancelled.cancel(true);
check(collect(List.of(cancelled)).join().get(0).error() instanceof CancellationException);
check(CompletableFuture.completedFuture(3).thenCompose(x -> CompletableFuture.completedFuture(x*2)).join()==6);
System.out.println("PASS 7");
}
}这些特殊情况也要试一试
- 输入为空时
allOf立即完成。 - 正常
null值与异常完成保持可区分。 - 完成顺序颠倒仍按输入顺序返回。
- 取消保留
CancellationException,未完成输入会继续等待。
进阶选读 · 限制并发的异步批处理调度器
场景一次请求要查询一万条记录,直接为每条创建远程任务会耗尽连接与内存。
做法维护最多k个活跃任务与一个待调度游标,每完成一个再补充一个;结果按原索引写入,失败策略与总截止时间由统一协调器决定。协调状态需要串行执行或原子协议,避免同步完成回调造成递归过深。
复杂度调度O(n),活跃任务O(k),若保留全部输出则仍需O(n)结果空间;流式消费可减少结果保留。
好处与代价限制并发牺牲瞬时扇出速度,却改善资源可控性;过小k会闲置容量,过大k会扩大排队与故障。
05项目里怎么用
在服务层返回CompletionStage时,明确谁拥有执行器和取消责任。不要每次调用都新建线程池,也不要在公共池里执行大量阻塞数据库或网络调用。执行器拒绝任务属于提交失败,可能在链构造过程中同步发生,需要纳入接口错误处理与监控。
总时间限制要覆盖所有分支和最后汇合。如果每个分支都给很长超时,再逐段等待,用户可能等得比预期久得多。共享 Future 上有些超时方法会改变原对象状态,影响其他调用者;需要时用独立派生阶段表达本次调用自己的等待期限。
错误恢复必须保留业务含义。可选推荐服务失败可返回带降级标记的空推荐,支付与库存失败则必须阻止成功状态。记录原请求ID、分支名称和错误分类,避免只打印一个包裹了多层CompletionException的堆栈,最终无人知道哪个依赖失败。
容易踩的坑
supplyAsync后立即get,把异步接口重新串行阻塞。thenApply返回Future却把外层完成当全部完成。- 误认为
allOf自动收集结果或第一个失败立即结束。 - 异常恢复为默认值后丢失业务失败含义。
cancel(true)后宣称底层远程调用已终止。
几种做法,怎么选
表格有四列,窄屏可左右滑动查看代价和适用条件。
| 方案 | 优势 | 代价与限制 | 适用条件 |
|---|---|---|---|
| 失败即终止的业务聚合 | 及时暴露关键失败 | 需要明确其他分支是否继续与如何清理 | 必须全部成功的核心操作 |
| 全结果Outcome汇总 | 保留每项成功与失败,便于部分结果 | 等待最慢输入,需要外部总预算 | 批量查询与评测报告 |
| 有限并发流水线 | 限制连接和内存占用 | 调度逻辑更复杂 | 大批量远程任务 |
06检验是否读懂了
先自己想、自己写,再展开答案核对。场景题是假设的练习情境;遇到还没学过的内容,可以做完基础后再回来。
先用自己的话解释
01thenApply与thenCompose何时产生不同类型?
展开参考答案与推导
函数返回异步阶段时,Apply得到嵌套阶段,Compose把内层完成接到最终阶段;普通值转换用Apply。
02非Async回调一定在调用者线程执行吗?
展开参考答案与推导
不一定,可能由完成前一阶段的线程执行,也可能因源已完成在注册路径执行,不能依赖固定线程。
03Future取消为什么不等于业务撤销?
展开参考答案与推导
完成状态不必直接控制底层计算,远端副作用可能已发生,需要独立取消协议与幂等查询。
换个情况,还会处理吗
01批量接口只看到一个异常,怎样保留全部失败?
展开参考答案与推导
给每个输入handle成Outcome,再allOf等待转换阶段并按索引汇总;同时为永不完成的输入配置业务时间预算。
02异步链偶尔跑到HTTP线程做长计算,如何定位?
展开参考答案与推导
检查非Async回调和已完成阶段的同步执行路径,把耗时工作放到明确的受控执行器。
03推荐失败被当作没有推荐,是否合理?
展开参考答案与推导
取决于产品契约,应区分真实空结果与降级空结果;核心业务失败不能靠默认值冒充成功。
写代码试一试
01实现全结果顺序汇总。
- 输入 / 条件
A后完成10,B先失败- 输出 / 预期
结果位置仍A、B
提示 每项handle成Outcome。
展开参考答案与推导
按正文allOf转换阶段后扫描,保留异常类型。
02写一个证明Compose等待内层完成的测试。
- 输入 / 条件
外层值3,函数返回尚未完成inner- 输出 / 预期
最终阶段未完成,inner完成6后结果6
提示 手工创建CompletableFuture控制时序。
展开参考答案与推导
无需sleep,先断言isDone为false,再complete内层并读取结果。
03实现两个独立查询的组合。
- 输入 / 条件
价格100,折扣80百分比- 输出 / 预期
应付80
提示 thenCombine,不在回调中get另一阶段。
展开参考答案与推导
用整数分或BigDecimal避免金额浮点误差,独立测试任一分支失败与正常零值。
读完之后,独立完成
课后作业:自己做一遍
本篇共 10 道作业,先完成 8 道基础与综合任务,再按前置知识挑战进阶题。其中包含 1 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。
能组合两个独立调用与一个依赖调用,并明确失败和超时如何传播。
本篇重点加练
失败恢复和取消的语义影响整条链的结果解释,完成状态并不代表外部工作被撤销。
- 异常传播
- 取消边界
重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。
先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。
基础必做
- 作业 01代码推演
查询用户后异步查询会员等级,分别写出会返回嵌套 Future 与扁平 Future 的方法签名,追踪内外层完成顺序。
- 提交内容
- 签名、时序和编译记录
- 怎样算完成
- 逐步注明泛型类型
- 包含内层异常
练到的知识点thenApply · thenCompose
- 作业 02原理问答
页面并行加载资料、订单和推荐,业务既需要组合资料与订单,又需要收集全部任务结局。比较两种组合操作提供的信息。
- 提交内容
- 依赖图与结果模型
- 怎样算完成
- 区分等待完成和获取结果
- 覆盖零任务与多任务失败
练到的知识点thenCombine · allOf
- 作业 03代码推演
同一计算链分别在主任务、恢复回调及完成通知回调抛异常。比较最终观察者能获得的异常来源与上下文,不预设所有恢复都成功。
- 提交内容
- 三条完成轨迹
- 怎样算完成
- 区分结果转换与旁路观察
- 保留原始异常身份
练到的知识点异常传播
综合应用
- 作业 04排错实验
建立两个命名执行器,让链中计算和阻塞查询分开执行,注入一个上游异常,记录每个回调线程及是否执行。
- 提交内容
- Java 程序与回调轨迹
- 怎样算完成
- 记录执行器配置
- 区分正常链与恢复链
练到的知识点专用执行器 · 异常传播
- 作业 05面试场景
聚合 Future 在 300 毫秒超时,下游模拟计算仍可能继续 1 秒。规定调用者响应、后台任务和资源释放分别应如何被观测。
- 提交内容
- 超时及取消状态表
- 怎样算完成
- 不把 Future 状态当作外部任务状态
- 包含最终完成后的记录
练到的知识点超时完成 · 取消边界
- 作业 06编程实践
编写仅使用本地桩的商品聚合接口:基础信息必需,推荐可选,库存需要独立失败表示;以确定性触发器覆盖成功、失败和超时组合。
- 提交内容
- 源码与场景结果记录
- 怎样算完成
- 结果类型保留失败来源
- 执行器在实验结束后可关闭
练到的知识点thenApply · thenCompose · thenCombine · allOf · 专用执行器 · 异常传播 · 超时完成 · 取消边界
- 作业 07设计评审
两个页面请求复用同一个正在加载的 Future,其中一个请求超时后取消共享对象。评审单个等待者和共享计算的生命周期契约。
- 提交内容
- 共享状态与取消规则
- 怎样算完成
- 包含另一个等待者仍需要结果
- 说明资源释放责任
练到的知识点取消边界
- 作业 08编程实践
写本地共享加载器演示,允许两个等待者独立退出并注入计算与恢复异常,记录各 Future 和底层任务的状态。
- 提交内容
- Java 代码与状态日志
- 怎样算完成
- 有确定性触发器及运行上限
- 不依靠 sleep 顺序推断取消效果
练到的知识点异常传播 · 取消边界
进阶挑战 · 掌握前置后选做
- 作业 09设计评审
批量聚合 1000 个对象时同时创建所有异步任务导致资源压力。设计并发预算与部分结果契约,并提出能检验资源上界的测试。
- 提交内容
- 流程图与资源验收方案
- 怎样算完成
- 区分任务总数和同时执行数
- 覆盖取消期间的新任务提交
练到的知识点allOf · 专用执行器 · 异常传播 · 超时完成 · 取消边界
思维拓展
先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。
- 作业 10思维拓展 · 条件变化
将聚合结果改为渐进式输出:部分内容已发送给客户端后才发生取消或失败。原先单个最终结果的错误语义需要怎样扩展?
- 提交内容
- 流式契约与边界样例
- 怎样算完成
- 区分已交付与未交付结果
- 覆盖客户端断开
练到的知识点异常传播 · 取消边界
完成后,再检验一次
给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。
继续阅读
先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。
- 必读参考文档英文
Java 17:CompletableFuture (新标签页)↗
阅读组合、异步执行器、超时与取消,明确阶段完成和底层任务终止的区别。
- 实践参考文档英文
Java 17:CompletionStage (新标签页)↗
阅读阶段组合和异常传播契约,区分转换、展开与汇合。
- 深入参考文档英文
Java 17:ExecutorService (新标签页)↗
阅读提交、关闭、取消和任务结果,明确线程池的生命周期责任。
- 深入参考文档英文
CompletableFuture固定源码 (新标签页)↗
阅读Compose与Relay如何连接完成结果。