装饰器把「给一个对象加能力」变成了廉价操作:new BufferedInputStream(new FileInputStream(f)) 只是多写一层。
但每一层都会改写下层被调用的形态——调用次数、每次的块大小、flush 与 close 的语义。这些改动在源码里看不到,只能测。
本文用一个 48 MiB 文件、libc 层的真实 read(2) 计数,和两个 JDK(21.0.8 与 25)回答一个问题:这条链上多包一层,到底买到了什么,代价在哪。

一、装饰器换到的东西

继承式的算术

给一个数据源加「缓冲」和「校验」两种能力。如果用继承,每多一种能力就要多一层子类,而两种能力的叠加顺序又各自是一个类:

2 种能力 × 2 种数据源 = 10 个类(每种数据源 1 个基类 + 4 个组合)。再加一种能力,每种数据源涨到 16 个,两种数据源 32 个。

装饰式的算术

装饰器把「能力」从「数据源」里拆出来,做成 holds-a 而不是 is-a。能力类是包装器,包装对象是它自己实现的同一个接口:

4 个类,覆盖全部组合,顺序任意。

JDK 自己就是这么做决定的

在 JDK 25 的 src.zip 里对全部 14,671 个 .java 文件按 extends 统计:

写法 命中文件数 例子
extends FilterInputStream 20 BufferedInputStreamCheckedInputStreamDigestInputStreamInflaterInputStreamDataInputStreamPushbackInputStream
extends FilterOutputStream 19 BufferedOutputStreamCheckedOutputStreamDeflaterOutputStreamPrintStream
extends FileInputStream / extends FileOutputStream 2(共 4 个内部类) 只在 java.lang.Systemjava.lang.Process 里,是 System.inProcess 管道这类替换 fd 的场景,不是叠加能力

给字节流叠加能力这件事,JDK 自己做成了 39 个装饰器,几乎不做成 FileInputStream 的子类。这是上面那张类图的算术结果。

二、IO 链的层次

一条真实的读链通常长这样。注意每一层给下层发出的请求大小并不一样:

代表类 它改写了什么
字节源 FileInputStreamSocketInputStream 决定数据从哪来,直接产生系统调用
缓冲 BufferedInputStream 把下层的多次小读合并成一次大读
基本类型 DataInputStream 把字节组装成 intlong、UTF 字符串
压缩 GZIPInputStream 把压缩字节变成原始字节,自己有 512 字节输入缓冲
校验 CheckedInputStreamDigestInputStream 旁路计算,不改数据
字符集 InputStreamReader 字节到字符,引入编码状态
文本 BufferedReader 按行切分,再叠一层字符缓冲

8192 是从哪来的

BufferedInputStream 的默认缓冲是 8192 字节,两个 JDK 都一样:

  • JDK 21:java.base/java/io/BufferedInputStream.java:58private static final int DEFAULT_BUFFER_SIZE = 8192;
  • JDK 25:同文件 :62,同一行代码
  • 无参构造直接转调带 size 的构造:JDK 21 :218-219、JDK 25 :219-220this(in, DEFAULT_BUFFER_SIZE)

javadoc 没有解释这个数字的来源。能验证的只有它和硬件常数的关系:本机 APFS 的块大小是 4096 字节(stat -f %k 输出 4096),虚拟内存页是 16384 字节(pagesize 输出 16384)。8192 各是它们的两倍和一半,正好夹在中间。

512 和 8192 的冲突

GZIPInputStream 的默认输入缓冲是 512 字节,不是 8192:

  • JDK 21:java.util.zip/GZIPInputStream.java:90-91this(in, 512)
  • JDK 25:同文件 :113-114,同样 this(in, 512)
  • 这个 size 一路传到 InflaterInputStream:JDK 21 :89、JDK 25 :111buf = new byte[size]
  • fill() 只做一件事——用这个 512 字节的数组向下层要数据:JDK 21 InflaterInputStream.java:262-264、JDK 25 :306-308len = in.read(buf, 0, buf.length)

所以一个 GZIPInputStream 会给它的下层发出每 512 字节一次的读请求。这是第四节所有现象的根源。

