责任链把「谁处理这个请求」从调用点挪到了运行时。调用方发出一次,节点自己决定接不接。
JDK 里这条链有三种落地形态,表达「拦住了」的方式各不相同:处理链返回 boolean,过滤链返回替换对象或 null,Stream 的 stage 链用反向询问的取消标记。
本文用 1 亿个元素量三种形态的每元素成本随链长的变化,用 8 节点链量命中位置的影响,再从 JDK 源码指出差别出自哪一行。

一、三种形态,三种收场

一个请求要穿过若干检查点,每个检查点各自判断:这个我能处理,或者这不归我管,交给下一个。责任链(Chain of Responsibility)把这条传递路径做成对象结构,但「传递」本身在 JDK 里有三条不同的实现路线。

处理链的节点返回 boolean。命中返回 true,调用方知道事情办完了;全部返回 false 就是没人管。停下来的信号沿返回值传回调用方。

过滤链的节点不报告「办没办成」,返回一个替换对象或者 null。返回新对象表示这一层改写了请求、需要重发;返回 null 表示这一层放行,继续看下一个节点。java.net.http 用这个形状:请求过滤器正序遍历,响应过滤器倒序遍历,任何一层想重发就中断整个遍历。

stage 链的方向是反的。数据往前推(accept 递给下游),停止的信号往回问。每个 stage 的 sink 不保存「还要不要」的状态,末端 sink 返回取消时,上游逐层转发这个回答。Stream 的 findFirstanyMatch 走这条路。

三条路线的表达能力不一样:形态一把停止点放在「哪个节点接下了」,形态二放在「哪个节点要重发」,形态三只有末端能终止,中间节点想让整条链提前结束,得靠 takeWhile 这种带状态的操作。

二、实验设计

机器与本机 JDK:

  • Apple M1 Pro,macOS Darwin 25.6.0,arm64
  • JDK 21.0.8+12-LTS-250 与 JDK 25+37-LTS-3491,各跑一遍
  • 每轮新起一个 JVM,先预热再计时,取 5 轮的中位数
  • 本机没有 JMH。这是单机微基准,用来判断量级和形状,不能当成硬件上限
  • 计时循环在全局锁内串行执行。锁能排除同机其它进程抢 CPU,排不掉频率调节与热噪声

三组实验:

  1. 链长:链长 1/2/4/8,每个元素走完整条链,共 1 亿个元素。同时用一份带计数器的链数出每元素实际调用了几个节点。这个计数是精确的,不受计时误差影响,用来确认「走满整条链」这一前提成立。
  2. 短路位置:固定 8 节点链,让元素分别在第 1、第 4、第 8 个节点被拦下,外加「没有一个节点拦得住」的穿透情况和「最后一个节点兜底」的对照。
  3. Stream 对照LongStream.range(0, 1e8) 上叠 1/2/4/8 个 map stage,再 filtersum()。对照组是把同样的运算写在一个方法里、由 JIT 内联的循环。另外测 findFirst 的短路:命中位置从数组第 1 个元素挪到第 2000 万个,以及完全不命中。

三种形态的节点语义不同,绝对数字不能横向比。能横向比的是各自随链长的增长斜率,那才是「链有多长要付多少钱」。

测量怎么自检

每组实验都带断言,跑错会直接抛异常终止 JVM:

  • Stream 每一组 stage 数下的求和结果,必须等于单方法内联的求和结果。8 个 stage 时这个数是 1,350,076,499,944。
  • 处理链的命中数必须等于元素数;短路实验里「命中第 k 个节点」的命中数必须是 1 亿。
  • findFirst 的 Stream 版和单方法版必须返回同一个值,不命中时都返回 -1。

这些断言拦住的是「写错了但跑得挺快」这类结果。基线跑对了,比较才有意义。所有数字和断言输出都在 /tmp/pattern-runs/ChainOfResp.log 里。

三、实测一:链长决定每元素成本

处理链

链上的节点是同一种类,每个持一个 (mask, tag),判断 (x & mask) == tag:命中返回 true,否则转给下一个;走完全部节点还没命中就返回 false。构造数据时只让最后一个节点命中,这样「走满整条链」是确定的,没有概率平均。1 亿个元素:

