承接的问题
13.1 说 result 一个字段有四种取值,其中一种是 AltResult。13.2 说异步阶段可能在别的线程上跑。两件事合起来,引出一串关于失败的问题:
- 异常为什么不单独存一个字段,而要包成"结果"?
join抛CompletionException、get抛ExecutionException,这条分界在源码哪里?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;
}
}
这段代码里有三个刻意的选择:
orTimeout用在supplyAsync的返回值上。 那是这条分支独有的 future,改它不会波及别人;如果把上游共享的 future 直接orTimeout,等于给所有依赖方都设了超时。- 用
handle而不是exceptionally。 两者拿到的异常类型都会因来源而异(见上面的实验),但handle同时拿到值和异常,能把"成功"和"失败"收敛成一个Delivery,下游allOf就不用关心异常。 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 超时、重试与熔断的并发实现
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。