零拷贝原理(mmap / sendfile)
做过文件传输服务的同学,可能都遇到过这个问题:服务器带宽跑满了,CPU 也不高,但传输速度上不去。
排查了一圈,发现瓶颈在数据拷贝次数上。
传统 IO 模式下,一个文件从磁盘到网卡要经历 4 次拷贝、2 次上下文切换。如果改成零拷贝,可能只需要 2 次拷贝、甚至 1 次拷贝。
今天我们就来彻底搞懂零拷贝。
一、传统 IO 的数据拷贝流程
1.1 一次完整的数据传输
当程序从文件读取数据并发送到网络时,数据经历了多少次拷贝?
用户态 内核态
│ │
磁盘 ──DMA──→ 内核缓冲区 ──CPU──→ 用户缓冲区 ──CPU──→ Socket缓冲区 ──DMA──→ 网卡
1次 2次 3次 4次
详细步骤:
1.2 上下文切换
除了拷贝,还有上下文切换:
1.3 ❌ 错误理解:零拷贝就是没有拷贝
很多人误解"零拷贝"是"一次都不拷贝"。实际上,零拷贝只是消除了不必要的内存拷贝,数据在不同介质之间传输还是需要物理移动的。
零拷贝的目标是:减少用户态和内核态之间的数据拷贝次数。
二、mmap:内存映射
2.1 mmap 的原理
mmap(Memory-Mapped File)把文件直接映射到用户进程的地址空间:
// Java 中使用 FileChannel 的 mmap
RandomAccessFile file = new RandomAccessFile("data.txt", "rw");
FileChannel channel = file.getChannel();
// 把文件映射到内存
MappedByteBuffer buffer = channel.map(
FileChannel.MapMode.READ_WRITE, // 读写模式
0, // 起始位置
channel.size() // 映射长度
);
// 像操作数组一样读写文件
buffer.put(0, (byte) 'H');
byte b = buffer.get(0);
映射后的数据:
用户空间 内核空间
┌─────────┐ ┌─────────┐
│ buffer │◄──映射──► │ page │◄──磁盘──► 磁盘文件
└─────────┘ └─────────┘
用户态 内核态
2.2 mmap 减少了 1 次拷贝
使用 mmap 后,数据传输变成了:
磁盘 ──DMA──→ 内核缓冲区(映射到用户空间)───→ Socket缓冲区 ──DMA──→ 网卡
1次 0次(共享) 1次
2.3 mmap 的适用场景
// 适合 mmap 的场景:
// 1. 需要频繁读写的大文件
// 2. 需要随机访问文件内容(如数据库)
// 3. 进程间共享数据
MappedByteBuffer buffer = channel.map(
FileChannel.MapMode.READ_ONLY, 0, fileSize
);
:::warning ⚠️
mmap 的问题:
- 如果映射的文件很大,消耗的虚拟内存可能很大
- 如果 mmap 的文件被其他进程截断,会收到 SIGBUS 信号
- 不适合频繁创建销毁的场景(映射开销较大)
:::
三、sendfile:真正的零拷贝
3.1 sendfile 的原理
sendfile 是 Linux 2.2 引入的系统调用,直接在内核空间完成数据传输:
// C 语言调用
#include <sys/sendfile.h>
ssize_t sendfile(out_fd, in_fd, offset, count);
在 Java 中,FileChannel.transferTo() 内部使用了 sendfile:
FileChannel inChannel = new RandomAccessFile("source.txt", "rw").getChannel();
FileChannel outChannel = new RandomAccessFile("dest.txt", "rw").getChannel();
// 使用 sendfile(底层调用)
long transferred = inChannel.transferTo(0, inChannel.size(), outChannel);
3.2 sendfile 减少到 2 次拷贝
磁盘 ──DMA──→ 内核缓冲区 ────────────────────→ Socket缓冲区 ──DMA──→ 网卡
1次 1次 1次
等等,为什么不是 2 次?
因为 sendfile 在 Linux 2.4 之前确实还是 3 次拷贝。Linux 2.4+ 才有真正的 2 次拷贝。
3.3 Linux 2.4+ 的 sendfile with gather
Linux 2.4 优化了 sendfile,可以直接传递"文件描述符"而不是数据:
// 底层原理:
// sendfile 调用时,把内核缓冲区描述符传递给 Socket
// 网卡驱动直接 DMA 从内核缓冲区读取数据
// 只需要 2 次 DMA 拷贝
磁盘 ──DMA──→ 内核缓冲区(只传描述符)───DMA──→ 网卡
1次 0次数据拷贝 1次
这就是"零拷贝"的真正含义:CPU 不参与数据拷贝,只有 DMA 在搬移数据。
四、【直观类比】
【直观类比】
想象你要把一份文件从 A 办公室送到 B 办公室:
传统 IO(4 次拷贝):
文件从 A 抽屉 → A 桌面(助手搬)
A 桌面 → 你的书包(你搬)
你的书包 → B 桌面(你搬)
B 桌面 → B 抽屉(助手搬)
经历了 4 次人工搬运
mmap(3 次拷贝):
文件从 A 抽屉 → A 桌面(助手搬)
A 桌面直接映射到你的桌子上(你不用搬,但共享同一份)
你的桌子 → B 桌面(你搬)
B 桌面 → B 抽屉(助手搬)
经历了 3 次人工搬运
sendfile(2 次拷贝):
文件从 A 抽屉 → A 桌面(助手搬)
A 桌面 → B 抽屉(助手直接送过去)
你根本不参与!
经历了 2 次人工搬运
五、Java 中的零拷贝实践
5.1 FileChannel.transferTo
public class ZeroCopyDemo {
public static void main(String[] args) throws IOException {
// 文件到文件的零拷贝
try (FileChannel in = new FileInputStream("bigfile.zip").getChannel();
FileChannel out = new RandomAccessFile("copy.zip", "rw").getChannel()) {
long size = in.size();
long position = 0;
while (position < size) {
// transferTo 使用 sendfile
long transferred = in.transferTo(position, size - position, out);
position += transferred;
}
}
}
}
5.2 文件到网络的零拷贝
public class FileToNetZeroCopy {
public static void main(String[] args) throws IOException {
// 用 NIO 实现文件到网络的零拷贝传输
FileChannel fileChannel = new FileInputStream("largefile.bin").getChannel();
// 配合 NIO SocketChannel
SocketChannel socketChannel = SocketChannel.open();
socketChannel.connect(new InetSocketAddress("localhost", 8080));
long transferred = 0;
long fileSize = fileChannel.size();
while (transferred < fileSize) {
long n = fileChannel.transferTo(transferred, fileSize - transferred, socketChannel);
if (n > 0) {
transferred += n;
}
}
}
}
5.3 Netty 的零拷贝
Netty 在零拷贝方面做了很多优化:
// 1. CompositeByteBuf:组合多个 Buffer,不需要拷贝
ByteBuf header = Unpooled.wrappedBuffer(headerBytes);
ByteBuf body = Unpooled.wrappedBuffer(bodyBytes);
ByteBuf message = Unpooled.wrappedBuffer(header, body);
// 2. slice():分割 Buffer,不拷贝数据
ByteBuf buffer = Unpooled.buffer(1024);
ByteBuf slice = buffer.slice(0, 512); // 共享底层数据
// 3. FileRegion:文件传输使用 sendfile
FileRegion region = new DefaultFileRegion(fileChannel, 0, fileChannel.size());
ctx.write(region);
// 4. DirectByteBuf:直接内存,减少一次堆外拷贝
ByteBuf directBuf = Unpooled.directBuffer(1024);
六、Kafka 的零拷贝应用
Kafka 是零拷贝的典型应用场景:
Producer ──→ Kafka Broker ──→ Consumer
│
Page Cache(内存)
│
sendfile(零拷贝)──→ 网络
Kafka 的文件传输流程:
- Producer 发送消息到 Broker,写入磁盘(或 Page Cache)
- Consumer 消费时,Broker 使用 sendfile 直接把数据从 Page Cache 发送到网卡
- 不需要先把数据拷贝到用户空间再发送
这就是为什么 Kafka 能达到那么高的吞吐量。
七、生产避坑
7.1 ❌ 错误示范:在小文件上用 mmap
// 小文件不适合 mmap
MappedByteBuffer buffer = channel.map(
MapMode.READ_ONLY, 0, 100 // 只有 100 字节
);
// mmap 的开销(页对齐、虚拟内存管理等)可能比节省的拷贝还大
正确做法:小文件直接用普通 IO,大文件才用 mmap。
7.2 ❌ 错误示范:忽略了 Page Cache
// 零拷贝依赖 Page Cache
// 如果文件不在 Page Cache 中,第一次还是要从磁盘读取
// 之后的访问才能利用 Page Cache
正确做法:利用顺序读预热 Page Cache,或者使用 readahead。
7.3 ❌ 错误示范:mmap 后不同步就关闭
MappedByteBuffer buffer = channel.map(...);
// 写入数据
buffer.put(0, (byte) 'X');
// ❌ 没有 force 就关闭了
channel.close();
正确做法:如果写了数据,需要调用 buffer.force() 同步到磁盘。
八、面试追问链
第一层:基础概念
面试官问:"什么是零拷贝?"
零拷贝是一种减少数据在用户态和内核态之间拷贝次数的技术。传统 IO 需要 4 次拷贝,零拷贝可以减少到 2-3 次,甚至 1 次。
第二层:mmap vs sendfile
面试官追问:"mmap 和 sendfile 有什么区别?"
mmap 把文件映射到用户空间,用户可以直接读写文件内容,减少了 1 次 CPU 拷贝,但仍然是 3 次。sendfile 是系统调用,数据传输完全在内核空间完成,最少只需要 2 次拷贝,CPU 完全不参与。
第三层:Java 实现
面试官追问:"Java 怎么实现零拷贝?"
FileChannel.transferTo() 底层使用 sendfile。FileChannel.map() 使用 mmap。Netty 的 CompositeByteBuf、slice() 等也是零拷贝思想。
第四层:应用场景
面试官追问:"什么场景需要零拷贝?"
文件服务器、日志收集(Kafka)、图片/CDN 服务、大文件传输等高吞吐量场景。零拷贝可以显著提升 IO 效率,减少 CPU 参与。
【学习小结】
- 传统 IO:4 次拷贝、4 次上下文切换
- mmap:3 次拷贝(减少 1 次 CPU 拷贝)
- sendfile:2-3 次拷贝(CPU 不参与)
- Java:
FileChannel.transferTo() 和 FileChannel.map()
- Kafka、Netty 是零拷贝的典型应用