链长(节点数) JDK 21 JDK 25 JDK 21 每元素 JDK 25 每元素 节点调用/元素
1 318 ms 317 ms 3.18 ns 3.18 ns 1
2 412 ms 352 ms 4.12 ns 3.52 ns 2
4 666 ms 493 ms 6.67 ns 4.94 ns 4
8 1212 ms 821 ms 12.12 ns 8.22 ns 8

最后一列是带计数器的同一份链数出来的节点调用数,它精确等于链长,说明每个元素都走满了整条链。

这两个数不是估算。计数器在节点的 handle 方法第一行自增,链跑完后把所有节点的计数加起来。1 亿个元素、链长 8 得到 8 亿,链长 1 得到 1 亿,都是整数。计时会受调度和频率影响,计数不会,所以「走满整条链」这个前提不依赖计时。

从 1 个节点到 8 个节点,JDK 25 上每元素从 3.18 ns 涨到 8.22 ns,2.6 倍;JDK 21 上 3.8 倍。节点数只涨了 8 倍,耗时没跟上,前两个节点几乎是免费的。

原因是这条链的每一层只做一次位与、一次比较、一次方法调用,而 1 亿次迭代之间互相独立。M1 Pro 是乱序执行,前一个元素还没走完,后一个元素的链已经进入流水线,链的深度被盖住了一部分。斜率在 4 节点之后才显出来:每多一个节点,每元素多花约 0.72 ns。JDK 21 的斜率更陡,8 节点时 JDK 21 是 12.12 ns、JDK 25 是 8.22 ns。两个版本这条链的源码一致,差的是编译器版本。

每节点 0.72 ns 换算到 1 亿个元素上:链长 8 的处理链比链长 1 多花 0.5 秒。在这条形态上,链长不是主要成本。

JDK 自己的处理链:java.util.logging

java.util.logging 的 Handler 链是形态一的现成实现。给一个关闭了父传播的 Logger 挂 1/2/4/8 个空 Handler,预建一个 LogRecord,调 2000 万次 logger.log(record)

Handler 数 JDK 21 logger.log JDK 25 logger.log JDK 21 手写 for 循环 JDK 25 手写 for 循环 每次调用分配字节
1 15.92 ns 19.26 ns 2.24 ns 2.52 ns 24
2 15.62 ns 16.97 ns 4.18 ns 4.17 ns 24
4 25.84 ns 21.28 ns 8.37 ns 8.33 ns 32
8 30.96 ns 30.27 ns 16.70 ns 16.69 ns 48

两列「手写 for 循环」是同样的 Handler 数组上直接循环 publish,用来看 JDK 那层包装值多少钱。

1 个 Handler 时 logger.log 是 19.26 ns,手写循环是 2.52 ns。8 个 Handler 时是 30.27 ns 对 16.69 ns,差距从 16.74 ns 缩到 13.58 ns。那笔固定开销来自等级判断、过滤器检查和一次数组分配,三样都在每次调用的路径上,没有缓存;Handler 越多,这笔开销摊得越薄。

1 个 Handler 反而比 2 个 Handler 慢,两个 JDK 上都是这个方向(JDK 25 是 19.26 对 16.97 ns,JDK 21 是 15.92 对 15.62 ns),原因没有继续追。

分配那一列是干净的证据。每次调用分配 24 字节(1 个和 2 个 Handler)、32 字节(4 个)、48 字节(8 个),手写循环始终是 0。

Stream 的 stage 链

LongStream.range(0, 1e8) 上叠 stage 再终结。1 亿个元素:

stage 数 JDK 21 流水线 JDK 25 流水线 JDK 21 单方法 JDK 25 单方法 倍数(JDK 25)
1 0.50 ns 0.50 ns 0.34 ns 0.34 ns
2 3.65 ns 3.50 ns 0.34 ns 0.35 ns 10×
4 7.84 ns 7.59 ns 0.65 ns 0.65 ns 12×
8 28.09 ns 24.35 ns 0.71 ns 0.73 ns 33×

1 个 stage 时流水线只比单方法慢一点:JDK 25 是 0.50 ns 对 0.34 ns。到 8 个 stage 就是 24.35 ns 对 0.73 ns,33 倍。单方法那一列从 1 个 stage 到 8 个 stage 只从 0.34 ns 涨到 0.73 ns,两头都没有量级上的变化。

