外观模式的公开描述是「为子系统提供统一接口」。这句话在评审里没有用,谁也说不清「统一」到什么粒度。
换一个可测量的说法:外观把 N 次细粒度调用收成 1 次粗粒度调用,收益上限就是省下的那 N-1 次调用的固定成本。
本文写一万条小记录:逐条裸写、套缓冲、内存拼装后单次写,用 write(2) 计数和两种 JDK 的耗时给这三条路的差价结账,再看 JDK 自己怎么做外观。

一、把「统一接口」换成调用次数

外观模式的介绍通常画一张图:客户端指向一个 Facade 类,Facade 再指向三个子系统的类。图里没有数字,读完记不住任何可执行的东西。

把接口设计换成计费问题就具体了。每次调用都有一条固定成本:跨边界的开销。这个边界可能在进程之间、网络之间、内核与应用之间,也可能就在模块之间。外观把 N 次调用合并成 1 次,收益就是省下的 N-1 条固定成本;代价是这次合并本身要付参数打包、错误语义合并、内存占用的账。

写文件的例子把这两种成本都摆了出来。同样一万条记录、同样的字节,三种写法:

  • 逐条 FileOutputStream.write(byte[]),每条一次系统调用。
  • 套一层 BufferedOutputStream,调用次数不变,系统调用按缓冲大小摊销。
  • 先在内存里拼成一个数组,最后 write 一次。

调用次数和耗时这两把尺子量出来的差距不一样大。

外观与相邻模式的边界:装饰器不改接口,只在接口上叠行为;适配器改接口形状,把不兼容的调用变成兼容的调用;桥接拆的是两个独立变化的维度。外观不改变单个调用的形状,它减少调用的次数,把一串调用换成一个入口。

把 JDK 里的外观拆开看,有三种做法,本实验三条路各占一种:

做法 例子 调用次数怎么变 代价
摊销 BufferedOutputStream 应用层不变,系统调用按缓冲大小摊薄 用户态缓冲、延迟、崩溃时丢最后一段
合并 Files.write(path, lines) 一万次调用收成一次 需要一次性拿到全部参数
编排 Files.copyFileChannel.transferTo 多个子系统调用收成一次 具体走哪条实现由 JDK 决定,调用方看不见

三种做法在调用次数上的收益量级依次变大,代价也依次变重。总纲那篇量过 Visitor、Strategy、Command、Template Method 的代码行数与耗时,这里换成调用次数与系统调用次数:外观的收益上限,取决于它能把 N 收到多小。

调用次数有两层:应用层的调用和跨越边界的调用。BufferedOutputStream 把第二层从 10,000 收到 64,第一层不动;Files.write(lines) 把第一层也收掉了,调用方只发一个请求。两层收益的量级不同,本实验把它们分开量。

二、实验设计

数据与四个变体

一万条记录,每条 52 字节的定长文本行,形如 id=000042,sku=SKU-0155,qty=42,price=13.94,note=n046。总量 520,000 字节。记录从一个 64 条的池子里循环取,字符串转字节只做一次,计时循环里没有 String.format、没有字符集编码、没有分配。三个写法写出的字节必须逐字节相同,否则比较无意义。

  • rawFileOutputStream.write(byte[]),逐条。
  • bufferedBufferedOutputStream(FileOutputStream, 8192),逐条写同样的数组。
  • assembled:先在内存里拼好 520,000 字节的数组,一次 write(byte[])
  • bytewise:同一个文件、同一批字节,但每次只写一个字节(write(int)),把调用粒度再降一格,用来看成本模型在 N 放大 52 倍时是否还成立。

校验方式是把所有输出文件(含下面提到的两个 JDK 自带 API)都算 SHA-256,前 8 字节全部相同:7147638db3034d31。一万条记录的文件大小都是 520,000 字节。

再加两个对照,用的是 JDK 自己的外观:Files.write(path, lines)Files.write(path, bytes)。前者接收 List<String> 加平台换行,后者接收一个字节数组。它们不是本篇要比较的写法,是用来观察 JDK 把「一行调用」翻译成多少次底层操作。

系统调用计数怎么拿

macOS 上 dtruss 被系统完整性保护挡住,dtrace: failed to execute .../bin/java: Operation not permittedfs_usage 的输出是系统级事件流,过滤短命的 JVM 进程不现实。这一段在装饰器篇已试过一遍,本篇沿用同一条路。

