qinghuan
← JUC + JVM / 全部笔记

单元 95 / JUC + JVM

P1 · 重点提升L2 · 进阶

CompletableFuture:组合任务、传播失败与管理超时

按先后依赖和独立汇合来组织异步任务,再处理失败、线程选择和超时,避免 Future 完成与业务完成混淆。

本篇目录 · 按需要跳转

读完这篇,你能做什么

能组合两个独立调用与一个依赖调用,并明确失败和超时如何传播。

学习理由、准备要求与知识点

为什么学它

异步组合容易混淆展开、汇合和取消,正确的完成状态不等于底层任务已经停止。

先读这些内容

知识点清单

  • thenApply
  • thenCompose
  • thenCombine
  • allOf
  • 专用执行器
  • 异常传播
  • 超时完成
  • 取消边界

运行前先确认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,直接给原对象加超时,也可能影响其他分支。

正在加载流程图…

依赖用Compose,独立分支用Combine;先把错误显式化,再按业务规则汇总。
查看流程图源码(Mermaid)
flowchart LR
 U["异步取得用户"] --> C["thenCompose 查询该用户订单"]
 P["异步取得商品"] --> B["thenCombine 组合商品与库存"]
 S["异步取得库存"] --> B
 C --> H1["handle 形成成功或失败结果"]
 B --> H2["handle 形成成功或失败结果"]
 H1 --> A["allOf等待全部转换后阶段"]
 H2 --> A
 A --> R["按原请求顺序汇总"]

按顺序看,每一步做了什么

  1. 画出任务依赖图,区分串行依赖与可并行部分。
  2. 为阻塞任务指定有界执行资源和总时间预算。
  3. 用Apply、Compose、Combine表达相应依赖。
  4. 明确成功、失败、取消与降级结果的类型。
  5. 在系统边界等待或返回异步结果,并观察全部异常与资源清理。

03对照实现代码

thenCompose如何把内层阶段接到最终结果 (新标签页)↗

OpenJDK17.0.12+7 · CompletableFuture#uniComposeStage / UniCompose / UniRelay

原创公开API组合模型。真实源码用完成节点与结果编码连接依赖,模型不复刻其无锁节点管理、执行器调度或内部异常编码。

核心机制代码 · java
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 组合结果,不依赖内部布局。

代码先调用谁,再调用谁

  1. thenCompose选择同步或异步执行参数
  2. uniComposeStage创建目标阶段并检查源状态
  3. 源成功后调用函数取得内层阶段
  4. 内层未完成则注册Relay,完成后转接结果
  5. 任一对应异常按完成协议传播到目标阶段

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个输入的最终结果;所有转换阶段完成后,按索引读取可得到完整且顺序稳定的结果列表。

时间 组装与汇总O(n),墙钟等待取决于最慢输入完成时间额外空间 O(n)

每个输入创建一个结果容器和转换阶段,最终扫描一次;异常包装剥离还需与包装层数成正比的工作,不包含底层任务自身耗时。

保存为 Main.java,运行 javac --release 17 -encoding UTF-8 Main.java,再运行 java Main。程序包含固定输入自测,断言失败时会抛出错误。

展开收起代码 · Main.java · 完整可运行程序Java · 42 行

长行可在代码区域内左右滚动,按 Tab 可把焦点移入代码区。

Main.java · 完整可运行程序 · Java
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 道思维拓展,试着换个条件,看看原来的办法是否还管用。不附参考答案。

这次练什么

能组合两个独立调用与一个依赖调用,并明确失败和超时如何传播。

本篇重点加练

失败恢复和取消的语义影响整条链的结果解释,完成状态并不代表外部工作被撤销。

  • 异常传播
  • 取消边界

重点内容会多练几次。先说清楚怎么做,再试试空数据、出错或条件改变时会发生什么。

先自己做,卡住时记下问题,再回来查。问答题写清“为什么、什么时候适用”;动手题留下输入、输出和运行步骤,方便下次重做。