曲线形状不同:手写循环的斜率接近零,流水线的斜率是每 stage 约 3.41 ns 并且随 stage 数变大。JDK 21 与 25 在这个形状上一致。

三种责任链形态的每元素成本随链长变化,以及 8 节点链的命中位置对比

JIT 把这几纳秒花在哪了

-XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining 跑同一段代码。1 个 stage 时内联是成功的,RangeLongSpliterator.forEachRemaining 里能看到整条 sink 链被展开:

1
2
3
4
5
6
7
@ 48   java.util.stream.LongPipeline$3$1::accept (20 bytes)   inline (hot)
@ 9 ChainBench$$Lambda::applyAsLong (5 bytes) inline (hot)
@ 1 ChainBench::f0 (4 bytes) inline (hot)
@ 14 java.util.stream.LongPipeline$9$1::accept (24 bytes) inline (hot)
@ 5 ChainBench$$Lambda::test (5 bytes) inline (hot)
@ 18 java.util.stream.ReduceOps$8ReducingSink::accept (19 bytes) inline (hot)
@ 10 LongPipeline$$Lambda::applyAsLong (6 bytes) inline (hot)

stage 多了以后,同一个位置变成:

1
2
3
@ 14   java.util.stream.LongPipeline$9$1::accept (24 bytes)   inline (hot)
callee changed to java.util.stream.LongPipeline$3$1::accept (20 bytes)
failed to inline: recursive inlining is too deep

LongPipeline$3$1map 生成的 sink,LongPipeline$9$1filter 生成的 sink,每个 stage 的 accept 都在方法体里调 downstream.accept。HotSpot 按类型画像切开这个调用点之后,认为它在做递归内联,于是撞上了深度上限。两个 JDK 的默认值都是 MaxRecursiveInlineLevel = 1MaxInlineLevel = 15

1
2
3
$ java -XX:+UnlockDiagnosticVMOptions -XX:+PrintFlagsFinal -version | grep -E "MaxInlineLevel|MaxRecursiveInlineLevel"
intx MaxInlineLevel = 15 {C2 product} {default}
intx MaxRecursiveInlineLevel = 1 {C2 product} {default}

depth 环里每次 p.depth > 0 就包一层 sink,包到第 N 层时第 N 层的 accept 想内联下一层,而编译器认为这是同一个方法在调自己。结果是链上每层都留下一个真实的调用,每 stage 那几纳秒就是这么来的。

-XX:+PrintInlining 是诊断工具,它的输出依赖编译时机和类型画像,不是稳定契约。上面两段摘录来自同一份代码的两次运行,能说明机制,不能当成逐字节复现的产物。

换算成时间才知道值不值

斜率乘上元素数就是实际开销。JDK 25 上处理链每节点约 0.72 ns,stage 链每 stage 约 3.41 ns:

元素数 处理链每多 1 个节点 stage 链每多 1 个 stage
1 万 0.007 ms 0.03 ms
100 万 0.72 ms 3.4 ms
1 亿 72 ms 341 ms

这张表是从实测斜率推出来的,不是单独测的。第一列末尾那个 72 ms 可以和实测量对照:8 节点链比 1 节点链多花 821 ms 减 317 ms,除以 7 个节点,每节点每亿元素约 72 ms。

每秒 1000 个请求的服务,链长 8 和链长 1 的差别是每个请求 5.04 ns,一秒累计 5 微秒。这个量级不需要优化。要付钱的是 stage 链:同样是 1 亿个元素,每多一个 stage 就是 341 ms,8 个 stage 和写成一个方法差 33 倍。

四、JDK 源码里的三处收场

处理链:Logger.log 每次调用重建一次 Handler 数组

java.util.logging 的 Handler 链是形态一。Logger.log(LogRecord) 的循环在 JDK 25 的 java.logging/java/util/logging/Logger.java 第 955 到 974 行:

1
2
3
4
5
6
7
8
9
10
11
Logger logger = this;
while (logger != null) {
final Handler[] loggerHandlers = isSystemLogger
? logger.accessCheckedHandlers()
: logger.getHandlers();

for (Handler handler : loggerHandlers) {
handler.publish(record);
}
...
}

