用户进入商品页,服务需要同时查询多个商家的价格,再计算可用优惠。依次调用容易把等待时间累加;先创建所有异步任务,再逐个阻塞读取,又不一定能清楚表达依赖、失败和取消。CompletableFuture 提供的是组织这些阶段的能力,真正的工程难点仍然是每条路径如何结束。
这是“Java 并发工程”的第七篇。我们延续比价案例,用六项 Java 21 实验检查并行聚合、依赖阶段、异常、超时、取消与提交拒绝。商家响应使用本地可控任务,属于机制实验,没有调用真实电商服务,也没有给出网络性能结论。
先画任务图,再选方法名
商家甲和商家乙的询价互相独立,可以并行启动;最低价需要两个结果,属于汇合;优惠计算依赖选中的报价,属于后继任务;请求超时后使用旧快照,则属于失败策略。把这些关系画出来,比从几十个方法里逐个背名字更容易选择正确组合。
Future 主要提供结果状态与读取接口,CompletableFuture 还实现 CompletionStage,可以注册计算完成后的阶段。这并不意味着调用它就一定创建新线程:执行任务的是执行器,某些非 Async 阶段可以由完成前序任务的线程执行,也可能在注册时同步运行。
因此,设计任务图时应同时标注结果依赖和执行位置。一个很小的结果转换可以直接执行,一个可能阻塞的下游调用则需要明确的资源预算。把它们都写成相似的链式调用,不代表它们具有相同的运行成本。
并行启动与结果汇合
CompletableFuture<Integer> a =
CompletableFuture.supplyAsync(() -> queryA(), executor);
CompletableFuture<Integer> b =
CompletableFuture.supplyAsync(() -> queryB(), executor);
CompletableFuture<Integer> best = a.thenCombine(b, Math::min);
两个 supplyAsync 把任务提交给指定执行器,thenCombine 描述两个结果都正常完成后的计算。若立刻对第一个任务 get,拿到结果后才创建第二个任务,就没有形成预期的同时在途关系。顺序写出的代码是否并发,取决于任务何时启动以及在哪里等待。
实验让两个商家任务分别报告已启动,再一起等待释放信号。主线程确认两者都已进入后才释放,最终价格为九十九。这证明两个任务在逻辑上同时在途,不证明它们的每条指令在不同 CPU 核上同时执行,也不能由此计算真实服务的加速比。
当任务数量变化时,allOf 可以等待一组 Future 完成,但它本身不返回类型化的结果列表。应用仍需保留原 Future 集合,并在整体成功之后读取各结果。部分失败是否允许输出,必须由业务显式决定,不能把 allOf 当作自动容错聚合器。
thenApply 与 thenCompose 的边界
如果报价得到后只是进行本地转换,可以用 thenApply。若下一步本身又返回一个异步阶段,用 thenCompose 表达依赖并展平结果,避免得到嵌套的 Future。实验先产生一百的价格,再异步计算优惠后的九十。
var discounted = quote.thenCompose(price ->
CompletableFuture.supplyAsync(() -> price - 10, executor));
展平的是任务关系,不是取消线程与执行器的边界。被调用的服务如果另用线程池,仍需分析它自己的队列、超时和资源关闭。一个类型上看起来平整的链条,并不代表整条链共享同一项执行预算。
尤其应避免在资源很少的执行器任务内部,提交到同一个池后立刻阻塞等待后继结果。工作线程都被前序等待占满时,后继可能根本没有机会开始。表达依赖通常应让前序返回阶段,而不是把异步组合退回层层阻塞。
执行器决定资源隔离
没有显式指定执行器的异步方法通常使用默认异步设施;CompletableFuture 文档对 commonPool 并行度不足的情况还规定了后备行为。生产代码不应只根据方法名推断某个任务固定运行在哪个线程,更不能把全局共享执行器当作无限容量。
实验全部为商家任务提供显式线程池,方便观察所有权并在结束后关闭。它使用的固定线程池只是有限任务的教学设置,不是生产队列配置建议。真实服务还要考虑队列是否有界、拒绝策略、下游连接数和同机其他任务的资源竞争。
线程池只能限制同时执行的数量,不能自动阻止入口无限创建待处理任务。提交速率超过完成速率时,需要在入口或队列边界处理积压。关于完整线程池选型另行成篇,本篇只保留理解任务生命周期所必需的配置。
异常是任务图中的一条路径
前序阶段异常完成时,只处理正常值的后继通常不会执行。exceptionally 可把失败转换为替代值;handle 同时处理成功和失败;whenComplete 主要用于观察两种结果,但观察回调自身也可能抛出异常,必须理解它对返回阶段的影响。
实验构造一个商家不可用的失败阶段,确认正常转换没有得到值,读取下游时得到原始失败原因,再通过显式降级得到一百二十。替代价格只是教学约定,真实产品必须标明它来自缓存、默认值还是其他商家,避免把降级数据伪装成实时最低价。
异常观测应保留足够上下文,例如商家身份、阶段名称和取消原因。把所有异常都变成零,虽然让链条看似成功,却可能把零元商品展示给用户。正常结果和降级结果最好在业务类型中有清晰区分,日志不能成为唯一的语义载体。
get 与 join 都可能等待
get 通过受检异常表达中断与执行失败,并有带超时的重载;join 使用不同的异常包装方式,也可能一直等待。选择 join 不是让调用变成非阻塞,只是改变调用接口和异常处理方式。它们对中断的处理也不能概括为“只是编译器要求不同”。
实验统一使用带截止时间的 get,便于让失败用例有限结束并读取原始异常原因。业务边界如果本来就是同步 HTTP 接口,最终等待聚合结果可能合理,但等待时限必须与整个请求预算协调;在本就稀缺的异步工作线程中阻塞则需要额外评估。
“异步”描述任务如何组织与推进,不是某种自动消除等待的魔法。下游仍然需要时间,关键是等待由谁承担,其他工作能否继续,以及资源上限是否可控。
超时完成不等于底层计算停止
orTimeout 可以让 Future 在规定时间内未完成时异常完成。实验先启动一个等待门闩的商家任务,确认它已经开始,再设置超时。消费者观察到 TimeoutException 时,底层任务仍被门闩挡住;释放门闩后,任务继续完成,但 Future 保持原来的异常状态。
这是一条非常重要的边界:Future 的完成状态与执行中的业务函数不是同一个对象的全部生命周期。响应已经降级,后台还在访问商家,可能继续占用连接、线程和配额。真实 I/O 需要自己的超时或可取消句柄,不能只在最外层 Future 上加一个计时器。
completeOnTimeout 类似地提供超时替代结果,也不自动解决底层停止问题。两种方法作用于相应 Future 本身,若多个消费者共享它,需要考虑一个消费者的超时策略是否应影响其他消费者;必要时为消费者建立独立的阶段与策略。
cancel(true) 的名字不能代替契约
CompletableFuture 的 cancel 会使它以取消方式完成,但 mayInterruptIfRunning 在这个实现中不用于控制计算。因此不能把 cancel(true) 等同于一定向 supplier 的执行线程发出 interrupt。官方 API明确说明了这个参数的边界。
取消实验同样先确认任务已启动,再执行 cancel(true)。消费者得到 CancellationException,底层 supplier 仍在等待;释放门闩后它结束,记录到没有因这次取消被中断。断言发生在关闭执行器之前,避免把清理阶段的 shutdownNow 中断误认为 cancel 的效果。
如果业务要求取消传播,应在设计中保留可以取消的底层操作、共享取消标记或服务客户端提供的取消接口。回调链完成并不自动撤销已经发出的请求,更不保证外部系统没有执行写操作。对于带副作用的调用,还需要幂等与结果查询协议。
“谁快用谁”也需要定义失败策略
anyOf 对首先完成的阶段作出反应,这个完成可能是正常值,也可能是异常。某些 Either 组合围绕正常完成定义行为,同样不能简单当成通用的“忽略失败,找到第一个成功值”的算法。需要这种业务语义时,应显式记录候选状态和全部失败时的结果。
比价服务如果选择第一份报价,牺牲的是等待全部商家之后可能得到的更低价格;如果等待全部结果,又需要面对慢商家拖延。价格质量、响应预算和下游负载之间存在真实取舍,API 只能表达已选定的策略,不能替产品决定哪一种最好。
本篇主实验使用全部成功后的两路汇合。上述竞速语义作为选型边界说明,没有把未实现的竞速容错描述为已完成的实验能力。
六项实验与资源关闭
python3 run.py --work /tmp/java-concurrency-run --group FutureLab
本机六项通过:两路并行汇合得到九十九;依赖阶段得到九十;失败显式降级为一百二十;超时后底层任务仍可继续;取消后 supplier 未被自动中断;向关闭的执行器提交时同步抛出拒绝异常。详细结果见实测摘要。
最后一项提醒我们,失败不只发生在任务内部。提交本身就可能失败,还没来得及得到一个可连接后继的 Future。调用方需要同时处理提交边界与阶段完成边界,尤其是在服务关闭、限流或资源不足时。
所有门闩在 finally 中释放,执行器在测试作用域结束后关闭并等待终止。运行脚本另有进程级截止时间,失败不会依靠后台线程无限等待。正式服务中的共享执行器由组件生命周期管理,不能每次请求结束就关闭;资源所有权必须先确定。
面试归纳与系列回看
介绍 CompletableFuture 时,先画独立任务、依赖和汇合,再解释执行器位置,最后处理异常、超时、取消和关闭。只展示一条成功链不足以说明工程可用,底层任务在消费者放弃后如何结束往往更重要。
至此,七篇把共享变量、原子更新、锁、等待、线程上下文和任务图连成一条路径。面对实际并发代码,可以从不变量、发布关系、等待预算、上下文作用域和退出路径逐层检查,而不是从某个 API 名字推断它已经解决所有问题。