模板方法把流程写死在父类,把可变的步骤留给子类。子类通常只需要实现一个方法。
其余步骤是带默认实现的钩子,默认体在源码里经常是空的,读起来不要钱。
本文量两件事:8 个空钩子在 1 亿次调用下的开销,以及 InputStream 的默认实现把一个批量读请求拆成逐字节调用之后,48 MiB 数据要多付多少。

一、骨架与钩子

模板方法把一段流程拆成两种方法:父类实现的骨架方法,子类实现的步骤方法。步骤方法里,一部分是抽象方法,子类必须实现;另一部分是带默认实现的钩子,子类想改才改。

JDK 的类文档自己用 hook 这个词。ThreadPoolExecutor 有一节标题就是 “Hook methods”,写的是 beforeExecuteafterExecuteterminated 这三个 protected 方法(JDK 25 ThreadPoolExecutor.java:241-251)。三者的默认实现都是空方法体(:1920:1971:1979)。

模板方法在 JDK 里最常见的形态是:抽象方法只留一个,其余全是钩子。

子类必须实现 钩子(带默认实现)
InputStream read() read(byte[],int,int)read(byte[])skipavailableclosetransferTo
AbstractList get(int)size() setaddindexOfiteratorclearremoveRange
ThreadPoolExecutor beforeExecuteafterExecuteterminated

这三个类跟教科书里“固定算法骨架”的写法有出入,共同点是把扩展点做成带默认实现的方法,把必须实现的部分压到一个或两个抽象方法上。

JDK 自己怎么写这些子类

在 JDK 25 的 src.zip 里对 14,671 个 .java 文件统计 extends 关系和批量方法的实现(方法声明后带方法体才算实现):

基类 子类数 实现了批量入口 没实现
InputStream 40 38 2
OutputStream 26 23 3
Reader 19 19 0
Writer 18 16 2

Reader 那一行是零:它的抽象方法就是 read(char[],int,int),子类漏掉编译不过。InputStream 那一行有 2 个:它的抽象方法是单字节的 read(),批量入口是钩子,覆写与否编译器不管。漏掉的两个是 ProcessBuilder 里的 NullInputStreamDataTransferer 里的 ReencodingInputStream,都是边角实现。避免这个坑只能靠人自觉。

统计口径:在源码里找形如 class X ... extends 基类 的声明,再看同一个文件里有没有带方法体的批量方法声明,byte b[] 这种 C 风格数组写法也算。ReaderWriter 的批量方法是抽象方法,编译器会强制子类实现它们。

默认实现是 JDK 对这份成本的回应:抽象方法每多一个,所有子类的实现成本就多一份。read(byte[],int,int) 的默认实现让一个只能逐字节输出的流(解压、解密、协议解码这些内部环节)只写一个方法就能接进整条 IO 链。JDK 把便利留给了子类写作者,把代价推给了没有覆写的调用方。

二、怎么测的

  • 机器:Apple M1 Pro,macOS Darwin 25.6.0
  • JDK:21.0.8+12-LTS-25025+37-LTS-3491,都是 arm64。源码引用逐字取自各自 lib/src.zip 里的文件,行号是逐个 unzip -p 出来的
  • 没有 JMH。每个变体单独起一个 JVM,先预热再计时。钩子实验每次计时调用 1 亿次,跑 5 轮取中位数;数据流实验每个变体跑 5 轮取中位数
  • 计时期间持有全局文件锁,同一时刻只有一个实验在跑。机器上还有 21 个并行任务在编译和写文件,绝对数值整体偏大,结论只建立在同一批实验内部的量级和方向上
  • 三个口径:耗时用 System.nanoTime;调用次数在流实现内部自增计数;分配字节数用 com.sun.management.ThreadMXBean#getThreadAllocatedBytes
  • 内联情况用 -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining 打印,引用的是 JDK 25 的输出
  • 数据源是 48 MiB 的 Java 堆数组(50,331,648 字节)。把 IO 排除掉,是为了让第二个实验量的是默认实现本身;系统调用那一侧已经在装饰器篇里量过,结论不复用

被测的骨架在两个变体里逐字节相同,唯一变量是钩子的数量和运行时接收者的类型数量。