三、实测一:逐字节读 48 MiB

测量方法

  • 文件:48 MiB = 50,331,648 字节,放在 APFS 卷上
  • 机器:Apple M1 Pro,macOS Darwin 25.6.0
  • JDK:21.0.8+12-LTS-25025+37-LTS-3491,都是 arm64
  • 每轮新起一个 JVM,先在临时小文件上跑一遍预热(结果丢弃),再对目标文件计时一次;跑 3 轮取中位数
  • 本机没有 JMH。这是单机微基准,只用来判断量级,不能当成硬件上限

系统调用计数怎么拿到

macOS 上 dtruss -c 会直接失败:

1
2
3
$ sudo dtruss -c java -cp out Hello
dtrace: system integrity protection is on, some features will not be available
dtrace: failed to execute .../bin/java: Operation not permitted

fs_usage 能跑,但输出是系统级事件流,按进程过滤短命 JVM 不现实。

换一条路:把计数放在 libc 边界上。写一个 dylib,在 __DATA,__interpose 段里替换 readpreadwriteopenclose,用 DYLD_INSERT_LIBRARIES 注入 JVM。这样每次被替换的函数调用就是一次真实的系统调用,按 fd 归因到打开的文件路径上:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
ssize_t read(int fd, void *buf, size_t n);

static ssize_t my_read(int fd, void *buf, size_t n) {
ssize_t r = read(fd, buf, n); /* 调用真正的 read(2) */
atomic_fetch_add(&total_calls, 1);
if (r > 0) atomic_fetch_add(&total_bytes, (long)r);
note(fd, 1, r > 0 ? (long)r : 0); /* 按 fd 归因到文件路径 */
return r;
}

__attribute__((used)) static struct { const void *repl; const void *orig; } interposers[]
__attribute__((section("__DATA,__interpose"))) = {
{ (const void *)&my_read, (const void *)&read },
/* write / pread / open / close 同理 */
};

这是 libc 层的用户态计数。它可信的理由是能交叉验证:read(byte[8192]) 读 48 MiB,libc 层计到 6145 次,同时 Java 用户态计数也是 6145 次。50331648 除以 8192 正好是 6144,加上最后一次读到 EOF 的那一回,两个数对得上。

结果

读法 JDK 21 中位数 JDK 25 中位数 read(2) 次数 Java 层 read 调用
FileInputStream 逐字节 25,486 ms 25,515 ms 50,331,649 50,331,649
BufferedInputStream 逐字节 695 ms 474 ms 6,145 50,331,649
FileInputStream + read(byte[8192]) 16 ms 17 ms 6,145 6,145
BufferedInputStream + read(byte[8192]) 17 ms 16 ms 6,145 6,145
双层 BufferedInputStream 逐字节 667 ms 503 ms 6,145 50,331,649

四种读法的时间与系统调用次数对比

第一行和第三行是同一份数据、同一个进程,只差一个「怎么读」:25.5 秒 对 17 毫秒。系统调用次数差约 8190 倍,时间差 1499 倍。这是装饰器买到的量级。

两个反直觉的数字

一、消费端已经按大块读时,这一层测不出收益。 第三行和第四行差 1ms,而且符号在两个 JDK 之间是反的:JDK 21 是 16ms 对 17ms(加了更慢),JDK 25 是 17ms 对 16ms(加了更快)。1ms 贴着本次测量的分辨力,不能据此断言加了会更快或更慢。能确定的是源码里的两条事实,JDK 21 BufferedInputStream.java:336-345、JDK 25 :319-328

1
2
3
4
5
6
7
8
/* If the requested length is at least as large as the buffer, and
if there is no mark/reset activity, do not bother to copy the
bytes into the local buffer. In this way buffered streams will
cascade harmlessly. */
int size = Math.max(getBufIfOpen(false).length, initialSize);
if (len >= size && markpos == -1) {
return getInIfOpen().read(b, off, len);
}

请求长度不小于缓冲长度时,直接透传,不复制。付出的是每次调用一次加锁和一层虚调用。

