跳到主要内容
约 10 分钟3 张卡

零拷贝

零拷贝通过省去 I/O 路径中的特定数据副本,减少 CPU 搬运数据的工作。本文先分析普通文件传输,再比较通道传输、文件映射和直接缓冲区。最后说明内存占用、释放时机和性能验证方法。

1. 面试核心​

先记住这几条,再读正文
  • 通道传输。 FileChannel.transferTo 在平台支持时可利用 sendfile,省去文件传输中的 Java 堆缓冲副本。卡 · FileChannel.transferTo
  • 文件映射。 FileChannel.map 将文件区域映射到进程地址空间,适合随机访问,访问冷页仍有缺页和磁盘 I/O 成本。
  • 直接缓冲区。 直接 ByteBuffer 在堆外保存数据,可减少本地 I/O 的中间复制,其清理仍与 Java 对象的可达性有关。卡 · 直接 ByteBuffer
  • 部分传输。 transferTo 返回本次实际传输量,部分传输后要保存进度,等待条件满足再继续。
  • 映射生命周期。 MappedByteBuffer 的生命周期独立于文件通道,关闭通道后,已有映射仍然有效。卡 · 映射大小与释放时机

2. 普通文件传输的复制过程​

要判断零拷贝省掉了哪一步,需要先看普通文件发送经过哪里。应用用堆数组读取文件,再写入 Socket 时,可以把路径简化为:

存储设备 → 内核页缓存 → Java 堆缓冲区 → 内核 Socket 缓冲区 → 网卡

内核页缓存是操作系统在内存中保存文件数据的区域。Java 堆缓冲区则位于应用能直接访问的用户空间。具体复制路径由操作系统、文件系统、驱动和 JVM 共同决定。

通过应用缓冲区发送文件,会产生以下复制与切换开销:

  1. 应用调用读取,将文件数据从内核交到用户缓冲区。
  2. 应用再调用写入,将同一批数据交回内核网络栈。
  3. 这些调用需要跨越用户态与内核态,CPU 还要参与额外的数据复制。

如果只是原样发送文件,应用无需检查或修改每个字节。因此,平台可以让数据绕过用户缓冲区,省去这一份副本。

3. FileChannel.transferTo​

FileChannel.transferTo 把指定文件区域直接传给目标通道。原样发送文件时,可以让通道实现选择更省复制的传输方式:

try (
FileChannel source = FileChannel.open(path, StandardOpenOption.READ);
SocketChannel target = SocketChannel.open(address)
) {
long position = 0;
long size = source.size();

while (position < size) {
long transferred = source.transferTo(
position,
size - position,
target
);
if (transferred == 0) {
throw new IOException("transfer made no progress at " + position);
}
position += transferred;
}
}

示例每次从 position 开始传输,并用返回值推进位置。支持的平台可使用 sendfile,由内核组织文件到 Socket 的传输,省去 Java 堆缓冲副本。

3.1 部分传输​

一次调用只返回本次实际传输的字节数,这个数量可以小于请求量,甚至为 0。目标暂时不可写,或操作系统限制单次传输量时,调用方都要保留已经完成的位置。

示例使用阻塞目标,零进展时会抛出异常,让调用方知道文件尚未完整发送。非阻塞实现则应保存当前位置,等待可写事件后再继续。整个传输完成之前要保持通道打开,否则后续数据就无法发送。

3.2 平台差异​

调用 transferTo 后是否使用 sendfile,取决于源与目标通道、文件类型和操作系统。Java API 规定的是传输结果,具体系统调用由实现选择。因此,要在目标平台观察系统调用、CPU 和吞吐量,确认减少复制的优化是否生效。

如果应用要在用户态压缩、加密、修改协议内容或逐字节解析,数据就需要进入应用处理。这时,原样通道传输只能覆盖无需改写的部分,其余部分仍要保留对应的处理过程。

4. 内存映射​

需要反复访问文件的不同位置时,可以把文件区域映射到进程地址空间。这样,程序就能通过内存读写访问文件。FileChannel.map 返回的 MappedByteBuffer 就是访问这段区域的入口:

try (FileChannel channel = FileChannel.open(path, StandardOpenOption.READ)) {
MappedByteBuffer mapped = channel.map(
FileChannel.MapMode.READ_ONLY,
0,
Math.min(channel.size(), 1024L * 1024)
);

while (mapped.hasRemaining()) {
consume(mapped.get());
}
}

示例只映射文件开头至多 1 MiB,并逐字节交给 consume。映射建立后,文件数据由操作系统的页缓存管理。