写一个 dylib,在 __DATA,__interpose 段里替换 writeopenclose 三个 libc 符号,用 DYLD_INSERT_LIBRARIES 注入 JVM。每次进入 my_write 就是一次真实的 write(2),按 fd 归因到打开时的路径,进程退出时打印:

1
2
3
4
5
6
7
static ssize_t my_write(int fd, const void *buf, size_t n) {
ssize_t r = write(fd, buf, n);
atomic_fetch_add(&total_writes, 1);
if (r > 0) atomic_fetch_add(&total_wbytes, (long)r);
note_w(fd, r > 0 ? (long)r : 0);
return r;
}

计数口径能互相验证:写入字节数按路径求和后必须等于文件大小,测得 520,000 与 5,200,000 两个总量都与 ls -l 一致;JVM 自己的类文件、字体文件也有写操作,按路径过滤掉。这两个事实让计数可信。

计时怎么拿

计时循环放在全局文件锁内,一次只跑一个基准,避免并行任务抢 CPU。每个变体先跑 2 轮预热丢弃,再跑 5 轮取中位数。JDK 21.0.8 与 JDK 25 各跑一遍。取值区间用最小值和最大值一起报,不报标准差。

另有两个探针,都不需要计时,用来量外观的两种代价:

  • 故障探针:每条路各写 7,437 条记录,然后 Runtime.halt() 结束进程,不 flush、不 close,再从进程外看文件里剩下多少字节。进程崩掉时,有意义的问题是:有多少数据根本没有离开过用户态。
  • 内存探针:把记录数放大到 100 万条(52 MB 负载),用 -Xmx32mbufferedassembled,看谁先撞墙,并记录堆峰值。

这是一台 Apple M1 Pro 上的单机微基准,不是 JMH,没有 fork 隔离,只用来判断量级。JVM 启动、类加载、文件系统页缓存的冷热都会进来。绝对耗时不能当硬件上限读,比值要看两个 JDK 是否给出相同方向。

三、实测

两张尺子量同一件事

先看系统调用次数。一万条记录在两种 JDK 下的计数一致:

写法 应用层 write 调用 write(2) 次数 每次系统调用平均字节
raw 逐条裸写 10,000 10,000 52
buffered 8192 缓冲 10,000 64 8,125
assembled 拼装单写 1 1 520,000
Files.write(lines) 1 64 8,125
Files.write(byte[]) 1 64 8,125
bytewise 单字节写 520,000 520,000 1

再看待机耗时,取 5 轮中位数:

写法 JDK 21.0.8 JDK 25 单条记录成本(JDK 25)
raw 逐条裸写 19.59 ms 23.62 ms 2,362 ns
buffered 8192 缓冲 0.74 ms 0.78 ms 78 ns
assembled 拼装单写 0.29 ms 0.20 ms 20 ns
bytewise 单字节写 891 ms 883 ms 88,310 ns
Files.write(lines) 4.92 ms 1.96 ms 196 ns
Files.write(byte[]) 0.79 ms 0.56 ms 56 ns

调用次数与耗时的对照

裸写比拼装慢 117 倍(JDK 25)和 68 倍(JDK 21),系统调用次数差 10,000 倍。两者的关系可以算成一个斜率:裸写与缓冲写法相差 9,936 次系统调用,中位耗时相差 22.84 ms,摊下来每次 write(2) 约 2.3 微秒。这个数字只对本机、本文件系统、这个记录形态成立,换台机器要重测。

重复性。 同一批测量在两个锁窗口里各跑了一遍。差异最大的是 raw:第一次 25.99 ms 与 24.34 ms,第二次 19.59 ms 与 23.62 ms,波动约 25%。Files.write(lines) 的波动更大(第二次的 JDK 21 落在 2.61 ms 到 9.04 ms 之间)。表格用的是第二次的完整数据。下面的结论只建立在量级上,例如「裸写与拼装相差两个数量级」,这类结论在两次测量里都成立。

一行 Files.write(path, lines) 顶掉了一万次 write 调用,代价是 1.96 ms(JDK 25),比手动编码加缓冲的 0.78 ms 慢 2.5 倍。这一份差价来自一次性的字符串编码:写同一批字节的 Files.write(path, bytes) 只要 0.56 ms,与手写缓冲同档,写入路径本身没有多花时间。