loggerHandlers 取自 while 循环内部。accessCheckedHandlers() 在同一个文件第 2068 到 2070 行:

1
2
3
Handler[] accessCheckedHandlers() {
return config.handlers.toArray(emptyHandlers);
}

JDK 21 是同一个实现,Logger.java 第 2098 到 2100 行。config.handlers 是一个 CopyOnWriteArrayListtoArray(T[]) 只在传入数组的长度恰好等于列表长度时才复用它,否则新建一个。长度是 0 时传的是静态的 emptyHandlers(JDK 25 第 222 行),有元素时就是一次新分配。上一节量到的 24、32、48 字节就是它。

这条链没有短路的位置。Handler.publishjava.logging/java/util/logging/Handler.java 第 149 行:

1
public abstract void publish(LogRecord record);

没有返回值。一个 Handler 处理完,下一个照样收到同一条记录,没有任何一个位置能表达「已经有人处理过了,后面不用看」。

过滤链:一张 List,两个方向,null 表示放行

java.net.httpHeaderFilter 是形态二。接口在 java.net.http/jdk/internal/net/http/HeaderFilter.java 第 35 到 45 行:

1
2
3
4
5
6
7
8
9
10
11
interface HeaderFilter {

void request(HttpRequestImpl r, MultiExchange<?> e) throws IOException;

/**
* Returns null if response ok to be given to user. Non null is a request
* that must be resent and its response given to user. If impl throws an
* exception that is returned to user instead.
*/
HttpRequestImpl response(Response r) throws IOException;
}

请求方向不返回任何东西,这一层只加头、记状态,没有地方表达拦截。响应方向返回 HttpRequestImplnull 表示放行。遍历在 MultiExchange.java 第 231 到 255 行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
private void requestFilters(HttpRequestImpl r) throws IOException {
Log.logTrace("Applying request filters");
for (HeaderFilter filter : filters) {
Log.logTrace("Applying {0}", filter);
filter.request(r, this);
}
Log.logTrace("All filters applied");
}

private HttpRequestImpl responseFilters(Response response) throws IOException
{
Log.logTrace("Applying response filters");
ListIterator<HeaderFilter> reverseItr = filters.listIterator(filters.size());
while (reverseItr.hasPrevious()) {
HeaderFilter filter = reverseItr.previous();
Log.logTrace("Applying {0}", filter);
HttpRequestImpl newreq = filter.response(response);
if (newreq != null) {
Log.logTrace("New request: stopping filters");
return newreq;
}
}
Log.logTrace("All filters applied");
return null;
}

请求正序、响应倒序,同一个 ListIterator,只是初始位置在末尾。第 248 到 250 行的 if (newreq != null) return newreq; 就是短路:RedirectFilter 判定要重定向时返回一个新的 HttpRequestImplMultiExchange 拿它重发,后面的过滤器这一轮不再执行。

链本身是一张普通 ArrayListFilterFactory.getFilterChain() 用反射实例化节点,FilterFactory.java 第 40 到 52 行:

1
2
3
4
5
6
7
8
9
10
11
12
13
List<HeaderFilter> getFilterChain() {
List<HeaderFilter> l = new ArrayList<>(filterClasses.size());
for (Class<? extends HeaderFilter> clazz : filterClasses) {
try {
// Requires a public no arg constructor.
HeaderFilter headerFilter = clazz.getConstructor().newInstance();
l.add(headerFilter);
} catch (ReflectiveOperationException e) {
throw new InternalError(e);
}
}
return l;
}

HttpClientImpl.initFilters() 把顺序定死,HttpClientImpl.java 第 1674 到 1680 行:认证、重定向,Cookie 只在配了 CookieHandler 时才加。这条链的节点数在客户端构造时确定,之后不再变。

一个请求在这条链上要走两个方向。发出时正序遍历:认证过滤器往系统头里塞凭据,Cookie 过滤器塞 Cookie,两个 request 方法都返回 void,所以无论它们做了什么,后面的节点照样执行。响应回来时倒序遍历:先走 Cookie 把 Set-Cookie 交给 CookieHandler,再走重定向判断,最后才回到认证过滤器。RedirectFilter.handleResponse 判定需要重定向时返回 HttpRequestImpl.newInstanceForRedirection(...)RedirectFilter.java 第 136 行),这个非 null 返回值让倒序遍历当场中断,MultiExchange 用新请求重发。

