零拷贝

返回 计算机网络

「零拷贝」要消灭的,是数据传输时没有意义的 CPU 拷贝和多余的用户态↔内核态切换。理解它必须先看清传统 IO 到底拷贝了几次。

传统 IO:4 次拷贝 + 4 次上下文切换

以最经典的 读文件 → 写 Socket(文件下载)为例:

File.read(buf);     // 磁盘 → 用户缓冲区
Socket.write(buf);  // 用户缓冲区 → 网卡

底层发生 4 次数据拷贝 + 4 次上下文切换

            ┌─────────── 用户空间 ───────────┐  ┌──── 内核空间 ────┐
磁盘 ──DMA──────────────────────────────────────► [内核缓冲区]      ① DMA:磁盘 → page cache
            │   [用户缓冲区] ◄──── CPU 拷贝 ─────────┘                ② CPU:内核 → 用户
            │   [用户缓冲区] ──── CPU 拷贝 ────────► [Socket 缓冲区]  ③ CPU:用户 → socket 缓冲区
            │                                │  │  [Socket 缓冲区] ─DMA─► 网卡  ④ DMA:内核 → 网卡
            └────────────────────────────────┘  └──────────────────┘
次数方向谁来拷上下文切换
磁盘 → 内核 page cacheDMA(不占 CPU)read 进内核
内核 → 用户缓冲区CPUread 返回用户态
用户缓冲区 → socket 缓冲区CPUwrite 进内核
socket 缓冲区 → 网卡DMA(不占 CPU)write 返回用户态

数据只是想原样发出去,却在用户空间「转了一圈」。零拷贝要消灭的就是 ②③ 两次无用的 CPU 拷贝

方案 1:mmap + write(减少 1 次 CPU 拷贝)

mmap 把内核 page cache 直接映射到用户空间地址,用户进程能直接访问内核缓冲区,省掉 ②「内核→用户」的拷贝。在 Java 中对应 MappedByteBuffer(见 IO与NIO)。

拷贝:4 → 3(① DMA、③ CPU、④ DMA)    上下文切换:仍 4 次

适合反复随机读写大文件,但 ③「用户→socket」的 CPU 拷贝仍在。

方案 2:sendfile(数据全程不进用户空间)

sendfile 系统调用让数据在内核内部直接从 page cache 拷到 socket 缓冲区,不经过用户空间。在 Java 中对应 FileChannel.transferTo() / transferFrom()

            ┌── 用户空间 ──┐  ┌──────── 内核空间 ────────┐
磁盘 ─DMA──────────────────────► [内核缓冲区] ①
            │  (数据不来了) │  │  [内核缓冲区] ─CPU─► [socket 缓冲区] ②
            │              │  │  [socket 缓冲区] ─DMA─► 网卡 ③
            └──────────────┘  └──────────────────────────┘
拷贝:3(① DMA、② CPU、③ DMA)    上下文切换:4 → 2
src.transferTo(0, src.size(), dst); // 底层走 sendfile,数据不进 JVM 堆

此时用户态代码拿不到数据内容,零拷贝只适合「原样转发」的场景(文件下载、静态资源、Kafka 日志传输),不适合需要在 Java 里加密/压缩/处理数据的场景。

方案 3:sendfile + DMA gather(真·零 CPU 拷贝)

若网卡支持 SG-DMA(Scatter-Gather DMA),内核只把「数据的内存地址+长度」描述符传给 socket 缓冲区,网卡 DMA 引擎据此直接从 page cache 读数据发出,连 ② 的 CPU 拷贝也省掉。

拷贝:2(全是 DMA,0 次 CPU 拷贝)    上下文切换:2 次

这是 Linux 2.4+ 的默认行为,全程没有一次 CPU 参与数据搬运。

对比总结

方案CPU 拷贝DMA 拷贝上下文切换数据进用户空间Java API
传统 read + write224InputStream/OutputStream
mmap + write124✅(映射)MappedByteBuffer
sendfile122transferTo
sendfile + SG-DMA022transferTo(内核+网卡支持时自动)

补充:DirectByteBuffer 的「减少一次拷贝」是另一回事

ByteBuffer.allocateDirect() 说的拷贝是 JVM 堆 ↔ 内核 这一层,和上面的零拷贝不是同一维度:

  • HeapByteBuffer(堆内存):数据在 JVM 堆里,GC 可能移动对象,做 IO 时 JDK 必须先把数据拷到一块堆外临时直接内存再交给内核 → 多一次拷贝。
  • DirectByteBuffer(直接内存):数据直接分配在堆外,地址固定不被 GC 移动,内核可直接访问 → 省掉「堆→堆外」这次拷贝

代价:直接内存分配/回收更慢(靠 Cleaner/虚引用回收,不受 GC 直接管理),适合生命周期长、反复复用的缓冲区,所以 Netty 用池化的 DirectBuffer。

零拷贝 ≠ 非阻塞

两者正交,解决的不是同一个问题:

维度解决什么关键词
非阻塞 / 多路复用一个线程如何高效管理成千上万个连接Selector、epoll、OP_READ
零拷贝单次数据传输如何减少 CPU 拷贝和上下文切换sendfile、transferTo、mmap

高性能网络框架(Netty / Kafka / Nginx)二者兼用:用 epoll 非阻塞地知道「哪个连接该发数据了」,再用 sendfile 零拷贝地把数据发出去

相关链接