零拷贝
→ 返回 计算机网络
「零拷贝」要消灭的,是数据传输时没有意义的 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 cache | DMA(不占 CPU) | read 进内核 |
| ② | 内核 → 用户缓冲区 | CPU | read 返回用户态 |
| ③ | 用户缓冲区 → socket 缓冲区 | CPU | write 进内核 |
| ④ | 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 + write | 2 | 2 | 4 | ✅ | InputStream/OutputStream |
| mmap + write | 1 | 2 | 4 | ✅(映射) | MappedByteBuffer |
| sendfile | 1 | 2 | 2 | ❌ | transferTo |
| sendfile + SG-DMA | 0 | 2 | 2 | ❌ | transferTo(内核+网卡支持时自动) |
补充: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 零拷贝地把数据发出去。