把量放大十倍再看

记录数从 1 万涨到 10 万,字节从 520,000 涨到 5,200,000:

写法 1 万条的 write(2) 10 万条的 write(2) 增长倍率 10 万条耗时 JDK 21 / JDK 25
raw 10,000 100,000 10.0 195.2 ms / 191.4 ms
buffered 64 637 10.0 8.85 ms / 7.72 ms
assembled 1 1 1 2.13 ms / 2.42 ms
Files.write(lines) 64 635 9.9 10.27 ms / 9.56 ms
Files.write(byte[]) 64 635 9.9 4.00 ms / 4.45 ms

除拼装写法外,调用次数随 N 线性增长,耗时也按同一倍率放大:裸写从 23.62 ms 涨到 191.4 ms(8.1 倍),缓冲从 0.78 ms 涨到 7.72 ms(9.9 倍)。拼装写法的耗时从 0.20 ms 涨到 2.42 ms,涨幅来自那一次 5,200,000 字节的 write,系统调用次数始终是 1。

线性关系决定了这个模式的适用面:细粒度调用的成本正比于 N,粗粒度调用的成本正比于数据量本身。N 越大,两条线的差距越大。

把粒度再降一格

同一个文件、同一批 520,000 字节,改成每次写一个字节:系统调用计数是 520,000 次,与应用层调用次数相等,写出的字节与前几种写法逐字节相同。

写法 应用层 write 调用 write(2) 次数 每次系统调用平均字节 耗时中位数 JDK 21 / JDK 25
bytewise 单字节写 520,000 520,000 1 891 ms / 883 ms

按前面的斜率 2.3 微秒算,520,000 次系统调用预期约 1.2 秒,实测 0.88 秒,单字节写的每次系统调用比 52 字节写便宜三成,因为要搬的数据少。这条例子把模型的两端连起来:调用次数从 1 涨到 520,000,耗时从 0.20 ms 涨到 883 ms,比值约 4,400 倍。中间几条写法(10,000 次的裸写、64 次的缓冲)都落在这条直线上,斜率由系统调用次数决定。

缓冲层的两次记账

buffered 在 1 万条时是 64 次,10 万条时是 637 次,都不是 8192 除下来的整数:刷写以 52 字节的整条记录为单位,除不尽。

  • 缓冲区 8192 字节,能放 157 条完整记录,占 8,164 字节,第 158 条放不下。
  • 第 158 条触发刷写,下一次又从零开始攒。
  • 10,000 条 → 63 次满刷,剩下 109 条在 close() 时刷出,合计 64 次。
  • 100,000 条 → 636 次满刷,剩下 148 条在收尾时刷出,合计 637 次。

637 = ⌈100000 ÷ 157⌉,账对得上。这也解释了为什么缓冲的价值要看记录大小与缓冲大小的比值:8192 字节的缓冲区摊在 52 字节的记录上有 157 倍放大,摊在 8,000 字节的记录上只有 1 倍,套缓冲就没意义了。

缓冲省掉了什么,没省掉什么

bufferedassembled 的系统调用只差 63 次,耗时差 3.9 倍(0.78 ms 对 0.20 ms)。这 0.58 ms 花在那 10,000 次 Java 层调用上:每次进入 BufferedOutputStream.write 要拿一次锁、做一次边界检查、一次 System.arraycopy,合计 520,000 字节的搬运。78 ns 一条的成本主要出在这些操作上,不在系统调用。

外观的三层收益,量级依次是:

  • 系统调用次数,从 10,000 到 64,量级差。这是主要收益。
  • Java 层调用次数,从 10,000 到 1,但剩下的成本是纳秒级。
  • 调用方代码行数,从一行循环变成一行调用。

第一层决定要不要做,第二层决定能省到什么程度,第三层是前两层的结果。

故障时落盘多少

合并调用次数会把失败的单位一起合并。每条路写 7,437 条记录(386,724 字节)后立刻 halt(),不 flush 不 close,文件里剩下的字节是:

写法 文件里剩下的字节 记录数 丢失的数据在哪
raw 逐条裸写 386,724 7,437 没有丢失,全部已交给内核
buffered 8192 缓冲 383,708 7,379 58 条(3,016 字节)在用户态缓冲区
assembled 拼装单写 0(文件不存在) 0 全部 7,437 条还在内存里,没开始写

这些数字都对得上账:3,016 = 58 × 52,7,379 = 47 × 157。缓冲写法每 157 条刷一次,崩溃时缓冲区里正好是下一批没攒满的零头。拼装写法更极端,进程死之前一次 write 都没发出,文件还是空的。

失败可见性是外观容易被忽略的一项代价:Files.write(lines) 这类接口把「一万次写」变成一个动作,出错时调用方只看到一个异常,看不出写到了第几条。数据在磁盘上的可见性也跟着变了:缓冲写法的可见性以 8,164 字节为粒度,拼装写法以整批为粒度。

内存的天花板

把记录数放大到 100 万条,负载 52 MB,堆限制 32 MB,两个 JDK 上的结果一样:

写法 堆峰值 结果
buffered 8192 缓冲 2.4 MB(JDK 21)/ 2.1 MB(JDK 25) 正常写完 52,000,000 字节
assembled 拼装单写 撞到堆上限 OutOfMemoryError

缓冲写法的内存占用是常数:一个 8,192 字节的数组,与批量大小无关。拼装写法要求一次拿到 52 MB 的连续数组,堆给不出来就失败。批量的规模上限由内存决定,这是外观设计时要写进文档的参数。JDK 自己清楚这一点,所以 Files.write(byte[]) 的 8192 分块(下面源码一节)不要求调用方一次性交出全部数据。

两个探针的结果合起来:外观减少调用次数靠的是「攒」,攒在用户态缓冲里,代价是失败可见性与内存占用。这两项在调用次数那张表里看不到。

四、JDK 源码里的外观

JDK 里这类外观到处都是。我按 JDK 25 src.zip 逐条对了一遍上面用到的和没用到的方法,看它们把 N 收敛到什么程度。

BufferedOutputStream:批量刷写的账本

JDK 25 的 BufferedOutputStream 有两个常量,一个初始值一个上限(java.base/java/io/BufferedOutputStream.java:46-47):

1
2
private static final int DEFAULT_INITIAL_BUFFER_SIZE = 512;
private static final int DEFAULT_MAX_BUFFER_SIZE = 8192;

显式传缓冲大小时两个值取同一个数(:116-118),write(byte[], int, int) 的逻辑是这里的关键(:177-192):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public synchronized void write(byte[] b, int off, int len) throws IOException {
if (len >= maxBufSize) {
/* If the request length exceeds the max size of the output buffer,
flush the output buffer and then write the data directly.
In this way buffered streams will cascade harmlessly. */
flushBuffer();
out.write(b, off, len);
return;
}
growIfNeeded(len);
if (len > buf.length - count) {
flushBuffer();
}
System.arraycopy(b, off, buf, count, len);
count += len;
}

实测的数字来自这段逻辑。写入请求不小于缓冲上限时直接透传,不做二次拷贝。装不下的记录触发一次刷写再落到位,缓冲区攒到 8,164 字节就刷,离 8,192 差 28 字节,放不下第 158 条 52 字节的记录。flushBuffer 只有 4 行(:121-126),它把 out.write(buf, 0, count) 交给下层,这是几种写法里唯一一处把多次调用合并成一次的地方。

write(int)write(byte[], int, int) 都带 synchronized。这一层锁是为了单流多线程安全,代价出现在每条记录 78 ns 的成本里。

Files.write(byte[]):分块是自己写的

调用方写一行 Files.write(path, bigArray),进到 java.base/java/nio/file/Files.java:3168-3184 是这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public static Path write(Path path, byte[] bytes, OpenOption... options)
throws IOException
{
// ensure bytes is not null before opening file
Objects.requireNonNull(bytes);

try (OutputStream out = Files.newOutputStream(path, options)) {
int len = bytes.length;
int rem = len;
while (rem > 0) {
int n = Math.min(rem, BUFFER_SIZE);
out.write(bytes, (len-rem), n);
rem -= n;
}
}
return path;
}

BUFFER_SIZE 在同文件 :95 声明,值是 8192。「一次性写入」在 JDK 这里同样是 8192 字节一块,5,200,000 字节分 635 块,与实测的 635 次系统调用对应。名字叫一次 write,底下是 635 次。