首次访问尚未驻留内存的文件页时,会触发缺页处理。这时,操作系统需要把对应文件页读入内存,当前访问也要等待。也就是说,映射省去了普通堆数组副本,冷页访问仍有磁盘 I/O 成本。

4.1 内存映射的适用场景​

内存映射适合按位置访问文件,也适合复用已经读入的文件页。常见的访问需求包括:

  • 需要随机读取大文件中的不同区域。
  • 多个进程需要读取同一份文件页。
  • 索引、日志或数据文件采用固定布局,可以按偏移找到内容。
  • 希望减少用户态复制,并让操作系统管理页面换入换出。

如果只是顺序读取一个小文件,缓冲流或 FileChannel.read 更容易管理。选择映射时,还要考虑访问数据是否已经在内存中。

4.2 内存映射的边界​

减少复制后,程序仍需承担页面管理和文件共享的成本。使用时要处理以下五类边界:

  • 映射占用进程的虚拟地址空间,也需要页表记录地址对应关系。映射区域越大,这些管理成本越需要关注。
  • 首次访问冷页会等待缺页处理,因此单次访问延迟会升高。
  • 大范围随机访问会反复换入文件页,把其他常用数据挤出缓存,造成缓存抖动。
  • 映射有独立的生命周期,关闭文件通道后仍然保留。长期使用大量映射时,需要管理它们的引用和占用。
  • 文件被其他进程截断或并发修改时,需要约定读写顺序和一致性协议。

本地文件的 READ_WRITE 映射可以调用 force(),保证返回前将映射修改写入存储设备。非本地存储没有相同的写回保证,要按平台规则判断。

PRIVATE 映射采用写时复制,也就是修改时保留一份私有页面。修改留在这份页面中,force() 对它没有写回文件的效果。多个更新是否一起成功,仍需应用另外设计协议。

5. 直接 ByteBuffer​

直接 ByteBuffer 把数据放在普通 Java 堆之外,供本地 I/O 直接使用。本地 I/O 指 JVM 调用操作系统完成的读写。下面分配一个 64 KiB 的直接缓冲区:

ByteBuffer direct = ByteBuffer.allocateDirect(64 * 1024);

System.out.println(direct.isDirect()); // true

isDirect() 返回 true,说明该缓冲区使用直接内存。HotSpot 用堆缓冲区进行通道 I/O 时,会通过临时直接缓冲区搬运数据。复用直接缓冲区可以省去这一步中间复制。

直接缓冲区仍有一个 Java 对象作为访问入口。HotSpot 在对象不再可达后安排底层清理,也就是程序已无法再通过引用访问它。清理完成前,堆外数据仍占用进程内存和分配额度。

5.1 直接内存的代价​

直接内存减少的是 I/O 中间复制,分配和使用本身仍有成本。选择时需要权衡以下五项代价:

  • 分配与释放涉及本地内存管理,通常比普通堆缓冲区更贵。因此,它更适合分配后长期复用。
  • 数据不计入普通 Java 堆占用,但仍占用进程内存,并受 JVM 直接内存上限约束。
  • 大量短命缓冲区会在清理完成前积压,持续占用直接内存额度。
  • 纯 Java 代码随机调用 get、put 时,没有本地 I/O 中间复制可省,性能需要与堆数组实测比较。
  • 直接内存额度耗尽时,即使 Java 堆还有空间,也会发生 OutOfMemoryError。

网络框架可以用缓冲池复用直接内存,减少为每个小消息分配缓冲区的次数。池本身也要限制容量,并约定使用完成后如何归还。

6. 三种机制的比较​

三种机制优化的环节不同,可以先根据数据是否需要进入应用处理来区分:

机制主要目标数据是否进入 Java 堆适合场景主要风险
transferTo / transferFrom在通道间直接传输可省去应用堆副本文件原样发送平台差异、部分传输
MappedByteBuffer将文件页映射到地址空间不需要普通堆数组副本大文件随机访问、固定布局缺页延迟、映射生命周期
直接 ByteBuffer为本地 I/O 提供直接数据区域数据在堆外高频网络或文件 I/O 缓冲分配成本、堆外 OOM

文件原样发送适合通道传输,按位置访问文件适合评估映射,需要应用处理字节时可评估直接缓冲。这些机制可以组合,也可以作为不同实现方案进行比较。选择时还要结合数据量和生命周期,确认省下的复制成本是否超过新增的管理成本。

7. 性能验证指标​