基础必做

  1. 作业 01代码推演

    查询用户后异步查询会员等级,分别写出会返回嵌套 Future 与扁平 Future 的方法签名,追踪内外层完成顺序。

    提交内容
    签名、时序和编译记录
    怎样算完成
    • 逐步注明泛型类型
    • 包含内层异常

    练到的知识点thenApply · thenCompose

  2. 作业 02原理问答

    页面并行加载资料、订单和推荐,业务既需要组合资料与订单,又需要收集全部任务结局。比较两种组合操作提供的信息。

    提交内容
    依赖图与结果模型
    怎样算完成
    • 区分等待完成和获取结果
    • 覆盖零任务与多任务失败

    练到的知识点thenCombine · allOf

  3. 作业 03代码推演

    同一计算链分别在主任务、恢复回调及完成通知回调抛异常。比较最终观察者能获得的异常来源与上下文,不预设所有恢复都成功。

    提交内容
    三条完成轨迹
    怎样算完成
    • 区分结果转换与旁路观察
    • 保留原始异常身份

    练到的知识点异常传播

综合应用

  1. 作业 04排错实验

    建立两个命名执行器,让链中计算和阻塞查询分开执行,注入一个上游异常,记录每个回调线程及是否执行。

    提交内容
    Java 程序与回调轨迹
    怎样算完成
    • 记录执行器配置
    • 区分正常链与恢复链

    练到的知识点专用执行器 · 异常传播

  2. 作业 05面试场景

    聚合 Future 在 300 毫秒超时,下游模拟计算仍可能继续 1 秒。规定调用者响应、后台任务和资源释放分别应如何被观测。

    提交内容
    超时及取消状态表
    怎样算完成
    • 不把 Future 状态当作外部任务状态
    • 包含最终完成后的记录

    练到的知识点超时完成 · 取消边界

  3. 作业 06编程实践

    编写仅使用本地桩的商品聚合接口:基础信息必需,推荐可选,库存需要独立失败表示;以确定性触发器覆盖成功、失败和超时组合。

    提交内容
    源码与场景结果记录
    怎样算完成
    • 结果类型保留失败来源
    • 执行器在实验结束后可关闭

    练到的知识点thenApply · thenCompose · thenCombine · allOf · 专用执行器 · 异常传播 · 超时完成 · 取消边界

  4. 作业 07设计评审

    两个页面请求复用同一个正在加载的 Future,其中一个请求超时后取消共享对象。评审单个等待者和共享计算的生命周期契约。

    提交内容
    共享状态与取消规则
    怎样算完成
    • 包含另一个等待者仍需要结果
    • 说明资源释放责任

    练到的知识点取消边界

  5. 作业 08编程实践

    写本地共享加载器演示,允许两个等待者独立退出并注入计算与恢复异常,记录各 Future 和底层任务的状态。

    提交内容
    Java 代码与状态日志
    怎样算完成
    • 有确定性触发器及运行上限
    • 不依靠 sleep 顺序推断取消效果

    练到的知识点异常传播 · 取消边界

进阶挑战 · 掌握前置后选做

  1. 作业 09设计评审

    批量聚合 1000 个对象时同时创建所有异步任务导致资源压力。设计并发预算与部分结果契约,并提出能检验资源上界的测试。

    提交内容
    流程图与资源验收方案
    怎样算完成
    • 区分任务总数和同时执行数
    • 覆盖取消期间的新任务提交

    练到的知识点allOf · 专用执行器 · 异常传播 · 超时完成 · 取消边界

思维拓展

先写出你的猜想,再用例子或实验验证。答案可以不同,但要说清依据、适用情况,以及还有什么没弄懂。

  1. 作业 10思维拓展 · 条件变化

    将聚合结果改为渐进式输出:部分内容已发送给客户端后才发生取消或失败。原先单个最终结果的错误语义需要怎样扩展?

    提交内容
    流式契约与边界样例
    怎样算完成
    • 区分已交付与未交付结果
    • 覆盖客户端断开

    练到的知识点异常传播 · 取消边界

完成后,再检验一次

给每题标记“独立完成 / 查阅后完成 / 尚未完成”。查过资料或没做出的题,记下卡在哪里。隔一天换一组输入再试,能解释结果和出错原因,再把这个知识点标为掌握。

继续阅读

先看能帮你完成当前练习的部分。例子跑通后,再读更深入的解释。

打开资料库,找下一份学习材料 ↗