NIO 的三大组件与零拷贝是怎么回事?
结论:NIO 的三大组件是通道(Channel)、缓冲区(Buffer)、多路复用器(Selector):数据总是先读进 Buffer、再从中取;Channel 是双向的数据通道;Selector 用一个线程监听多个 Channel 的就绪事件。零拷贝(zero-copy)不是"完全不拷贝",而是减少用户态与内核态之间的数据复制和上下文切换:FileChannel.transferTo 走 sendfile,数据根本不经过用户缓冲区;MappedByteBuffer 走 mmap,用户态直接映射页缓存。
1. Buffer 的三个指针
Buffer 状态 = [ capacity ][ position ][ limit ][ mark ]
写模式:position 从 0 增长到 limit(= capacity)
flip():limit = position;position = 0 ← 写 → 读
读模式:position 从 0 增长到 limit
clear():position = 0;limit = capacity ← 重置标记,数据并未擦除
compact():把未读数据搬到头部,position = 剩余长度 ← 读一半要转回写
这三个方法最容易混:clear() 不是清空数据,只是重置指针;flip() 之后才能读;读了一半又想写,用 compact() 而不是 clear()。
2. 一次读取经过几次拷贝
传统 read + write(4 次拷贝、4 次上下文切换):
磁盘 ──DMA──→ 内核页缓存 ──CPU──→ 用户缓冲区 ──CPU──→ socket 缓冲区 ──DMA──→ 网卡
sendfile / transferTo(2 次拷贝、2 次切换,数据不进用户态):
磁盘 ──DMA──→ 内核页缓存 ──────────────→ socket 缓冲区 ──DMA──→ 网卡
mmap + write(3 次拷贝,用户态与内核共享页缓存):
磁盘 ──DMA──→ 页缓存 ←──映射── 用户态 ──CPU──→ socket 缓冲区 ──DMA──→ 网卡
| 方式 | 系统调用 | 数据拷贝次数 | 上下文切换 | 典型用途 |
|---|---|---|---|---|
| 传统 | read + write | 4 | 4 | 需要在用户态处理数据 |
sendfile | transferTo | 2(DMA) | 2 | 文件下发、静态资源、Kafka |
mmap | map + write | 3 | 2~4 | 大文件随机读、RocketMQ |
splice | splice | 2 | 2 | 管道转发 |
3. 代码:transferTo 与 mmap
import java.io.RandomAccessFile;
import java.nio.MappedByteBuffer;
import java.nio.channels.FileChannel;
import java.nio.file.*;
public class ZeroCopyDemo {
/** sendfile:文件到文件/套接字,数据不进用户态 */
static void sendFile(Path from, Path to) throws Exception {
try (FileChannel in = FileChannel.open(from, StandardOpenOption.READ);
FileChannel out = FileChannel.open(to,
StandardOpenOption.CREATE, StandardOpenOption.WRITE)) {
long size = in.size();
long pos = 0;
while (pos < size) {
// 单次调用有上限(通常 2GB 内),必须循环
pos += in.transferTo(pos, size - pos, out);
}
}
}
/** mmap:把文件映射进虚拟内存,读大文件避免整段加载 */
static byte readAt(Path path, long offset) throws Exception {
try (RandomAccessFile raf = new RandomAccessFile(path.toFile(), "r");
FileChannel ch = raf.getChannel()) {
MappedByteBuffer map = ch.map(FileChannel.MapMode.READ_ONLY, offset, 1);
return map.get(0);
}
}
}
transferTo 的返回值是实际传输的字节数,可能小于请求值(内核缓冲上限、信号中断),所以必须 while 循环推进 position,否则会静默丢数据。
4. 零拷贝的边界
| 限制 | 说明 |
|---|---|
| 不能修改数据 | 数据绕过用户态,应用无法加解密或改包;要改就得回到普通读写 |
| 依赖操作系统 | sendfile 在 Linux 上成熟,JDK 会回退到普通复制 |
| 单次 2GB 上限 | transferTo 内部按 Integer.MAX_VALUE 分段,需要循环 |
| mmap 释放不即时 | 映射区由 Cleaner/Unmapper 释放,长期持有会占用虚拟内存与页缓存 |
| TLS 场景退化 | 传统 sendfile 无法在加密前处理数据;kTLS 等新机制才支持 |
5. 常见追问
| 追问 | 回答要点 |
|---|---|
| Heap Buffer 与 Direct Buffer 区别? | 堆内数组 vs 堆外内存;Direct 少一次拷贝,但分配/释放贵,受 -XX:MaxDirectMemorySize 限制 |
flip() 到底改了什么? | limit = position,position = 0,切换读写视角 |
为什么 Selector 要 configureBlocking(false)? | 阻塞通道无法注册到 Selector(会抛 IllegalBlockingModeException) |
selectedKeys 为什么必须 remove()? | 集合不会自动清空,不删会导致同一事件被重复处理 |
| 零拷贝对 Kafka/RocketMQ 的意义? | Kafka 用 sendfile 下发日志段,RocketMQ 用 mmap 读写 CommitLog,减少 CPU 拷贝与 GC 压力 |
mmap 会导致 Full GC 吗? | 映射区不在堆内,但大量映射会占用页缓存与虚拟地址空间,可能触发系统级内存压力 |
小结
三大组件回答"NIO 怎么组织数据与事件",零拷贝回答"NIO 怎么少搬数据"。记住拷贝次数对照表(4 → 2 → 3)和两条铁律:transferTo 要循环、flip() 之后才能读。