二、JDK 21 上,把 BufferedInputStream 子类化反而更快。 逐字节读:695ms(直接用)对 521ms(子类化后),后者快 25%。这违反「子类化会掉出快速路径」的直觉,原因在构造函数里,JDK 21 BufferedInputStream.java:240-248

1
2
3
4
5
6
7
8
9
10
initialSize = size;
if (getClass() == BufferedInputStream.class) {
// use internal lock and lazily create buffer when not subclassed
lock = InternalLock.newLockOrNull();
buf = EMPTY;
} else {
// use monitors and eagerly create buffer when subclassed
lock = null;
buf = new byte[size];
}

getClass() == BufferedInputStream.class 为真时走 jdk.internal.misc.InternalLock(内部是 ReentrantLock),为假时退回普通 monitor。做 A/B 验证:JDK 21 加 -Djdk.io.useMonitors=true(这个属性会让 InternalLock.newLockOrNull() 返回 null,InternalLock.java:39-47)之后,同一个逐字节读法从 687ms 掉到 503ms,正好落到子类化那条路径上;JDK 25 上这个开关没有任何作用(479ms 对 477ms)。

因为 InternalLock 已经在 JDK 24 被删掉。JDK-8343039「Remove jdk.internal.misc.InternalLock and usages from java.io」,Fix Version 24,描述里写着 JEP 491 集成之后就可以把 java.io 改回统一的 synchronized。所以 JDK 21 的 src.zip 里有 jdk/internal/misc/InternalLock.java,JDK 25 的没有,javap --module java.base jdk.internal.misc.InternalLock 在 25 上直接报「找不到类」。

结论不必记成「JDK 21 慢」:这条分支只影响逐字节读这种极端场景,而且方向是反的。别假设 JDK 内部对 BufferedInputStream 的策略在两个 LTS 之间不变

缓冲调多大才有用

bufferSize 从 64 字节扫到 1 MiB,逐字节消费,量出 syscall 次数和耗时:

bufferSize read(2) 次数 JDK 21 耗时 JDK 25 耗时
64 786,433 1,164 ms 934 ms
512 98,305 736 ms 512 ms
1 KiB 49,153 710 ms 483 ms
4 KiB 12,289 685 ms 461 ms
8 KiB(默认) 6,145 677 ms 476 ms
64 KiB 769 667 ms 463 ms
256 KiB 193 656 ms 457 ms
1 MiB 49 654 ms 456 ms

缓冲大小扫描:系统调用数与耗时

从 64 字节到 8192,syscall 数量降了 128 倍,耗时只降了 1.7 倍。从 8192 再到 1 MiB,syscall 又降 125 倍,耗时只降 3%。8192 已经在收益曲线的拐点右侧,调大它只是在 syscall 计数上好看。

这条曲线的形状也说明「一次系统调用值多少时间」:JDK 21 上 64 字节方案比 8 KiB 方案多出 780,288 次系统调用,多花 487ms,约 0.62 微秒一次。同一次实验里 JDK 25 的数换算是 0.59 微秒。这个量级只在系统调用次数上到百万级时才值钱。

四、实测二:同一份压缩数据,缓冲层放哪一层

数据

48 MiB 不可压缩数据(/dev/urandom 拼出来的),用 gzip -1 压成 50,346,969 字节——比原文件还大一点。所有解压结果都校验过 CRC32 = 1647031944,与原始文件一致,确认没有读错。

缓冲层的位置决定系统调用粒度

组合 JDK 21 JDK 25 read(2) 次数
GZIPInputStream(FileInputStream) 无缓冲 108 ms 108 ms 98,347
BufferedInputStream(GZIPInputStream(FileInputStream)) 缓冲在外 123 ms 125 ms 98,347
GZIPInputStream(BufferedInputStream(FileInputStream)) 缓冲在内 64 ms 63 ms 6,146
两层都加 75 ms 73 ms 6,146

gzip 缓冲层位置对比

缓冲在外的那一层,一次系统调用都没省下来。 98,347 次与不缓冲时一模一样,还慢了 15ms 到 17ms——多出的是一次加锁和一层转发。