倒序的理由在重定向这一层。重定向会生成一次全新的往返,如果 Cookie 过滤器排在重定向之后,那次新往返的响应就绕过了 Cookie 处理。链上唯一能中断遍历的位置是返回非 null 的那一层;void request 那一侧没有这个位置。

stage 链:每个元素要把链走两遍

Stream 的中间操作链是形态三。链的构造在 AbstractPipeline.wrapSink,JDK 25 的 java.base/java/util/stream/AbstractPipeline.java 第 604 到 611 行:

1
2
3
4
5
6
7
8
final <P_IN> Sink<P_IN> wrapSink(Sink<E_OUT> sink) {
Objects.requireNonNull(sink);

for ( @SuppressWarnings("rawtypes") AbstractPipeline p=AbstractPipeline.this; p.depth > 0; p=p.previousStage) {
sink = p.opWrapSink(p.previousStage.combinedFlags, sink);
}
return (Sink<P_IN>) sink;
}

每个 stage 把自己的 sink 包在已有的 sink 外面,从末端往上包,返回的是最上游那个,源数据喂给它。JDK 21 的同名方法在 AbstractPipeline.java 第 543 到 550 行,代码一致。

「停」能不能生效,取决于末端 sink 的 cancellationRequested()Sink.ChainedReference 把它一路转下去,JDK 25 的 Sink.java 第 264 到 267 行,JDK 21 在同一个位置:

1
2
3
4
@Override
public boolean cancellationRequested() {
return downstream.cancellationRequested();
}

循环本身出现在两处。JDK 25 AbstractPipeline.java 第 565 到 576 行:

1
2
3
4
5
6
7
8
9
10
11
12
final <P_IN> void copyInto(Sink<P_IN> wrappedSink, Spliterator<P_IN> spliterator) {
Objects.requireNonNull(wrappedSink);

if (!StreamOpFlag.SHORT_CIRCUIT.isKnown(getStreamAndOpFlags())) {
wrappedSink.begin(spliterator.getExactSizeIfKnown());
spliterator.forEachRemaining(wrappedSink);
wrappedSink.end();
}
else {
copyIntoWithCancel(wrappedSink, spliterator);
}
}

有短路能力的流水线走的是另一条循环。LongPipeline.forEachWithCancel 在 JDK 25 的 LongPipeline.java 第 158 到 164 行:

1
2
3
4
5
6
7
final boolean forEachWithCancel(Spliterator<Long> spliterator, Sink<Long> sink) {
Spliterator.OfLong spl = adapt(spliterator);
LongConsumer adaptedSink = adapt(sink);
boolean cancelled;
do { } while (!(cancelled = sink.cancellationRequested()) && spl.tryAdvance(adaptedSink));
return cancelled;
}

每个元素之前先问一次 cancellationRequested()。这一问要沿着 sink 链从上游走到末端,于是每处理一个元素,整条链要走两遍:一遍 accept 往下送数据,一遍 cancellationRequested 往上问要不要停。JDK 21 的同一方法在 LongPipeline.java 第 157 到 163 行。

中间节点只有 takeWhile 能让链提前停,它的做法在 java.base/java/util/stream/WhileOps.java 第 198 到 219 行:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
Sink<Long> opWrapSink(int flags, Sink<Long> sink) {
return new Sink.ChainedLong<>(sink) {
boolean take = true;
...
@Override
public void accept(long t) {
if (take && (take = predicate.test(t))) {
downstream.accept(t);
}
}

@Override
public boolean cancellationRequested() {
return !take || downstream.cancellationRequested();
}
};
}

它自己的状态(!take)和末端的回答取或。makeTakeWhileLong 在这个 sink 上挂的是 StreamOpFlag.NOT_SIZED | StreamOpFlag.IS_SHORT_CIRCUIT(第 50 行),也就是告诉整条流水线「这个 stage 有资格触发短路循环」。普通 mapfilter 的 stage 没有这个资格,它们的 cancellationRequested() 只是纯转发。

两个 LTS 之间这三条链没有变

本文引用的实现逐行比对过 JDK 21 与 JDK 25 的 src.zip