验证零拷贝时,要同时确认复制是否减少,以及内存和延迟是否改善。除了平均耗时,还应观察以下性能指标:

  • 比较吞吐量与尾延迟,也就是单位时间完成的工作量和最慢一部分请求的耗时。
  • 记录 CPU 使用率和系统调用次数,判断数据搬运与跨内核调用是否减少。
  • 记录上下文切换与缺页次数,判断等待和页面换入是否增加。
  • 分别记录 Java 堆、直接内存和进程 RSS。RSS 表示进程当前驻留在物理内存中的页面总量。
  • 记录 GC 停顿和缓冲区分配速率,判断缓冲复用是否减少了分配压力。
  • 检查文件页缓存是否挤出了其他常用数据,导致系统其他部分变慢。

测试数据量和访问方式要接近真实负载,并区分冷缓存与热缓存。冷缓存需要重新读取文件页,热缓存则复用内存中的数据。还要让 JIT 预热程度一致,否则比较结果会混入缓存与编译状态的差异。

8. 映射大小与释放时机​

处理大文件时,要分别管理单段映射的大小与存活时间。返回 MappedByteBuffer 的 map 重载最多映射 Integer.MAX_VALUE 字节。因此,更大的文件需要分段映射。

关闭 FileChannel 后,已有映射仍然有效。Java 17、21 的 MappedByteBuffer 没有公开的 close(),解除映射依赖 GC。映射对象及其共享视图都不再被引用后,才具备回收条件,所以释放时间无法由通道关闭直接控制。

HotSpot 的 -XX:MaxDirectMemorySize 限制直接缓冲区分配,文件映射和其他本地内存要另外统计。诊断时,应分别查看 direct、mapped 与进程 RSS,区分直接缓冲、文件映射和进程整体占用。

9. 常见问题​

判断优化效果时,容易把省去复制、回收内存和持久化混为一谈。下面分别说明它们的边界。

9.1 零拷贝真的完全没有数据复制吗?​

零拷贝仍需要把数据从存储设备传入内存,再交给网卡或其他设备。它省去的是特定的用户态副本或内核冗余副本,从而减少 CPU 的复制工作。因此,回答时要指出具体省掉了哪一步。

9.2 直接内存受垃圾回收影响吗?​

9.3 内存映射一定比 read 快吗?​

9.4 force() 等于事务提交吗?​

10 · 面试题卡片​

10.1 Java 怎样实现零拷贝​

出现公司:阿里巴巴、字节跳动、Shopee

考察重点

  • 解释 transferTo 与内存映射各自省去了哪一份数据副本。
  • 说明省去 CPU 复制后,设备与内存为何仍需传输数据。
  • 解释平台差异和部分传输怎样影响实现与验证。

相关内容:FileChannel.transferTo、内存映射、三种机制的比较。

参考回答

Java 可以用通道传输或内存映射减少文件 I/O 中的中间复制。

FileChannel.transferTo 和 transferFrom 在通道之间传输文件区域。在支持的平台上,transferTo 可以利用 sendfile 省去 Java 堆副本。map 则将文件页映射到进程地址空间,让应用直接访问这段区域。

这些方式省去的是特定副本,设备与内存之间仍需移动数据。具体系统调用由平台与通道实现选择,所以要在目标环境验证。transferTo 还要处理部分传输,并在零进展时等待可写事件或明确报告失败。

10.2 直接内存有什么收益和风险​

出现公司:字节跳动、小米

考察重点

  • 解释直接缓冲区怎样减少本地 I/O 的中间复制。
  • 说明堆外数据何时能够释放,以及 Java Buffer 对象的引用如何影响它。
  • 比较分配成本、内存上限与缓冲池复用带来的收益。

相关内容:直接 ByteBuffer。

参考回答

直接缓冲区可以减少本地 I/O 的中间复制,代价是分配释放更贵,还要单独管理堆外内存。

它把数据放在普通 Java 堆外,供本地 I/O 直接使用。因此,高频复用的网络和文件缓冲更容易从中获益。

直接内存仍受进程和 JVM 上限约束,耗尽时即使堆有空闲也会发生 OOM。入口对象仍受 Java 可达性管理,短命缓冲区会在清理前积压。缓冲池可以降低分配频率,但也要限制容量并管理归还。

10.3 关闭 FileChannel 会立即释放映射吗,大文件如何映射?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:说明单段映射的大小限制,并区分通道关闭与映射回收的时机。

相关内容:映射大小与释放时机。

参考回答

关闭 FileChannel 后,已有映射仍然有效,大文件则需要按单段大小上限分段映射。

返回 MappedByteBuffer 的 map 重载最多映射 Integer.MAX_VALUE 字节。Java 17、21 中,映射的释放依赖 GC。只有映射对象及其共享视图都不再被引用,才具备回收条件,因此通道关闭时间与映射释放时间需要分别管理。

11. 参考资料​