《Java 并发编程·JUC 源码与容器》13.3 异常传播与取消

异常为什么被包装成 AltResult 而不是单独存一个字段?join 抛 CompletionException、get 抛 ExecutionException,这条边界在源码里画在哪?本节从 encodeThrowable、encodeRelay 讲到 cancel 与 orTimeout,并用异常链实验说明回调里拿到的异常类型为什么不一致。

承接的问题

13.1 说 result 一个字段有四种取值,其中一种是 AltResult。13.2 说异步阶段可能在别的线程上跑。两件事合起来,引出一串关于失败的问题:

  1. 异常为什么不单独存一个字段,而要包成"结果"?
  2. join 抛 CompletionException、get 抛 ExecutionException,这条分界在源码哪里?
  3. cancel、completeExceptionally、orTimeout 三条失败路径,语义差在哪?

这三问的答案决定了你在写 exceptionally / handle 时能不能正确处理异常——因为回调里拿到的异常类型并不统一。

AltResult:异常被当成结果

// JDK 25 源码摘录,已删减
static final class AltResult { // See above
    final Throwable ex;        // null only for NIL
    AltResult(Throwable x) { this.ex = x; }
}

static final AltResult NIL = new AltResult(null);

final boolean completeThrowable(Throwable x) {
    return RESULT.compareAndSet(this, null, encodeThrowable(x));
}

static AltResult encodeThrowable(Throwable x) {
    return new AltResult((x instanceof CompletionException) ? x :
            wrapInCompletionException(x));
}

把异常塞进 result 有三个直接好处:

  • 完成动作仍然是一次 CAS。 正常完成 completeValue、异常完成 completeThrowable、取消,都是 RESULT.compareAndSet(this, null, ...)。如果异常单独一个字段,就要保证"值字段为空"和"异常字段为空"两件事同时成立,多字段没有单一原子的写法。
  • 触发逻辑不用分支。 postComplete 只关心"result 非 null 了吗",不关心里面是值还是异常;每个 Completion.tryFire 自己解包。整条链的推进机制与成功/失败无关。
  • NIL 解决 null 值的编码。 正常完成但值是 null,result 不能是 null(那是"未完成"),于是用 NIL 这个 ex == null 的单例。isCompletedExceptionally() 因此写成 r instanceof AltResult && r != NIL——先排除 NIL,剩下的 AltResult 都是异常。

encodeThrowable 无条件把非 CompletionException 包一层。选 CompletionException 而不是别的,是因为它是 RuntimeException:Function、Consumer、Supplier 的函数式接口签名不允许抛受检异常,包装成非受检类型之后,下游回调才能在签名不变的前提下把失败传下去。

encodeThrowable 与 encodeRelay

同样是"编码异常",源码里有两个版本,用途不同:

// JDK 25 源码摘录,已删减
static Object encodeThrowable(Throwable x, Object r) {
    if (!(x instanceof CompletionException))
        x = wrapInCompletionException(x);
    else if (r instanceof AltResult && x == ((AltResult)r).ex)
        return r;
    return new AltResult(x);
}

static Object encodeRelay(Object r) {
    Throwable x;
    if (r instanceof AltResult
        && (x = ((AltResult)r).ex) != null
        && !(x instanceof CompletionException))
        r = new AltResult(wrapInCompletionException(x));
    return r;
}

encodeThrowable(x, r) 比单参数版本多一个判断:如果 x 已经是 CompletionException,并且它就是源 future 里存的那个对象(x == ((AltResult)r).ex),直接返回 r,不新建。

这有两个作用。第一是省一次分配:一条十级的失败链,每一级都要传播异常,能复用就复用。第二是保住了异常对象的同一性——AltResult 换了新实例,但里面的 Throwable 还是同一个,getCause()、堆栈、suppressed 全都不变。如果每级都重新 new CompletionException(x),你会得到一条"异常套异常"的长链,日志里几乎没法看。

encodeRelay 走的是另一条路:它用于 copy()、minimalCompletionStage() 和 thenCompose 的内层结果搬运。它只在"源里的异常不是 CompletionException“时才包一层。这意味着:

  • 普通 action 抛出的异常,源里已经是 CompletionException,relay 原样搬。
  • completeExceptionally(new IllegalStateException()) 塞进去的是裸异常,经过 copy() 之后会变成 CompletionException。

同样一个失败,经过 copy() 和不经过,回调看到的类型不一样。 这不是 bug,是"下游只应该见到 CompletionException“这条约定在补漏,但补漏只发生在 relay 路径上。

join 与 get 的包装边界

join 和 get 读的是同一个 result,差别只在解码函数:

// JDK 25 源码摘录,已删减
private static Object reportJoin(Object r, String details) {
    if (r instanceof AltResult) {
        Throwable x;
        if ((x = ((AltResult)r).ex) == null)
            return null;
        if (x instanceof CancellationException)
            throw new CancellationException(details, (CancellationException)x);
        if (x instanceof CompletionException)
            throw (CompletionException)x;
        throw wrapInCompletionException(x);
    }
    return r;
}

// reportGet 中对应的分支
if (x instanceof CancellationException)
    throw new CancellationException(details, (CancellationException)x);
if ((x instanceof CompletionException) && (cause = x.getCause()) != null)
    x = cause;
throw wrapInExecutionException(x);

三条规则可以背下来:

存进去的join() 抛出get() 抛出
CompletionException它本身ExecutionException,cause 是它的 cause
裸异常(如 TimeoutException)新的 CompletionException 包着它ExecutionException 包着它
CancellationException新的 CancellationException,cause 是原来那个同左

注意 CancellationException 的判断在 CompletionException 之前。取消走的是特殊通道,不会被包成 CompletionException,也不会被包成 ExecutionException——它在两种取结果方式下表现一致。另外两处 new CancellationException(details, ...) 都是新建实例,details 就是 "join" / "get" 字符串,所以堆栈指向的是你调用 join 的位置,而不是取消发生的位置。

cancel 与 completeExceptionally

// JDK 25 源码摘录,已删减
public boolean cancel(boolean mayInterruptIfRunning) {
    boolean cancelled = (result == null) &&
        internalComplete(new AltResult(new CancellationException()));
    postComplete();
    return cancelled || isCancelled();
}

public boolean completeExceptionally(Throwable ex) {
    Objects.requireNonNull(ex);
    boolean triggered = internalComplete(new AltResult(ex));
    postComplete();
    return triggered;
}

两者都是"CAS 一次 + 倒栈”,区别全在语义:

  • cancel 存的是 CancellationException,completeExceptionally 存的是你给的异常本身(不包装)。 前者让 isCancelled() 为真,后者不会。
  • cancel 的返回值是 cancelled || isCancelled()。 所以对一个已取消的 future 再调一次 cancel,返回的仍是 true;completeExceptionally 在已完成时返回 false。返回值语义不一致,别用 cancel 的返回值判断"这次调用是否真的改变了状态”。
  • mayInterruptIfRunning 完全无效。 源码注释写明 “this value has no effect in this implementation because interrupts are not used to control processing”。supplyAsync 提交的任务不会被中断,它会继续跑完,只是结果被丢弃。

完整程序:

import java.util.concurrent.Callable;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.TimeUnit;

/** 异常包装、取消与超时的行为对照。 */
public final class ExcProbe {
    static String chain(Throwable t) {
        StringBuilder sb = new StringBuilder();
        for (int i = 0; t != null && i < 6; i++, t = t.getCause()) {
            if (i > 0) sb.append(" <- ");
            sb.append(t.getClass().getSimpleName());
        }
        return sb.toString();
    }

    static void show(String label, Callable<?> action) {
        try {
            System.out.println(label + " -> " + action.call());
        } catch (Throwable t) {
            System.out.println(label + " -> throws " + chain(t));
        }
    }

    static String seen(Throwable ex) {
        return ex.getClass().getSimpleName() + "/cause="
                + (ex.getCause() == null ? "null" : ex.getCause().getClass().getSimpleName());
    }

    public static void main(String[] args) throws Exception {
        CompletableFuture<String> boom = CompletableFuture.completedFuture("x")
                .thenApply(s -> { throw new IllegalStateException("boom"); });
        show("join", boom::join);
        show("get", boom::get);
        System.out.println("handle 见到 " + boom.handle((v, ex) -> seen(ex)).join());

        CompletableFuture<String> injected = new CompletableFuture<>();
        injected.completeExceptionally(new IllegalStateException("injected"));
        System.out.println("completeExceptionally 后 handle 见到 "
                + injected.handle((v, ex) -> seen(ex)).join());

        CompletableFuture<String> cancelled = new CompletableFuture<>();
        CompletableFuture<String> dependent = cancelled.thenApply(s -> s + "!");
        System.out.println("cancel -> " + cancelled.cancel(true) + ", 再 cancel -> " + cancelled.cancel(true));
        show("cancelled.join", cancelled::join);
        show("dependent.join", dependent::join);

        CompletableFuture<String> slow = new CompletableFuture<>();
        long t0 = System.nanoTime();
        slow.orTimeout(300, TimeUnit.MILLISECONDS);
        show("orTimeout 后 join", slow::join);
        System.out.println("  耗时(单次运行,不作基准) = "
                + TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - t0) + "ms");
        System.out.println("  isCancelled=" + slow.isCancelled()
                + ", exceptionNow=" + slow.exceptionNow().getClass().getSimpleName()
                + ", 超时后 complete(\"late\") = " + slow.complete("late"));

