零拷贝原理(mmap / sendfile)

做过文件传输服务的同学,可能都遇到过这个问题:服务器带宽跑满了,CPU 也不高,但传输速度上不去。

排查了一圈,发现瓶颈在数据拷贝次数上。

传统 IO 模式下,一个文件从磁盘到网卡要经历 4 次拷贝、2 次上下文切换。如果改成零拷贝,可能只需要 2 次拷贝、甚至 1 次拷贝。

今天我们就来彻底搞懂零拷贝。

一、传统 IO 的数据拷贝流程

1.1 一次完整的数据传输

当程序从文件读取数据并发送到网络时,数据经历了多少次拷贝?

                    用户态          内核态
                       │              │
  磁盘 ──DMA──→ 内核缓冲区 ──CPU──→ 用户缓冲区 ──CPU──→ Socket缓冲区 ──DMA──→ 网卡
      1次          2次           3次           4次

详细步骤:

步骤操作拷贝次数
1磁盘 → 内核缓冲区(DMA 拷贝)1 次
2内核缓冲区 → 用户缓冲区(CPU 拷贝)1 次
3用户缓冲区 → Socket 缓冲区(CPU 拷贝)1 次
4Socket 缓冲区 → 网卡(DMA 拷贝)1 次
总计4 次拷贝

1.2 上下文切换

除了拷贝,还有上下文切换:

切换说明
用户态 → 内核态read() 调用
内核态 → 用户态read() 返回
用户态 → 内核态write() 调用
内核态 → 用户态write() 返回
总计4 次上下文切换

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次
步骤操作拷贝次数
1磁盘 → 内核缓冲区(DMA 拷贝)1 次
2内核缓冲区 → 用户缓冲区0 次(映射共享)
3用户缓冲区 → Socket 缓冲区1 次
4Socket 缓冲区 → 网卡1 次
总计3 次拷贝

2.3 mmap 的适用场景

// 适合 mmap 的场景:
// 1. 需要频繁读写的大文件
// 2. 需要随机访问文件内容(如数据库)
// 3. 进程间共享数据

MappedByteBuffer buffer = channel.map(
    FileChannel.MapMode.READ_ONLY, 0, fileSize
);

:::warning ⚠️ mmap 的问题:

  1. 如果映射的文件很大,消耗的虚拟内存可能很大
  2. 如果 mmap 的文件被其他进程截断,会收到 SIGBUS 信号
  3. 不适合频繁创建销毁的场景(映射开销较大) :::

三、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次
步骤操作拷贝次数
1磁盘 → 内核缓冲区(DMA 拷贝)1 次
2内核缓冲区 → Socket 缓冲区1 次(内核内部)
3Socket 缓冲区 → 网卡(DMA 拷贝)1 次
总计3 次拷贝

等等,为什么不是 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 的文件传输流程:

  1. Producer 发送消息到 Broker,写入磁盘(或 Page Cache)
  2. Consumer 消费时,Broker 使用 sendfile 直接把数据从 Page Cache 发送到网卡
  3. 不需要先把数据拷贝到用户空间再发送

这就是为什么 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() 同步到磁盘。

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 的 CompositeByteBufslice() 等也是零拷贝思想。

第四层:应用场景

面试官追问:"什么场景需要零拷贝?"

文件服务器、日志收集(Kafka)、图片/CDN 服务、大文件传输等高吞吐量场景。零拷贝可以显著提升 IO 效率,减少 CPU 参与。

【学习小结】

  • 传统 IO:4 次拷贝、4 次上下文切换
  • mmap:3 次拷贝(减少 1 次 CPU 拷贝)
  • sendfile:2-3 次拷贝(CPU 不参与)
  • Java:FileChannel.transferTo()FileChannel.map()
  • Kafka、Netty 是零拷贝的典型应用