局限

  • 没有 JMH,数字用 System.nanoTime 取中位数。绝对值和用 JMH 测出来的不会一样
  • 机器上有其他任务在跑。钩子实验 5 轮之间的极差大多在 3% 以内,耗时 1 到 3 ms 的数据流变体波动更大,个别轮次能差出一倍。钩子实验里用来下结论的最小差是双实现的 4.546 ns,比该实验的波动大一个量级;数据流实验的结论建立在 20 倍以上的差上
  • 数据源是堆数组,JIT 能把默认实现和子类方法一起内联,这比真实 IO 场景更有利。真实文件流上每字节一次 read(2),量级已经在装饰器篇里给过
  • 钩子数量和子类数量是仅有的两个变量,没有覆盖继承深度、接口默认方法、泛型桥方法这些情况

三、实测一:1 个钩子与 8 个空钩子

被测的骨架

1
2
3
4
5
6
7
8
9
10
public static abstract class Eight {
public final long run(long x) { // 骨架方法
long r = body(x);
h1(r); h2(r); h3(r); h4(r); h5(r); h6(r); h7(r); h8(r);
return r;
}
protected abstract long body(long x); // 子类必须实现
protected void h1(long x) { } // 钩子:默认什么都不做
/* h2 到 h8 同上 */
}

另一个骨架把 8 个钩子减到 1 个,其余完全相同。四个子类分别继承两个骨架,覆写 body 和全部钩子,钩子的方法体是空的:

1
2
3
4
5
6
7
static final class A8 extends Eight {
protected long body(long x) { return x * 31 + 7; }
protected void h1(long x) { } protected void h2(long x) { }
protected void h3(long x) { } protected void h4(long x) { }
protected void h5(long x) { } protected void h6(long x) { }
protected void h7(long x) { } protected void h8(long x) { }
}

计时循环把接收者放在数组里轮换:

1
for (long i = 0; i < N; i++) acc += r[(int) (i & mask)].run(i);

mono 只实例化 1 个子类,bi 实例化 2 个,mega 实例化 4 个。三个变体做的计算完全相同,区别只在 JIT 在调用点能看到几种接收者类型。项目里“有几种子类”就是这么决定调用点形状的。

结果

变体 实现数 JDK 21 每次调用 JDK 25 每次调用
1 个钩子 1 0.920 ns 0.918 ns
8 个钩子 1 0.914 ns 0.928 ns
1 个钩子 2 1.618 ns 1.617 ns
8 个钩子 2 6.085 ns 6.163 ns
1 个钩子 4 6.402 ns 5.765 ns
8 个钩子 4 25.223 ns 23.483 ns

钩子数量与默认实现的调用次数

同一列里 1 个钩子和 8 个钩子的差,就是 7 个空钩子的价钱:

实现数 JDK 21 差值 JDK 25 差值 每个空钩子
1 -0.006 ns 0.010 ns 0.001 ns
2 4.467 ns 4.546 ns 0.649 ns
4 18.821 ns 17.718 ns 2.53 ns

只有一个实现时,7 个空钩子的差落在测量分辨力以内,两个 JDK 上都是这个结果。两个实现时每个空钩子贵 0.65 ns。四个实现时每个空钩子 2.53 ns,8 个钩子把每次调用从 5.765 ns 推到 23.483 ns,多出来的开销超过骨架本身。

为什么差在这些地方

-XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining 打印 JDK 25 上 HookCost$Eight::run 的内联决定。单实现变体里 8 个钩子全部被内联:

1
2
3
4
@ 8   HookCost$A8::h1 (1 bytes)   inline (hot)
@ 13 HookCost$A8::h2 (1 bytes) inline (hot)
...
@ 43 HookCost$A8::h8 (1 bytes) inline (hot)

同一个骨架在四实现变体里,8 个钩子的调用点全部放弃内联:

1
2
3
4
@ 8   HookCost$Eight::h1 (1 bytes)   failed to inline: virtual call
@ 13 HookCost$Eight::h2 (1 bytes) failed to inline: virtual call
...
@ 43 HookCost$Eight::h8 (1 bytes) failed to inline: virtual call

C2 在调用点上最多记住两种接收者类型做投机内联,超过就走通用虚调用。双实现变体的输出印证了这条上限:C2 先后内联两个实现,同一个调用点在编译结果里换过一次绑定(原始输出是一行,这里拆开):

1
2
@ 8   HookCost$A8::h1 (1 bytes)   inline (hot)
callee changed to HookCost$B8::h1 (1 bytes) inline (hot)