        CompletableFuture<String> timed = new CompletableFuture<>();
        timed.orTimeout(100, TimeUnit.MILLISECONDS);
        System.out.println("orTimeout 后 exceptionally 见到 " + timed.exceptionally(ex -> seen(ex)).join());

        CompletableFuture<String> fallback = new CompletableFuture<>();
        fallback.completeOnTimeout("default-value", 200, TimeUnit.MILLISECONDS);
        System.out.println("completeOnTimeout -> " + fallback.join()
                + ", isCompletedExceptionally=" + fallback.isCompletedExceptionally());
    }
}
javac --release 25 -d out ExcProbe.java && java -cp out ExcProbe

本机输出:

join -> throws CompletionException <- IllegalStateException
get -> throws ExecutionException <- IllegalStateException
handle 见到 CompletionException/cause=IllegalStateException
completeExceptionally 后 handle 见到 IllegalStateException/cause=null
cancel -> true, 再 cancel -> true
cancelled.join -> throws CancellationException <- CancellationException
dependent.join -> throws CompletionException <- CancellationException
orTimeout 后 join -> throws CompletionException <- TimeoutException
  耗时(单次运行,不作基准) = 307ms
  isCancelled=false, exceptionNow=TimeoutException, 超时后 complete("late") = false
orTimeout 后 exceptionally 见到 TimeoutException/cause=null
completeOnTimeout -> default-value, isCompletedExceptionally=false

第三行和第四行放在一起,是本篇最该记住的一条:同样是"异常完成",action 里抛出来的异常进回调时是 CompletionException,completeExceptionally 塞进去的却是裸异常。 写 handle 时如果直接 ex.getMessage(),两种情况的输出不一样;要拿根因,得先判断 instanceof CompletionException 再取 getCause()。第 13.2 节 NotifyOrchestrator 里 ex.getClass().getSimpleName() 打印出 CompletionException 而不是 NullPointerException,就是这个原因。

第五行的 cancel -> true 出现两次,正是 cancelled || isCancelled() 的效果。第六行的 CancellationException <- CancellationException 是两个不同实例:外层是 join 新建的,cause 是 cancel 存进去的那个。

orTimeout 与 completeOnTimeout 的实现路径

// JDK 25 源码摘录,已删减
public CompletableFuture<T> orTimeout(long timeout, TimeUnit unit) {
    arrangeTimeout(unit.toNanos(timeout), // Implicit null-check of unit
                   new Timeout<T>(this, null, true));
    return this;
}

private <U> void arrangeTimeout(long nanoDelay, Timeout<U> onTimeout) {
    ForkJoinPool e = ASYNC_POOL;
    if (result == null) {
        ScheduledForkJoinTask<Void> t =  new ScheduledForkJoinTask<Void>(
            nanoDelay, 0L, true, onTimeout, null, e);
        whenComplete(new Canceller(t));
        e.scheduleDelayedTask(t);
    }
}

static final class Timeout<U> implements Runnable {
    public void run() {
        if (f != null && !f.isDone()) {
            if (exceptional)
                f.completeExceptionally(new TimeoutException());
            else
                f.complete(value);
        }
    }
}

四个要点:

  • orTimeout 返回的是 this,不是新 future。 它修改的是原对象。如果你的 future 被多个调用方共享,超时会同时影响所有人;想隔离就先 copy()。
  • 已完成时什么都不做。 arrangeTimeout 第一句就是 if (result == null),所以对已完成的 future 调用 orTimeout 不会调度任何任务。
  • 调度走的是 JDK 25 新增的 DelayScheduler。 scheduleDelayedTask 把任务交给 common pool 内部的一条延迟调度线程(四叉堆按触发时间排序),第三个参数 isImmediate = true 表示到点后直接在调度线程上执行 Timeout.run(),不再转到 worker。JDK 9–21 的实现里有一个静态 delayer 执行器,本机 CompletableFuture.java 中 grep delayer 为 0 处,这条路径已经整体换掉了。
  • 正常完成会取消定时任务。 whenComplete(new Canceller(t)) 在 future 先完成时把调度任务取消,避免堆积。Canceller 是 BiConsumer,两个参数都不用,只做 f.cancel(false)。

completeOnTimeout 与它共用 arrangeTimeout,只是 Timeout 的 exceptional 标志为 false,到点后走 f.complete(value)——正常完成,不是异常。所以 isCompletedExceptionally() 返回 false,后续的 exceptionally 也不会触发。这两者不是"超时的两种写法",而是"超时算失败"与"超时给默认值"两种截然不同的语义。

在 libraryd-conc 里落地

通知扇出的每个下游都要有独立超时,且超时不能让整条链失败:

package com.plume.conc.core;

import java.util.List;
import java.util.Map;
import java.util.concurrent.CompletableFuture;
import java.util.concurrent.Executor;
import java.util.concurrent.TimeUnit;