原因是小读请求来自更内层的 GZIPInputStream。它在 fill() 里向下层要 512 字节,这个请求到不了外层的 BufferedInputStream;外层收到的反而是应用发出的大请求,按 read1 的透传分支又原样交下去了。外层缓冲唯一能省的,是它自己到下层的 Java 层调用次数。

把缓冲垫在解压层下面,512 字节的请求被 8192 字节的缓冲吸收,syscall 从 98,347 降到 6,146,时间减半。

顺序对错还取决于消费端

同一组流,把消费方式换成逐字节 read(),结论就反过来了:

组合 JDK 21 JDK 25 read(2) 次数
无缓冲 13,991 ms 14,152 ms 98,347
缓冲在外 819 ms 570 ms 98,347
缓冲在内 13,934 ms 14,151 ms 6,146

消费端逐字节读时,缓冲放在内层省不到时间:read(2) 次数确实降到了 6,146,但每次 read() 都直接穿过 GZIPInputStream,触发一次 1 字节的 inflate 调用,48 MiB 就是 5000 万次,瓶颈从系统调用换成了 inflate 本身。缓冲在外则把这些调用挡在 8192 字节的缓冲里:每次填满外层缓冲只需要约 16 次内层调用,48 MiB 数据的内层 inflate 调用从 4800 万次降到约 9.8 万次(这个数正好等于上表里的 98,347 次 read(2),因为每次 fill() 读进 512 字节压缩数据,inflate 就产出约 511 字节)。

所以「哪个顺序对」没有唯一答案,取决于谁在产生小请求

flush 不是一条链上统一的语义

多层装饰之后,flush() 到底保证什么,要逐层查。把 64 KiB 可压缩数据写进不同嵌套的流,每次只 flush()close(),再用 gunzip -t 验证:

写法 flush 后文件大小 gunzip -t
GZIPOutputStream(fos) + flush() 10 字节 失败
GZIPOutputStream(fos) + close() 799 字节 通过
BufferedOutputStream(GZIPOutputStream(fos)) + flush() 10 字节 失败
BufferedOutputStream(GZIPOutputStream(fos)) + close() 799 字节 通过
GZIPOutputStream(BufferedOutputStream(fos)) + flush() 10 字节 失败
GZIPOutputStream(BufferedOutputStream(fos)) + close() 799 字节 通过
GZIPOutputStream(fos, 8192, syncFlush=true) + flush() 795 字节 失败

10 字节就是 gzip 的文件头。三种嵌套顺序里,flush() 后的文件大小一样——缓冲层放在哪一侧,对这件事没有任何影响。决定它的是压缩层:GZIPOutputStream 默认 syncFlush=falseflush() 只是把调用转给下层,不产出任何压缩数据。只有把 syncFlush 打开,数据才会被吐出来,但 CRC 和长度这两项 trailer 仍然要等 finish()

反过来,BufferedOutputStreamflush() 会级联调用下层的 flush(),所以两层缓冲嵌套时 flush() 能把数据一路推到底。会卡住你的是链上某一层的 flush() 语义和你的预期不符——这件事跟包了几层无关,跟包了哪一层有关。

BufferedOutputStream 的滞留窗口

BufferedOutputStream 写多少字节才落盘,取决于单次写入的大小:

单次 write 不 flush 时文件大小 说明
100 字节 0 全文滞留
8191 字节 0 全文滞留
8192 字节 8192 单次写入不小于缓冲,直接穿透
8193 字节 8193 同上
100 字节 + flush() 100 flush 推给下层

穿透的判断是 if (len >= maxBufSize),JDK 21 在 BufferedOutputStream.java:212,JDK 25 在 :178,注释同样是 “buffered streams will cascade harmlessly”。

写 200,000 次单字节,两种写法在系统调用层面的差别:

写法 耗时 write(2) 次数 未 flush 时磁盘上的字节数
FileOutputStream 371 ms 200,000 200,000
BufferedOutputStream 7 ms 25 196,608(滞留 3,392)

196,608 正好是 24 个 8192,剩下的 3,392 还在缓冲里。这就是「忘记 flush」丢掉的量:最后一次没写满的那部分。

虚拟线程上的输出缓冲