Files.write(lines):一行调用,两万次内部调用

另一个重载更接近本文的场景,接收行列表(Files.java:3229-3244):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
public static Path write(Path path, Iterable<? extends CharSequence> lines,
Charset cs, OpenOption... options)
throws IOException
{
// ensure lines is not null before opening file
Objects.requireNonNull(lines);
CharsetEncoder encoder = cs.newEncoder();
try (OutputStream out = newOutputStream(path, options);
BufferedWriter writer = new BufferedWriter(new OutputStreamWriter(out, encoder))) {
for (CharSequence line: lines) {
writer.append(line);
writer.newLine();
}
}
return path;
}

调用方写一行,JDK 内部做了 10,000 次 append、10,000 次 newLine,再经过 BufferedWriter 的 8192 字符缓冲与 OutputStreamWriter 的 8192 字节编码缓冲,落到 64 次系统调用。这三段容量都能在源码里查到:字符缓冲 512 起步、上限 8192(java.base/java/io/BufferedWriter.java:75-76),编码器字节缓冲的上限同样是 8192(java.base/sun/nio/cs/StreamEncoder.java:44MAX_BYTE_BUFFER_CAPACITY),最里面的 Files.write 分块还是 8192。

成本也在源码里。每写一行要两次加锁的调用:write(String, int, int)BufferedWriter.java:271-285)持锁把字符拷进缓冲,newLine():294-296)再走一遍:

1
2
3
public void newLine() throws IOException {
write(System.lineSeparator());
}

一万行记录,两万次加锁与字符拷贝,加上一次性的 UTF-8 编码,就是 Files.write(lines) 相对手写缓冲多付的约 1.2 ms。外观把这些内部调用接了过去:谁做合并、合并到多大、编码放在哪一层,调用方不再关心,也改不了。

实测里 Files.write(lines) 比手写 buffered 慢 2.5 倍(JDK 25 的 1.96 ms 对 0.78 ms),差的就是这一次性编码。省调用次数的收益是量级差,编码成本约 1.2 ms,这个代价可以接受。

同名方法在父类里是展开

BufferedOutputStream 的父类把同一个方法写成相反的方向(java.base/java/io/FilterOutputStream.java:135-141):

1
2
3
4
5
6
7
public void write(byte[] b, int off, int len) throws IOException {
Objects.checkFromIndexSize(off, len, b.length);

for (int i = 0 ; i < len ; i++) {
write(b[off + i]);
}
}

父类的批量方法把一次调用拆成 len 次单字节调用,子类覆写之后改成攒进缓冲。BufferedOutputStream 在类图上只是一层包装,行为上把父类的展开反了过来。判断一个类是不是外观,看它的 write(byte[], int, int) 往下一层发了几次调用,与它在类图上的位置无关。

FilterOutputStream 里也有纯转发的方法,write(int) 就一行 out.write(b)FilterOutputStream.java:88-90),flush() 同理(:154-156)。一次调用换一次调用,这类方法只是转发;把 N 次调用收成一次,才是外观。

Files.copy:外观按 provider 分派

同一份拷贝操作,Files.copy 分两条路(Files.java:1186-1198):

1
2
3
4
5
6
7
8
9
10
11
12
13
public static Path copy(Path source, Path target, CopyOption... options)
throws IOException
{
FileSystemProvider provider = provider(source);
if (provider(target) == provider) {
// same provider
provider.copy(source, target, options);
} else {
// different providers
CopyMoveHelper.copyToForeignTarget(source, target, options);
}
return target;
}

两个路径同文件系统时走 provider 自己的实现,本机上是 sun.nio.fs.UnixFileSystem.copyFile,里面先尝试平台直接拷贝(UnixFileSystem.java:642-655,macOS 上是 copyfile(3) 一类),失败才退回到 ByteBuffer 缓冲循环。跨文件系统时走 CopyMoveHelper.copyToForeignTarget,它最后落到(CopyMoveHelper.java:146-148):

1
2
3
try (InputStream in = Files.newInputStream(source)) {
Files.copy(in, target);
}