位置 21 与 25 的差异
AbstractPipeline.wrapSink
AbstractPipeline.copyInto
AbstractPipeline.copyIntoWithCancel 只差一个 @SuppressWarnings 注解
Logger.log(LogRecord) 的 Handler 循环
MultiExchange.responseFilters

整个文件的行数有变化:AbstractPipeline.java 在两个 LTS 之间有 107 行不同,Logger.java 有 105 行。本文的行号只在对应版本里成立,跨版本引用要重新数。

五、实测二:短路位置决定要走几步

固定 8 节点链。节点 i 检查 x 的第 i 个四位组是否等于 i+1,构造数据时让它只在指定位置命中。1 亿个元素:

命中位置 走过的节点数 JDK 21 每元素 JDK 25 每元素 节点调用/元素
第 1 个节点 1 3.15 ns 3.15 ns 1
第 4 个节点 4 4.66 ns 4.81 ns 4
第 8 个节点 8 8.06 ns 8.09 ns 8
无人命中,8 个节点全部穿透 8 9.38 ns 9.25 ns 8
第 8 个节点兜底命中 8 8.52 ns 8.31 ns 8

JDK 25 上第 1 个节点命中是 3.15 ns/元素,第 4 个是 4.81 ns,第 8 个是 8.09 ns。节点调用计数精确等于 1、4、8。

两行都是 8 次节点调用,可以对照。第 8 个节点返回 true 时是 8.09 ns,8 个节点全部返回 false、让调用方拿到「没人管」的结果时是 9.25 ns,差 0.94 ns。链长一样,分歧只有返回值。这个差值来自链尾的 next == null 检查落在了返回路径上,而 SINK 的写入被 if 挡掉了。

短路的成本是线性的:命中第 K 个节点,每元素就是 K 次节点调用。8 节点链上第 1 个节点命中比第 8 个快 2.6 倍,一亿次调用上这个差额就是拦得早的收益。

六、Stream 的短路:省下的是扫描量

同一件事在 findFirst 上是另一个形状。数组长 2000 万,8 个 map stage 之后接 filterfindFirst,命中值放在数组的不同位置:

命中位置 每次扫描元素数 调用次数 Stream 每元素 单方法每元素 倍数
数组第 1 个 1 2,000,000 151.91 ns 2.17 ns 70×
数组中间(第 1000 万个) 10,000,001 15 27.18 ns 0.76 ns 36×
数组最后一个 20,000,000 7 27.61 ns 0.72 ns 38×
不存在,扫完全部 20,000,000 7 26.93 ns 0.29 ns 91×

每元素扫描成本在两列之间是常数:Stream 大约 27.18 ns/元素,单方法内联是 0.76 ns/元素。挪动命中位置不改变每元素成本,改变的是要扫多少元素。

短路的收益上限由扫描量决定,而每元素成本的差额由 sink 链决定。命中在数组中间时,Stream 要扫 1000 万个元素才能停下,单方法只需要扫同样的 1000 万个,但每个元素便宜 36 倍。命中在第一个元素时,Stream 每次调用固定花 152 ns 构建一次 sink 链,而每元素只处理一个数据。建链比处理数据贵得多。

Stream 的短路不减少每元素的成本,只减少处理的元素数,这是它和形态一、二的关键差别。想让链本身变便宜,只能减少 stage 数。

七、判断顺序

三种形态选哪一种,取决于「谁有资格让链停下来」和「停下来之后调用方要做什么」:

什么时候用

处理链:检查点的数量在运行期变化,而且每个检查点都可能结束这次处理。每节点 0.72 ns,链长 8 以内都便宜。

过滤链:节点会对请求本身动手,需要重发。这个形态的代价来自方向:响应方向倒序遍历,加节点时要同时想清楚它在两个方向上的位置。

stage 链:数据量大、每个元素的处理简单、stage 少。可读性换来的成本是每 stage 约 3.41 ns。

什么时候不用

  • 处理链的节点少于 3 个时,if 更快。 链长 1 的实测是 3.18 ns/元素,一次直接的 if 判断就在这个量级;把两个判断做成链,换来的是运行期增删节点的能力,不需要这个能力就别付。
  • Handler 链上挂一个 Handler 就要算一次分配。 每次 logger.log 都会 toArray 一个新数组,24 到 48 字节。日志量大、Handler 只有一个时,这个分配是纯开销。
  • Stream 的短路省不掉每元素的骨架成本。 findFirst 每元素 27.18 ns 对单方法内联的 0.76 ns。要在千万级元素上按条件提前退出,for 循环加 break 依然更快。
  • 任何节点都可能成为瓶颈时,把最热的那个前移。 第 1 个节点命中与第 8 个节点命中差 2.6 倍。