new BufferedOutputStream(out) 拿到的初始缓冲,在平台线程和虚拟线程上不一样:

  • JDK 21 与 JDK 25 的 BufferedOutputStream.java 都有 DEFAULT_INITIAL_BUFFER_SIZE = 512(21 在 :42,25 在 :46
  • 无参构造调 initialBufferSize()(JDK 25 :70-76):Thread.currentThread().isVirtual() 为真时返回 512,否则返回 DEFAULT_MAX_BUFFER_SIZE = 8192
  • 用反射读 buf.length 验证(需要 --add-opens java.base/java.io=ALL-UNNAMED):平台线程 8192,虚拟线程 512,显式传 size 时两条路径都是 8192。两个 JDK 结果一致

缓冲可以增长到 maxBufSize,所以这个差异不会改变文件可见的 flush 时机,只影响初始分配。同一个构造调用,在不同执行环境下拿到的东西可能不同

五、实测三:装饰器与继承的对照

同一组能力,CRC32 校验和 + 读取次数统计,两种写法各实现一遍。装饰器版是两个类:

1
2
3
4
5
6
7
8
9
10
static final class CountingInputStream extends FilterInputStream {
long calls, bytes;
CountingInputStream(InputStream in) { super(in); }
@Override public int read() throws IOException {
calls++; int b = in.read(); if (b >= 0) bytes++; return b;
}
@Override public int read(byte[] b, int off, int len) throws IOException {
calls++; int n = in.read(b, off, len); if (n > 0) bytes += n; return n;
}
}

继承版要为每种顺序各写一个类,类的基类是 FileInputStream

1
2
3
4
5
6
7
8
9
10
11
static class CountingChecksumFileInputStream extends ChecksumFileInputStream {
long calls, bytes;
CountingChecksumFileInputStream(String name) throws IOException { super(name); }
@Override public int read(byte[] b, int off, int len) throws IOException {
calls++; int n = super.read(b, off, len); if (n > 0) bytes += n; return n;
}
}

static class ChecksumCountingFileInputStream extends FileInputStream {
/* read 的逻辑从头抄一遍,只把顺序反过来 */
}

结果

实现 类数 有效代码行 crc readCalls
装饰器 校验在外 / 计数在内 2 16 1647031944 6,145
装饰器 计数在外 / 校验在内 2 16 1647031944 6,145
继承 先校验后计数 3 26 1647031944 6,145
继承 先计数后校验 3 26 1647031944 6,145
装饰器 套在 GZIP 解压流上 复用 0 1647031944 98,335
装饰器 套在 15 字节小文件上 复用 0 2988202360 2

四种实现的结果一致,校验和与调用次数都对得上,说明两种写法在语义上等价。差别在别的地方。

代码行数不能说明问题:26 行对 16 行只是 1.6 倍。差别在复用维度。装饰器那 2 个类可以套在 FileInputStreamGZIPInputStreamByteArrayInputStream、任何 InputStream 上,顺序随便换,结果一致——表里最后两行就是没写任何新代码套上去的。继承版那 3 个类只能读文件,顺序是编译期定死的,想套到解压流上要重写一遍。

类数量的增长方式才是要害,而且要把顺序算进去——第四节已经证明顺序不是自由选择。2 种能力:继承要 3 个类(补齐「只计数不校验」那种是 4 个),装饰器 2 个。3 种能力:继承要 15 个(3 种单能力 + 6 种两两组合 × 2 个顺序 + 6 种三能力全排列),装饰器 3 个。再把数据源当成一个维度:装饰器不增加任何类,继承是每个组合一个新类。

六、结论

收益是量级差

三组实验里最大的收益都来自同一个机制:把系统调用次数从百万量级降到千量级。同样逐字节读 48 MiB,从 50,331,649 次降到 6,145 次,时间从 25.5 秒降到 474 毫秒(JDK 25);换一种读法,裸流自己按 8192 字节读只要 17 毫秒。压缩流从 98,347 次降到 6,146 次,时间减半。这种收益不需要精细测量就能看出来。

代价是可测的三条

一是一层间接。这一条只在能被测出来的地方成立:gzip 缓冲在外时,多出的转发与加锁稳定地值 15ms 到 17ms,两个 JDK 上方向一致。消费端本来就按大块读时,加与不加的差落在 1ms 内、符号还在两个 JDK 之间翻转,属于测不出来而不是没有成本。

二是顺序耦合。缓冲层放错位置,16 倍的 syscall 差会整个消失;消费模式一换,谁对谁错还会反转。装饰器让「加一层」变便宜,也就让「加错一层」变便宜——编译器不会报错,接口也匹配。

三是生命周期语义要逐层查flush()BufferedOutputStream 上是「推给下层」,在 GZIPOutputStream 上是「什么也不做」。close() 会传播,但传播的是调用,不是保证。

什么时候自己写装饰器

满足这两个条件时,装饰器是正解:

  1. 这个能力是横切的,和具体数据源无关——计数、校验、限流、审计、重试。
  2. 它的正确实现只需要覆写一两个方法,read(byte[], int, int) 加上 read() 基本够了。

上面那 2 个类一共 16 行,就换来了任意顺序、任意数据源的组合能力。这个投入产出比很难被别的写法超过。

什么时候不用

  • 不需要保持接口时,一次函数调用更简单。 in.transferTo(out)in.readAllBytes()Files.copy 都是一次调用,不用包一层。装饰器的前提是调用方坚持要 InputStream 这个类型。
  • 消费端本来就按大块读时,先量再包。 第三节第三行与第四行差 1ms 且符号不定,就是这个场景:这一层在这个用法下没有可测收益,别默认它会加速。
  • 想调缓冲大小时,先看拐点。 8192 已经过了拐点,改到 1 MiB 换回 3% 的时间,代价是每线程多占 1 MiB 内存。并发 1000 个连接就是 1 GiB。

一条判断顺序

先问「谁产生小读请求」,把缓冲垫在它下面;再问「谁负责收尾」,把 close() 放在最外层;最后问「链上哪一层的 flush() 会真的吐出数据」,如果答案是「没有」或者「不确定」,就换成先写完整再交付,而不是指望中途 flush()

总结

  • 装饰器解决的是组合爆炸:2 种能力 × 2 种数据源在继承下要 10 个类,装饰器要 4 个。JDK 25 源码里 39 个类用 extends Filter*Stream 给字节流加能力,只有 2 个文件用 extends FileInputStream,且用途是替换 fd。
  • BufferedInputStream 默认 8192 字节(JDK 21 BufferedInputStream.java:58,JDK 25 :62);GZIPInputStream 默认 512 字节(JDK 21 GZIPInputStream.java:90-91,JDK 25 :113-114)。这两个数字的差别决定了缓冲层该放哪。
  • 48 MiB 逐字节读:裸流 25.5 秒 / 50,331,649 次 read(2),缓冲流 474ms / 6,145 次。量级差来自系统调用次数。
  • 消费端按大块读时,加不加 BufferedInputStream 的耗时差在 1ms 内、符号在两个 JDK 之间翻转(JDK 21 加更慢,JDK 25 加更快),按本次测量的分辨力区分不出来。
  • BufferedInputStream(GZIPInputStream(FileInputStream)) 的 syscall 次数与不缓冲时相同,且慢 15ms。缓冲要垫在发出小请求的那一层下面。
  • 消费端改逐字节读,两个顺序的优劣反转:缓冲在外 570ms,缓冲在内 14,151ms。
  • flush() 不是统一语义:GZIPOutputStream 默认 syncFlush=falseflush() 后文件只有 10 字节的 gzip 头,gunzip -t 失败;缓冲层的位置影响不了这一点。
  • 200,000 次单字节写在裸流上是 200,000 次 write(2),缓冲后是 25 次;忘记 flush 滞留的是最后一次没写满的部分(3,392 字节),不是 8192。
  • JDK 21 的 BufferedInputStream 对非子类用 InternalLock,子类用 monitor;A/B 差了 184ms(687ms 对 503ms)。InternalLock 已在 JDK 24 随 JDK-8343039 删除,JDK 25 上同一个开关无效。

参考资料

系列索引:设计模式系列