三个变体的差别就在内联决定里:

  • 单实现:方法体空,内联之后整段消失,8 个钩子测不出代价
  • 双实现:C2 保留类型检查再内联,7 组检查和分支留在了编译结果里,每次调用都要执行
  • 四实现:直接放弃内联,每个钩子是一次查虚表、跳转、返回

两个 JDK 上的编译参数一致,MaxInlineSize = 35FreqInlineSize = 325TypeProfileMajorReceiverPercent = 90,都可以用 -XX:+PrintFlagsFinal 核对。骨架方法本身不大,run 最终被整体内联进计时循环,所以表里的数字不含额外的方法调用开销。

两个 JDK 给出的结果几乎一样:单实现的差是 -0.006 ns 与 0.010 ns,双实现是 4.467 ns 与 4.546 ns,四实现是 18.821 ns 与 17.718 ns。每个变体 5 轮之间的极差大多在 3% 以内,四实现那一行是 2% 到 4%。同一代 HotSpot 的类型 profile 投机内联策略在两个 LTS 之间没有变化。

怎么用到自己的代码上

-XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining 加到启动参数上,在输出里找骨架方法的名字,钩子的价钱就写在里面:

  • 看到 inline (hot),钩子不要钱
  • 看到 failed to inline: virtual call,钩子按每次 2 到 3 ns 计价,乘以调用次数就是它的总价

这条检查只对已经跑热的代码有意义,C2 要等调用计数上来才会编译。冷路径上的钩子不用管。

四、实测二:InputStream 的默认实现

InputStream 的抽象方法只有一个:

1
public abstract int read() throws IOException;      // JDK 25 InputStream.java:182

批量读是钩子,默认实现是逐字节循环(JDK 25 :283-307):

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
public int read(byte b[], int off, int len) throws IOException {
Objects.checkFromIndexSize(off, len, b.length);
if (len == 0) {
return 0;
}

int c = read();
if (c == -1) {
return -1;
}
b[off] = (byte)c;

int i = 1;
try {
for (; i < len ; i++) {
c = read();
if (c == -1) {
break;
}
b[off + i] = (byte)c;
}
} catch (IOException ee) {
}
return i;
}

read(byte[] b) 也是钩子,转调批量读(JDK 25 :221-223),javadoc 的 @implSpec 写着 “same effect as read(b, 0, b.length)”(:204-209)。

这个设计对子类很友好:只要实现 read() 就能工作,所有批量入口都会自动退化成一层循环加逐字节调用。read() 是抽象方法,编译器强制子类实现;批量读是钩子,编译器不会提醒子类跳过了它。

被测的两种流

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
static class ByteAtATime extends InputStream {       // 只覆写 read()
int pos;
public int read() { return pos < SIZE ? (DATA[pos++] & 0xff) : -1; }
}

static class Bulk extends InputStream { // 覆写 read() 与批量读
int pos;
public int read() { return pos < SIZE ? (DATA[pos++] & 0xff) : -1; }
public int read(byte[] b, int off, int len) {
if (pos >= SIZE) return -1;
int n = Math.min(len, SIZE - pos);
System.arraycopy(DATA, pos, b, off, n);
pos += n;
return n;
}
}

消费端用同一个 8192 字节缓冲反复调用 read(buf, 0, 8192),直到 48 MiB 读完。计数在流内部做,计的是 read()read(byte[],int,int) 被调用的次数。

结果

消费方式 实现 流内部 read() 次数 流内部批量方法次数 JDK 21 JDK 25
read(buf,8192) 循环 只覆写 read() 50,331,649 走默认实现,未计数 31.1 ms 32.1 ms
read(buf,8192) 循环 覆写批量读 0 6,145 1.4 ms 1.6 ms
readAllBytes() 只覆写 read() 50,331,649 走默认实现,未计数 49.9 ms 52.6 ms
readAllBytes() 覆写批量读 0 6,144 23.4 ms 18.7 ms
skip(48 MiB) 只覆写 read() 50,331,649 走默认实现,未计数 28.0 ms 29.1 ms
skip(48 MiB) 覆写批量读 1 24,576 1.7 ms 3.0 ms