把链压平

三组测量给出的都是链本身的开销,要省掉它,只能在构造时把链压平,运行时不再有链:

  • 处理链:1 个节点 3.18 ns,8 个节点 8.22 ns。节点集合固定时,把 8 个判断写成 8 行 if,成本回到一个判断的量级,代价是运行期不能增删节点。
  • stage 链:8 个 stage 是 24.35 ns,写成一个方法是 0.73 ns。这里的差距是 33 倍,压平的收益最大。
  • Handler 链:8 个 Handler 时 JDK 路径是 30.27 ns,手写循环是 16.69 ns。差距 13.58 ns 是每次调用的固定开销,和链上有几个 Handler 无关,压平这条链省不掉它。

HttpClient 的过滤链在客户端构造时定死,后面不再变,所以它有资格压平。日志的 Handler 可以在运行时 addHandler,每次调用重新取一遍数组是它付的保险费。压平之前先回答一个问题:节点集合在运行期会不会变。

一条判断顺序

先问「几个节点可能拦下这个请求」。答案是一个,就用一次 if。答案是多个且每个都可能改写请求,用过滤链。然后是「每个元素要付多少钱」:处理链每节点 0.72 ns,stage 链每 stage 3.41 ns。最后问「节点集合在运行期变不变」:会变就用 List 每次遍历现取,不变就在构造时定死,别在热路径上重建。

总结

  • 三种形态的区别在「停」怎么表达:处理链用 boolean 返回值(Handler.publishvoid,所以 JDK 的 Handler 链根本没有短路的表达位置),过滤链用返回替换对象或 nullMultiExchange.responseFilters 第 250 行 return newreq),stage 链用末端 sink 的 cancellationRequested 反向转发。
  • 处理链每节点约 0.72 ns(JDK 25)。链长从 1 到 8,耗时涨 2.6 倍,节点数涨 8 倍。乱序执行把前两个节点的成本盖住了。
  • Logger.log 每次调用重新取一次 Handler 数组(Logger.java 第 955-974 行调 accessCheckedHandlers(),第 2068-2070 行 config.handlers.toArray(emptyHandlers))。实测每次调用分配 24/24/32/48 字节,对应 1/2/4/8 个 Handler,手写 for 循环是 0 字节。
  • Stream 的每 stage 成本约 3.41 ns:8 stage 的流水线是 24.35 ns/元素,单方法内联是 0.73 ns,33 倍。1 个 stage 时两者接近(0.50 对 0.34)。
  • 这个差距的原因是 C2 判定 sink 链在做递归内联:PrintInlining 输出 failed to inline: recursive inlining is too deep,两个 JDK 的 MaxRecursiveInlineLevel 默认都是 1。
  • 8 节点链上第 1 个节点命中是 3.15 ns/元素,第 8 个是 8.09 ns,差 2.6 倍。全部穿透(8 个节点都返回 false)是 9.25 ns,比第 8 个节点兜底还多 0.94 ns。
  • Stream 的短路只减少扫描量,不减少每元素成本:命中位置从数组头部挪到第 2000 万个,每元素成本稳定在 27.18 ns 上下,单方法内联稳定在 0.76 ns 上下。
  • 带短路的流水线每元素把 sink 链走两遍(LongPipeline.forEachWithCancel,第 158-164 行),一遍 accept 向下,一遍 cancellationRequested 向上。
  • 中间 stage 想自己喊停只有 takeWhile,实现是把自身状态和末端回答取或(WhileOps.java 第 214-217 行),并声明 StreamOpFlag.IS_SHORT_CIRCUITWhileOps.java 第 50 行)。

参考资料

相邻的几篇:模板方法 讲继承体系里的钩子,策略 讲把算法换掉,装饰器 讲怎么往一条链上叠能力,中介者 讲把 N×N 的调用收敛到一个中间人。

系列索引:设计模式系列