Files.copy(InputStream, Path, ...) 的收尾是 Files.java:2864 的一行 return in.transferTo(out);。一次 Files.copy 表面是一个调用,底下可能是一次 copyfile 系统调用、一个 8192 字节的循环,或者 transferTo 的三层机制。外观替调用方做了选择,调用方看不到自己拿到了哪一种。

transferTo:三层机制藏在一个名字后面

InputStream.transferTo 是 JDK 9 引入的粗粒度 API,默认实现(java.base/java/io/InputStream.java:790-806)是一个 16,384 字节缓冲的循环:

1
2
3
4
5
6
7
public long transferTo(OutputStream out) throws IOException {
Objects.requireNonNull(out, "out");
long transferred = 0;
byte[] buffer = new byte[DEFAULT_BUFFER_SIZE];
int read;
while ((read = this.read(buffer, 0, DEFAULT_BUFFER_SIZE)) >= 0) {
out.write(buffer, 0, read);

它的默认缓冲是 16,384 字节(同文件 :58DEFAULT_BUFFER_SIZE),与 BufferedOutputStream 的 8192 不同。16 KiB 这个数在 JDK 里反复出现:FileChannelImpl 里 mmap 路径的门槛 MAPPED_TRANSFER_THRESHOLD 也是 16 KiB(java.base/sun/nio/ch/FileChannelImpl.java:840)。

FileChannel.transferTo 走的层数最多。它的 javadoc 承诺(java.base/java/nio/channels/FileChannel.java:627-630):

1
2
3
4
* <p> This method is potentially much more efficient than a simple loop
* that reads from this channel and writes to the target channel. Many
* operating systems can transfer bytes directly from the filesystem cache
* to the target channel without actually copying them. </p>

实现是三级瀑布(FileChannelImpl.java:962-975):

1
2
3
4
5
6
7
8
9
10
// Attempt a direct transfer, if the kernel supports it,
// limiting the number of bytes according to which platform
int icount = (int) Math.min(count, nd.maxDirectTransferSize());
long n;
if ((n = transferToDirect(position, icount, target)) >= 0)
return n;

// Attempt a mapped transfer, but only to trusted channel types
if ((n = transferToTrustedChannel(position, count, target)) >= 0)
return n;
1
2
// fallback to read/write loop
return transferToArbitraryChannel(position, count, target);

第一层是 sendfilecopy_file_range 一类的零拷贝(FileChannelImpl.java:785-788 的注释写着 This implementation uses sendfile, copy_file_range or equivalent.),只支持文件到文件、文件到 socket 两种目标。第二层是 mmap,门槛 16 KiB,上限 8 MiB(:840-843)。第三层退回 8192 字节的读写循环(:1078TRANSFER_SIZE = 8192)。

一个方法名下面是三种搬运机制,调用方一句 transferTo(0, size, target) 就要接受其中任意一种。外观在这里收敛了两样东西:调用次数和实现差异,代价是性能特征从调用方视野里消失。

五、什么时候做外观,什么时候不做

判据流程

N 的门槛不是拍出来的。本机上一次 write(2) 约 2.3 微秒,一次 Java 层的加锁加拷贝约 78 纳秒,相差 30 倍。N 小于 100 时,细粒度调用的总成本不超过 0.25 ms,抵不过多一层抽象带来的维护成本。

批量接口改掉的东西

批量接口换掉的还有语义。下面四条都有本实验的数字。

内存峰值与批量规模绑定。 缓冲写法的堆峰值是 2.1 MB(JDK 25,100 万条记录),拼装写法在同样的 -Xmx32m 下直接 OutOfMemoryError。批量的上限由堆决定,接口文档里要写清楚这个上限,或者像 Files.write(byte[]) 一样内部分块,把单次分配的规模压回 8 KiB。

失败可见性以批为单位。 同样是写到 7,437 条时进程崩溃,逐条写留下 386,724 字节,缓冲写法留下 383,708 字节,拼装写法留下 0 字节。异常信息能定位到失败的批,定位不到第几条。

延迟攒在批里。 攒批意味着最后一个字节要等到批满或显式关闭才可见。BufferedOutputStreamflush() 要手动调,忘了它,最后一次没刷满的部分就留在缓冲区里(一万条时是 5,668 字节)。批量接口的写入时机要写进文档,不能留给调用方猜。

错误定位精度下降。 一万条一次写入,第 7,437 条出错时异常只带一个偏移量,调用方需要自己换算回记录序号。逐条写能在出错处停下并保留上下文。需要定位问题时,缓冲写法是折中:调用位置仍在循环里,系统调用按批摊薄,出错可以立刻停止并丢掉未刷出的部分。

一条判断顺序

按这个顺序问,四个问题都能答「是」再做批量外观:

  1. 这个边界上的单次调用成本是否远高于数据搬运成本。本机上是 2.3 微秒对 78 纳秒,差 30 倍。
  2. 一轮业务里的调用次数是否到百次量级。低于这个数,合并省下的时间小于接口带来的复杂度。
  3. 多条调用的参数能否提前拿到。拿不到就没有批量接口,只能做流水线。
  4. 失败时能否接受按批定位。不能接受就保留逐条调用,把成本花在别处。

第 2 条的阈值来自本机实测,换机器要重测。前两条决定值不值得做,后两条决定能不能做。

与相邻模式的边界

享元共享不可变对象,省的是内存;对象池复用可变对象,省的是构造与销毁。外观省的是调用次数,与内存关系不大,三个模式经常同时出现但解决的问题不同。

装饰器在不改接口的前提下叠行为,每次调用仍然只是一次转发,只是往下多走了几层,本实验里的 BufferedOutputStream 就是装饰器。同一个类既可以当装饰器用(保持 OutputStream 接口),也可以当外观用(把 N 次调用合并成 64 次)。模式的名字描述意图,不描述这个类在链上的位置。

适配器改变单个调用的形状,read() 变成 readLine();外观不改变形状,改变次数。一个方法同时做到两件事时,先想清楚调用方需要的是哪一个。

总结

  • 一万条 52 字节的记录,520,000 字节,三种写法的 write(2) 次数是 10,000、64、1,耗时中位数是 23.62 ms、0.78 ms、0.20 ms(JDK 25)。裸写与拼装差 117 倍。
  • 每次 write(2) 的边际成本约 2.3 微秒(两个 JDK 的差值方向一致)。这个数字决定了批量接口的盈亏平衡点:调用次数低于百次量级时,合并的意义不大。
  • 调用次数随记录数线性增长:10 万条时分别是 100,000、637、1 次,倍率分别是 10.0、10.0、1.0;耗时按同样倍率放大(裸写 191.4 ms,缓冲 7.72 ms)。
  • 单字节写同一批数据要 520,000 次系统调用,耗时 883 ms,是拼装写法的 4,400 倍。本实验的全部写法都落在调用次数与耗时的那条直线上。
  • BufferedOutputStream 每次攒满 8,164 字节就刷(157 条记录),剩下的零头在 close() 时刷出,所以 10 万条对应 637 次而不是 635 次。缓冲大小的收益取决于记录大小与缓冲大小的比值。
  • 缓冲写下每条记录的成本约 78 纳秒,主要来自 synchronizedSystem.arraycopy,不是系统调用。系统调用从 10,000 降到 64 的收益是量级差,Java 层调用从 10,000 降到 1 的收益是纳秒级。
  • 合并调用次数会把失败也合并:写到 7,437 条时进程崩溃,逐条写留下 386,724 字节,缓冲写留下 383,708 字节,拼装写留下 0 字节。内存上限同样绑在批量规模上:100 万条记录在 -Xmx32m 下,缓冲写法的堆峰值是 2.1 MB,拼装写法直接 OutOfMemoryError
  • JDK 自己的外观也是这么分层的:Files.write(byte[]) 内部按 8192 字节分块(Files.java:95:3177-3181),Files.write(lines) 内部做 20,000 次 append 与 newLine 后落到 64 次系统调用(:3229-3244)。实测后者慢 2.5 倍,差额来自字符编码;写同一批字节的 Files.write(byte[]) 只要 0.56 ms,与手写缓冲同档。
  • FileChannel.transferTo 一个方法名下面有三级实现:sendfile 一类的零拷贝、16 KiB 门槛的 mmap、8192 字节的读写回退(FileChannelImpl.java:962-975)。外观同时隐藏了调用次数与调用方拿不到的性能特征。
  • 做外观的判据是调用次数、参数可批量、结果可缓冲、错误定位要求四条。无法一次性拿到参数、或必须逐条定位失败时,缓冲比合并更合适。

参考资料

系列索引:设计模式系列