48 MiB 是 50,331,648 字节。只覆写 read() 时,消费端每个 8192 字节的批量请求都被拆成 8192 次单字节调用,一轮读下来 50,331,649 次,耗时是覆写批量方法的 20.1 倍(JDK 25)与 22.2 倍(JDK 21)。最后一行里那个 1 是消费代码接着调的 in.read(),跳过之后返回 -1。

三个消费入口的退化是同一件事:

  • read(buf, 0, 8192) 直接落在默认实现的循环上
  • readAllBytes() 转调 readNBytes(Integer.MAX_VALUE)(JDK 25 InputStream.java:347-349),后者分配 16384 字节的临时数组(:58DEFAULT_BUFFER_SIZE:407),在循环里调 read(buf, nread, ...):411-412)。这个调用同样落在默认实现上
  • skip() 的默认实现分配一个 2048 字节的 MAX_SKIP_BUFFER_SIZE 数组(:56:547-548),反复调 read(skipBuffer, 0, size):550

三条路径上的调用次数都对得上。readAllBytes() 每次读满 16384 字节的临时缓冲(48 MiB 分成 3072 块),缓冲填满时 Math.min(buf.length - nread, remaining) 变成 0,于是每块还要发一次长度为 0 的探测读,计数是 6144;skip() 的批量调用次数 24,576 正好是 48 MiB 除以 2048。子类作者只要漏掉批量读,所有批量入口一起变慢,而每个入口的默认实现在源码里分散在三个地方。

transferTo(OutputStream) 也走同一条路:它分配 DEFAULT_BUFFER_SIZE 大小的缓冲,循环调 read(buffer, 0, DEFAULT_BUFFER_SIZE)(JDK 25 :790-795)。

同一张表里 readAllBytes() 的批量变体比 read(buf,8192) 循环慢得多:18.7 ms 对 1.6 ms。这部分开销与流无关,出在 readNBytes 的实现上:它把 3072 个 16384 字节的块先攒在 ArrayList 里(:401:429-432),读完再分配一个 48 MiB 的结果数组把块拼起来(:447-455),那次拼接和 48 MiB 的分配就是多出来的时间。用 readAllBytes() 处理几十 MiB 的输入时,多出来的是内存和一次全量拷贝。

自己写 InputStream 时该怎么办

覆写批量方法,并按契约处理边界:len == 0 返回 0,只在到达末尾时返回 -1。Objects.checkFromIndexSize 那行检查也照着抄,调用方传越界参数时抛出的异常类型才和 JDK 一致。

available() 也落在这类必须覆写的方法里:ByteArrayInputStream 之类的实现覆写它返回剩余字节数,BufferedInputStream 返回缓冲里还没消费的字节数。

默认实现里的两个隐藏行为

逐字节循环里的 catch (IOException ee) { }(JDK 25 :304-305)把循环中途的异常吞掉,直接返回已经读到的字节数:第一次 read() 抛出的异常会传给调用方,第二次及以后的不会。

available() 的默认实现返回 0(:650-652):

1
2
3
public int available() throws IOException {
return 0;
}

调用方用 in.available() > 0 判断“有没有数据可以立刻读”时,只覆写 read() 的流拿到的就是 0。这个钩子没有分派代价,默认实现直接把这项能力关掉了。

反向的默认实现

同一个 JDK 里,Reader 把方向反了过来。read(char[],int,int) 是抽象方法(JDK 25 Reader.java:398),单字符的 read() 是钩子,实现方式是每次分配一个 char[1]:345-351):

1
2
3
4
5
6
7
public int read() throws IOException {
char[] cb = new char[1];
if (read(cb, 0, 1) == -1)
return -1;
else
return cb[0];
}

子类实现批量读之后单字符入口能用,代价是每次调用一次数组分配。用 ThreadMXBean#getThreadAllocatedBytes 数一下,逐字符读 48 Mi 个字符:

路径 单字节调用次数 分配字节数 平均每次 JDK 25 耗时
Reader.read() 逐字符 50,331,648 1,207,959,576 24.0 字节 230.7 ms
read(char[],int,int) 批量 0 0 0 8.2 ms

分配没有走标量替换:read(cb, 0, 1) 是虚调用,逃逸分析证明不了数组不外传,加 -XX:-EliminateAllocations 之后数字一模一样,两个 JDK 的计数也一致。48 Mi 个字符换来 1.13 GiB 的垃圾。