/** 每个下游独立超时;超时按失败处理,但不影响其余分支。 */
public final class TimedNotify {

    public record Delivery(String channel, boolean ok, String detail) {}

    public interface Notifier {
        Delivery send(Loan loan);
    }

    private final Executor io;
    private final Map<String, Notifier> notifiers;
    private final List<String> channels = List.of("mail", "sms", "push");

    public TimedNotify(Executor io, Map<String, Notifier> notifiers) {
        this.io = io;
        this.notifiers = notifiers;
    }

    public CompletableFuture<List<Delivery>> fanOut(Loan loan) {
        List<CompletableFuture<Delivery>> parts = channels.stream()
                .map(ch -> CompletableFuture
                        .supplyAsync(() -> notifiers.get(ch).send(loan), io)
                        .orTimeout(800, TimeUnit.MILLISECONDS)
                        .handle((d, ex) -> ex == null
                                ? d
                                : new Delivery(ch, false, rootCause(ex).getClass().getSimpleName())))
                .toList();
        return CompletableFuture.allOf(parts.toArray(CompletableFuture[]::new))
                .thenApply(ignored -> parts.stream().map(CompletableFuture::join).toList());
    }

    private static Throwable rootCause(Throwable ex) {
        return (ex instanceof java.util.concurrent.CompletionException && ex.getCause() != null)
                ? ex.getCause() : ex;
    }
}

这段代码里有三个刻意的选择:

  1. orTimeout 用在 supplyAsync 的返回值上。 那是这条分支独有的 future,改它不会波及别人;如果把上游共享的 future 直接 orTimeout,等于给所有依赖方都设了超时。
  2. 用 handle 而不是 exceptionally。 两者拿到的异常类型都会因来源而异(见上面的实验),但 handle 同时拿到值和异常,能把"成功"和"失败"收敛成一个 Delivery,下游 allOf 就不用关心异常。
  3. rootCause 显式拆一层 CompletionException。 因为 exceptionally/handle 里既可能是包装过的异常,也可能是 orTimeout 塞进去的裸 TimeoutException,不拆就会在日志里写出一串 CompletionException,看不出真正原因。

这个设计的代价

第一,异常类型不稳定。 同一个 handle 回调,可能收到 CompletionException、裸 TimeoutException、CancellationException,取决于失败是从哪条路径进来的。库代码里必须统一归一化,不能假设 ex 就是业务异常。

第二,包装会掩盖原始类型。 instanceof LoanException 在 handle 里可能为 false,因为它被包在 CompletionException 里。这也意味着 catch (LoanException e) 在 CF 链上不起作用——join 抛出的是 CompletionException,类型匹配不到你的业务异常。

第三,cancel 不停止工作。 它只改结果,任务照跑。对耗资源的任务(大查询、外部调用),取消之后资源仍在消耗,必须自己配合中断或超时;mayInterruptIfRunning 在这个实现里是装饰品。

第四,orTimeout 的定时任务不是免费的。 每次调用都会创建一个 ScheduledForkJoinTask,并在 common pool 的延迟堆里排队。高频、短超时的场景(比如每请求 3 个下游、每个都 orTimeout)会在这条调度线程上形成热点。真正的高频超时控制应该做在客户端层(连接超时、读超时),而不是给每个 future 挂一个定时器。

最后,什么时候不要用这套异常机制。 需要区分"业务失败"和"系统失败"的流程,AltResult 只有一个 Throwable,没有错误码字段,硬塞只能靠异常子类;需要重试的流程,orTimeout 不会告诉你"超时了几次",它只完成一次;还有需要原始堆栈做归因的场景,多层 CompletionException 会让堆栈变得很长,最好在入口处就用 rootCause 归一化并记录一次。

小结

AltResult 把异常编码成"结果",让完成动作保持单次 CAS、让依赖触发与成功失败无关,NIL 用来区分"值是 null"和"未完成"。encodeThrowable 统一包装成 CompletionException,双参数版本在传播时复用源对象以保住异常同一性;encodeRelay 则在 copy/relay 路径上补齐这层包装。join 抛 CompletionException、get 抛 ExecutionException,取消在两者中都保持 CancellationException。cancel 只改结果不停止任务,completeExceptionally 存入裸异常,orTimeout 用 common pool 的 DelayScheduler 在到点后调 completeExceptionally(new TimeoutException()) 并返回 this。写恢复逻辑时,永远先归一化再判断类型。

阅读导航:上一节 13.2 异步任务的执行器选择 | 下一节 14.1 超时、重试与熔断的并发实现

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「books」更多文章

  1. 《Java 消息中间件·事件驱动架构》附录 B 架构决策记录模板
  2. 《Java 消息中间件·事件驱动架构》附录 A 术语表
  3. 《Java 消息中间件·事件驱动架构》15.3 全书验收