OutputStreamInputStream 同向:write(byte[],int,int) 是钩子,默认实现循环调 write(int)(JDK 25 OutputStream.java:163-169),javadoc 里写着 “Subclasses are encouraged to override this method and provide a more efficient implementation.”。一个只覆写 write(int) 的输出流,写 48 MiB 会产生 5033 万次分派。

“谁抽象、谁默认”得主动选:抽象方法的位置决定子类必须实现什么,钩子的位置决定哪条入口有默认退路、退路有多贵。Reader 把抽象方法放在批量入口上,所有子类都被逼着去实现它,单字符入口付一次分配。InputStream 把抽象方法放在单字节入口上,子类写起来省事,批量入口的性能全靠子类作者自觉。

五、另外两个钩子

AbstractList

AbstractListget(int) 留成抽象方法(JDK 25 AbstractList.java:122),size() 继承自 AbstractCollection,同样是抽象方法(AbstractCollection.java:82)。两者之上的方法是钩子:

  • indexOf(Object) 创建 listIterator() 逐个比较(AbstractList.java:186-198
  • lastIndexOf(Object)size() 位置反向迭代(:212-224
  • clear()removeRange(0, size()):244-246
  • 内部类 Itrnext()get(i):369-381
1
2
3
4
5
6
7
8
9
10
11
12
13
public E next() {
checkForComodification();
try {
int i = cursor;
E next = get(i);
lastRet = i;
cursor = i + 1;
return next;
} catch (IndexOutOfBoundsException e) {
checkForComodification();
throw new NoSuchElementException(e);
}
}

子类实现两个方法,换来迭代、查找、清空、子列表这些行为。代价是每个元素的访问都是一次 get(i)。数据放在数组里的实现能让 JIT 内联掉它,放在链表上的实现每次 get(i) 都要走 i 步,indexOf 因此从 O(n) 变成 O(n²)。默认实现只保证语义正确。

ThreadPoolExecutor

三个钩子的调用点在 runWorker 里,包在任务执行的两侧(JDK 25 ThreadPoolExecutor.java:1088-1094):

1
2
3
4
5
6
7
8
9
10
try {
beforeExecute(wt, task);
try {
task.run();
afterExecute(task, null);
} catch (Throwable ex) {
afterExecute(task, ex);
throw ex;
}
}

terminated() 在状态机走到 TERMINATED 时调用(:701)。类文档里那节 “Hook methods” 说的是它们的用途:重置 ThreadLocal、收集统计、写日志(:241-251)。javadoc 还附了一条约定,子类覆写时要调用 super.beforeExecutesuper.afterExecutesuper.terminated,嵌套覆写才不会互相覆盖(:1913-1915:1929-1931:1975-1977)。

这三个钩子的成本在这里可以忽略:每次任务执行各调一次,而任务本身要做的事情比一次虚调用贵得多。JDK 25 源码里继承 ThreadPoolExecutor 的类只有 ScheduledThreadPoolExecutor 一个,它没有覆写 beforeExecuteafterExecute,覆写的是另一个包内钩子 onShutdown()ScheduledThreadPoolExecutor.java:376,调用点在 ThreadPoolExecutor.java:1346)。PausableThreadPoolExecutorExtendedExecutor 这两个名字出现在类文档的示例代码里,不是 JDK 的类。

这两个例子的差价来自一条三项乘法:

1
分派成本 × 每次执行的钩子数 × 调用频率

ThreadPoolExecutor 的三个钩子每次任务各调一次,唯一的子类也没有覆写它们,第三项以任务为单位,前两项小到量不出来。InputStream 的批量读三项全中:分派成本由子类数量决定,钩子数每次调用一次,调用频率按字节算。同一个模式,位置不同,价格差出几个量级。

六、结论

什么时候用

  • 骨架稳定、步骤有几个明确的变体,且运行时子类数量能控制在两个以内。实测里两个实现时每个空钩子贵 0.65 ns,骨架基本白拿
  • 钩子所在的路径调用频率不高。ThreadPoolExecutor 的三次调用摊在每个任务上,量不出来
  • 需要给第三方留扩展点,且默认行为必须存在。InputStream 的默认批量读保证了只实现 read() 的流也能被 readAllBytes() 使用,代价只有性能

什么时候不用

  • 钩子在热路径上,且程序里会有三个以上子类。实测每个空钩子 2.53 ns,8 个钩子让每次调用从 5.765 ns 变成 23.483 ns
  • 只有一个变体。这时候抽象类的分派成本全价支付,收益为零。同一台机器上量过抽象类钩子与传函数的差别(3 千万次行处理):JDK 21 上 6.07 ns 对 7.29 ns,JDK 25 上 6.05 ns 对 6.64 ns,两种写法在同一数量级(见总纲
  • 步骤的默认实现是一条慢路径,而子类作者看不出这一点。InputStream 的批量读就是这样:接口要求实现 read(),默认实现把批量入口接在它上面,漏覆写不报错,功能也正常,只有调用次数差 8192 倍、耗时差 20.1 倍

一条判断顺序

最后一问针对 InputStream 这类库代码。写库的时候,钩子的默认实现就是 API 契约的一部分,契约的方向决定所有子类的性能上限。写业务代码时方向由自己定,选错了最多损失一次重构。

改法

看到 failed to inline: virtual call 并且钩子在热路径上时,有三条路:

  1. 把钩子数收敛到一个。骨架里留一次 hook() 调用,具体要做的几件事交给子类内部决定。这是最小改动:
1
2
3
4
5
public final long run(long x) {
long r = body(x);
hook(r); // 之前是 8 次 hookN(r)
return r;
}
  1. 换成组合。把可变步骤抽成接口,骨架持有它,单实现下 JIT 一样能内联,多实现时的代价与模板方法相同,但类层次不再膨胀(对照见策略
  2. 让子类数量不超过两个。这属于设计问题:三个以上实现同时活跃的类层次,该重新看一遍

第一条路不是随处可用。钩子列表写进 API 契约之后(ThreadPoolExecutor 的三个钩子、InputStream 的批量读),改不动,只能接受或另起类型。

钩子成本按场景对照:

场景 分派成本 每次执行的钩子数 调用频率 结论
ThreadPoolExecutor.beforeExecute 内联,唯一继承它的类没有覆写 3 每个任务一次 忽略
AbstractList.get 子类决定 1 每个元素一次 数组实现免费,链表实现退化成 O(n²)
InputStream 批量读 0,子类自己实现 1 每 8192 字节一次 漏覆写等于每字节一次分派

总结

  • 模板方法把必须实现的方法压到一个或两个,其余步骤做成带默认实现的钩子。JDK 的三个例子:InputStream 只有一个抽象方法 read()AbstractList 有两个(getsize),ThreadPoolExecutor 一个都没有,三个钩子全是空默认体
  • JDK 25 源码里 extends InputStream 的子类 40 个,38 个实现了批量读;Reader 的子类 19 个,19 个都实现,因为它的抽象方法就放在批量入口上
  • 1 亿次调用的实测:单实现时 1 个钩子与 8 个空钩子的耗时差在测量分辨力以内;双实现时每个空钩子贵 0.65 ns;四实现时每个空钩子 2.53 ns。8 个钩子在四实现下把每次调用从 5.765 ns 推到 23.483 ns(JDK 25)
  • PrintInlining 给出的机制:单实现时 8 个钩子调用点全部 inline (hot),四实现时全部 failed to inline: virtual call。两个 JDK 的 MaxInlineSize 都是 35、FreqInlineSize 都是 325、TypeProfileMajorReceiverPercent 都是 90
  • InputStream.read(byte[],int,int) 的默认实现是逐字节循环(JDK 25 InputStream.java:283-307)。子类只覆写 read() 时,48 MiB 的批量读产生 50,331,649 次 read() 调用,覆写批量方法后是 6,145 次,耗时差 20.1 倍(JDK 25)
  • readAllBytes()skip()transferTo() 都从默认实现里向下调批量读(:347-349:539-558:790-795),退化程度一样。available() 的默认实现返回 0(:650-652
  • 默认实现里的 catch (IOException ee) { }:304-305)会让循环中途的异常静默变成“读到一部分”
  • Reader 的方向相反:抽象方法放在 read(char[],int,int)Reader.java:398),单字符入口每次分配一个 char[1]:345-351),48 Mi 个字符分配 1.13 GiB,平均每次 24.0 字节,-XX:-EliminateAllocations 不影响计数。OutputStreamInputStream 同向(OutputStream.java:163-169
  • 钩子的成本可以按 分派成本 × 钩子数 × 调用频率 估。三项里任意一项接近零,模板方法就没有可测代价

参考资料

系列索引:设计模式系列