文章目录
-
- 一、概述
- 二、预备知识:虚拟内存、内核态/用户态、DMA
- [三、传统 I/O:4 次拷贝、4 次上下文切换](#三、传统 I/O:4 次拷贝、4 次上下文切换)
- 四、操作系统层的零拷贝
-
- [1. mmap + write](#1. mmap + write)
- [2. sendfile](#2. sendfile)
- [3. sendfile + SG-DMA](#3. sendfile + SG-DMA)
- [4. splice](#4. splice)
- [5. 补充:DMA I/O 时序](#5. 补充:DMA I/O 时序)
- [6. 对比表](#6. 对比表)
- [五、Netty 的两层零拷贝](#五、Netty 的两层零拷贝)
-
- [1. OS 级零拷贝:FileRegion](#1. OS 级零拷贝:FileRegion)
- [2. 用户态零拷贝:ByteBuf 的玩法](#2. 用户态零拷贝:ByteBuf 的玩法)
-
- [2.1 CompositeByteBuf:逻辑合并](#2.1 CompositeByteBuf:逻辑合并)
- [2.2 slice / duplicate / retainedSlice:共享底层内存](#2.2 slice / duplicate / retainedSlice:共享底层内存)
- [2.3 Unpooled.wrappedBuffer:包装已有数据](#2.3 Unpooled.wrappedBuffer:包装已有数据)
- [2.4 DirectByteBuf 与池化](#2.4 DirectByteBuf 与池化)
- [2.5 nioBuffers() 与 ChunkedFile](#2.5 nioBuffers() 与 ChunkedFile)
- [3. Netty 零拷贝全景(图 9)](#3. Netty 零拷贝全景(图 9))
- 六、常见误区
我的理解是:零拷贝真正想消灭的,是 CPU 在"内核缓冲区 ↔ 用户缓冲区"之间搬的那一次字节复制。DMA 拷贝该有还是有,上下文切换也只是减少,很难归零。所以本文按"谁在拷、谁在切"来组织,而不是按技术出现的时间顺序简单罗列。
一、概述
如果只记一句话,我希望是这句:零拷贝不是"一次拷贝都没有",而是尽量不让 CPU 参与数据搬移,并减少用户态 / 内核态切换。
这句话拆开看是两层意思:
- DMA 拷贝(磁盘 ↔ 内存、内存 ↔ 网卡)通常消不掉,它由硬件完成,不占 CPU;
- 真正值得砍的,是"内核缓冲区 ↔ 用户缓冲区"之间由 CPU 执行的那次字节复制。

再往下,Netty 的零拷贝必须分两层看。是最常见的失分点:
| 层次 | 代表 API | 底层机制 |
|---|---|---|
| OS 级零拷贝 | FileRegion / DefaultFileRegion |
FileChannel.transferTo(),Linux 下通常走 sendfile |
| 用户态零拷贝 | CompositeByteBuf、slice/duplicate、wrappedBuffer、DirectByteBuf |
共享内存 / 移动引用,避免 JVM 内复制 |
二、预备知识:虚拟内存、内核态/用户态、DMA
先把几个概念捋一遍,后面读起来会顺很多。如果这些已经很熟,可以直接跳到第三章。
虚拟内存
- 每个进程都有自己独立的虚拟地址空间,虚拟地址通过页表映射到物理内存;
- 物理内存不够时,部分页可以换出到磁盘,再按需换入;
- 这样进程之间彼此隔离,也方便内核统一管理内存。
内核态 / 用户态
- 用户程序运行在用户态,不能直接访问内核空间,也不能直接操作硬件;
- 读写文件、创建 Socket 等操作要通过系统调用进入内核态;
- 一次系统调用通常包含"用户态 → 内核态 → 用户态"的切换,也就是常说的上下文切换。零拷贝要优化的,除了 CPU 拷贝,还有这类切换。
DMA(Direct Memory Access)
- 磁盘控制器、网卡等设备可以直接访问主内存,数据搬运不用 CPU 逐字节参与;
- 所以"磁盘 → 内核页缓存"和"Socket 缓冲区 → 网卡"通常由 DMA 完成;
- 但 CPU 仍然要发起系统调用、处理中断、维护描述符,零拷贝消不掉这些,只是消掉 CPU 数据拷贝。
| 角色 | 主要负责 |
|---|---|
| CPU | 执行指令、系统调用、页表/描述符管理、发起 DMA |
| DMA 控制器 | 在设备与主存之间搬运数据,CPU 不逐字节参与 |
| 内核页缓存 | 缓存磁盘文件数据,是 sendfile 的核心 |
| 用户缓冲区 | JVM 堆/堆外内存中的应用程序缓冲区 |
三、传统 I/O:4 次拷贝、4 次上下文切换
这是所有优化的对照基线,值得花几分钟看清楚:
java
byte[] buf = new byte[8192];
int n;
while ((n = in.read(buf)) != -1) {
out.write(buf, 0, n);
}
数据实际上走了这样一条路(图 1):
- DMA 拷贝:磁盘 → 内核页缓存(Page Cache)
- CPU 拷贝:内核页缓存 → 用户缓冲区(JVM 堆)
- CPU 拷贝:用户缓冲区 → Socket 发送缓冲区
- DMA 拷贝:Socket 发送缓冲区 → 网卡
同时,read() 和 write() 各自从用户态进入内核态再返回,共 4 次上下文切换。
| 项目 | 次数 |
|---|---|
| 总拷贝 | 4 次 |
| 其中 DMA 拷贝 | 2 次 |
| 其中 CPU 拷贝 | 2 次(第 2、3 步) |
| 上下文切换 | 4 次 |
第 2、3 步就是零拷贝要消灭的重点:数据在用户态和内核态之间来回搬,CPU 什么正事也没干,全在复制字节。
我一直觉得,把这四步看透之后,后面所有方案不过是"从哪一步下手"的选择题。
注意:DMA 拷贝虽然不占 CPU,但数据仍然要在总线上移动,所以"零拷贝"不等于"没有数据移动"。


图 1:传统 read + write 的 4 次拷贝与 4 次上下文切换
对应的时序图:

图 2:传统 I/O 的时序(用户进程 / CPU / 磁盘)
四、操作系统层的零拷贝
Linux 提供了好几种方案。我按"省掉了哪一步"来排,而不是按出现时间,因为这样更容易记住每种方案的能力边界。
1. mmap + write
mmap() 把内核页缓存映射到用户地址空间,应用程序直接访问这块映射,省掉了"页缓存 → 用户缓冲区"的第 2 次 CPU 拷贝。
它的思路很直接:既然绕不开页缓存,那就让用户空间直接看见它。
数据流(图 3):
- DMA:磁盘 → 内核页缓存
- (映射,无拷贝) 用户直接访问页缓存
- CPU:页缓存 → Socket 发送缓冲区
- DMA:Socket 发送缓冲区 → 网卡
典型值:3 次拷贝(2 DMA + 1 CPU)、4 次上下文切换。

图 3:mmap + write 的数据流(共享区域 + 内存映射)
适合大文件读取,但 mmap 有缺页、映射维护的开销,写 Socket 时仍然有 1 次 CPU 拷贝。
2. sendfile
Linux 2.1 引入 sendfile(),2.4 起支持把文件直接发送到 Socket。整个过程不需要用户态缓冲区。
我认为这是最"干净"的一种方案------数据根本不进用户态,应用只负责发号施令。
数据流(图 4):
- DMA:磁盘 → 内核页缓存
- CPU:页缓存 → Socket 发送缓冲区
- DMA:Socket 发送缓冲区 → 网卡
典型值:3 次拷贝(2 DMA + 1 CPU)、2 次上下文切换(一次系统调用完成)。

图 4:sendfile 的数据流(数据不经过 User Buffer)
sendfile 的限制:
- 不能修改数据,所以加密、压缩、应用层协议处理都不行;
in_fd必须是有文件句柄的文件(不能是普通用户缓冲区);- TLS 场景下需要用户态拿到明文再加密,零拷贝会失效。
3. sendfile + SG-DMA
如果网卡支持 Scatter-Gather DMA,内核可以让网卡直接从页缓存里"收集"数据发出去,省掉第 2 次 CPU 拷贝。
理解这一步的关键在于:网卡自己会"收集",Socket 缓冲区里只需要放文件描述符和长度这些元数据。
数据流(图 5):
- DMA:磁盘 → 内核页缓存
- DMA gather:页缓存 → 网卡(CPU 不参与)
典型值:2 次拷贝(2 次都是 DMA)、2 次上下文切换 。这是理想情况下的大文件传输路径,Nginx 的 sendfile on 常用的就是这套。

图 5:sendfile + SG-DMA(页缓存直接 DMA gather 到网卡)
4. splice
Linux 2.6.17+ 提供 splice(),通过内核管道(pipe)在两个文件描述符之间移动数据,不需要用户态缓冲区。
如果说 sendfile 是为"文件 → Socket"量身定做,splice 更像是为"转发"而生。
典型值同样是 2 次 DMA 拷贝、2 次上下文切换,常用于代理转发(浏览器 ↔ Nginx ↔ 后端服务)。

图 6:splice(内核 pipe 连接,0 次 CPU 拷贝)
5. 补充:DMA I/O 时序
这一节不是零拷贝方案,我把它放在这里,是因为它是最好的对照基线------先看清没有零拷贝时数据怎么走,才能体会后面每种方案省掉了哪一步。

图 7:DMA I/O 时序(用户进程 / CPU / DMA / 磁盘)
6. 对比表
最后用一张表收束。我的建议是不要死背数字,先记住每种方案"少了哪一步",数字自然就推出来了。
| 方式 | 拷贝次数 | CPU 拷贝 | 上下文切换 | 典型场景 |
|---|---|---|---|---|
read + write |
4(2 DMA + 2 CPU) | 2 次 | 4 次 | 传统文件 → Socket |
mmap + write |
3(2 DMA + 1 CPU) | 1 次 | 4 次 | 大文件、读多写少 |
sendfile |
3(2 DMA + 1 CPU) | 1 次 | 2 次 | 文件 → Socket,不经用户态 |
sendfile + SG-DMA |
2(2 DMA) | 0 次 | 2 次 | 大文件静态传输、Nginx |
splice |
2(2 DMA) | 0 次 | 2 次 | 代理转发、内核管道 |
表中的"次数"是常见 Linux 实现的典型值,具体会因内核版本、网卡能力、是否分块而不同。
五、Netty 的两层零拷贝
到了 Netty,零拷贝这个词的用法变得更宽了。这也是我觉得最容易讲混的地方:Netty 说的 zero-copy,一半在操作系统层,一半在 JVM 用户态。
1. OS 级零拷贝:FileRegion
FileRegion 是 Netty 对"文件区域发送"的抽象,DefaultFileRegion 包装了一个 FileChannel。
发送大文件时,业务 Handler 只写出一个 FileRegion:
java
public class FileRegionHandler extends SimpleChannelInboundHandler<String> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, String filePath) throws Exception {
File file = new File(filePath);
RandomAccessFile raf = new RandomAccessFile(file, "r");
FileRegion region = new DefaultFileRegion(raf.getChannel(), 0, raf.length());
ctx.writeAndFlush(region).addListener(future -> {
try {
raf.close(); // 传输完成后关闭文件
} catch (IOException ignored) {
}
if (!future.isSuccess()) {
ctx.close();
}
});
}
}
调用链(图 8):
业务 Handler
→ ctx.writeAndFlush(FileRegion)
→ NioSocketChannel 识别出 FileRegion
→ FileChannel.transferTo(...)
→ 内核 sendfile(Linux 通常如此)
→ 网卡

图 8:Netty FileRegion 调用链(彩色版)
关键点:
- 数据不进入 JVM 堆,只在文件描述符和 Socket 之间走;
- 底层依赖
FileChannel.transferTo(),Linux 上通常落到sendfile; - 不要和
SslHandler、压缩 Handler 一起用 :TLS/压缩需要在用户态修改数据,FileRegion无法零拷贝。需要加密时改用ChunkedWriteHandler + ChunkedFile; - 传输完成后要关闭
RandomAccessFile/FileChannel,不要在writeAndFlush之后立刻用 try-with-resources 关掉; FileRegion由 Netty 在写出完成后释放,业务侧不要重复release()。
我的看法是:FileRegion 适合"I/O 为主、中间不动数据"的场景;一旦要加密、压缩或者拼包,它的优势就没了。
适用场景:大文件、静态资源、内网文件服务。小文件因为系统调用、连接管理占比高,零拷贝的收益不明显。
2. 用户态零拷贝:ByteBuf 的玩法
Netty 文档里说的 "zero-copy" 还包括用户态内存层面的零拷贝:不复制字节,只组合引用、切视图、包装已有内存。
这一层和 sendfile 没有关系,它省的是 JVM 内部的内存复制。很多人一听到 Netty 零拷贝就只想到 FileRegion,其实是漏了一半。

图 9:Netty 用户态零拷贝的四种方式(彩色版)
2.1 CompositeByteBuf:逻辑合并
把多个 ByteBuf 逻辑上拼成一个,底层字节不动(图 9 左上)。最常见的使用场景就是"协议头 + 包体":
java
ByteBuf header = ctx.alloc().buffer();
header.writeCharSequence(
"HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\n",
StandardCharsets.US_ASCII);
ByteBuf body = Unpooled.wrappedBuffer("hello".getBytes(StandardCharsets.UTF_8));
CompositeByteBuf response = ctx.alloc().compositeBuffer(2);
response.addComponents(true, header, body);
ctx.writeAndFlush(response);
addComponents(true, ...)只记录组件引用和偏移,不复制字节;- 读写索引是逻辑上的,Netty 负责按组件顺序读写;
- 注意
maxNumComponents:组件数量超过上限时会触发consolidate(),把多个组件合并成一块内存,这时就会复制。组件多的时候构造参数要预留够。
2.2 slice / duplicate / retainedSlice:共享底层内存
slice() 和 duplicate() 都只创建视图,共享同一块底层内存(图 9 右上)。
解析协议时,这个特性特别有用------你不必为每一段都复制一份:
java
ByteBuf frame = Unpooled.wrappedBuffer(bytes);
ByteBuf magic = frame.slice(0, 4); // 子区域视图
ByteBuf body = frame.slice(4, frame.readableBytes() - 4); // 另一段视图
// magic / body 与 frame 共享底层内存,没有字节复制
- 视图有自己独立的
readerIndex/writerIndex,但底层数组是同一份; duplicate()共享整个可读区域,slice(index, length)共享指定区间;retainedSlice()会retain()底层 buffer,适合视图的生命周期比原 buffer 更长、或要跨线程传递的场景;- 引用计数要配对 :视图的
release()最终作用于同一块底层内存的引用计数;通常由持有原 buffer 的代码统一释放,不要重复release()。
2.3 Unpooled.wrappedBuffer:包装已有数据
java
byte[] bytes = ...;
ByteBuf buf = Unpooled.wrappedBuffer(bytes); // 不复制数组
如果手里已经有一块数据,包装一下就好,没必要再抄一遍。
- 包装
byte[]或ByteBuffer,直接复用原内存; - 之后对 ByteBuf 的修改会反映到原数组,原数组的修改也会被 ByteBuf 看到;
- 适合把已有的序列化结果、缓存数据直接交给 Netty,避免
new byte[] + copy。
2.4 DirectByteBuf 与池化
Netty 默认使用 PooledByteBufAllocator,并倾向于分配堆外直接内存(direct buffer)。
但我想说清楚:堆外内存不是银弹。它分配贵、回收要小心,所以 Netty 一定要配池化。
- JVM 堆内的
HeapByteBuf写 SocketChannel 时,JDK 往往需要临时的 direct buffer,多一次 heap → native 的拷贝; DirectByteBuf直接在堆外分配,写 Socket 时不用再转一次,减少这次拷贝和 GC 压力;- 堆外内存分配成本比堆内高,所以 Netty 用池化复用;同时要警惕内存泄漏,可开启
-Dio.netty.leakDetection.level=paranoid排查。
这部分优化的是"用户态 → 内核"这一段,数据最终仍会进入内核 Socket 缓冲区,并没有绕过内核。
2.5 nioBuffers() 与 ChunkedFile
CompositeByteBuf.nioBuffers()返回多个组件的ByteBuffer视图,配合 gathering write 可以避免合并;而nioBuffer()在组件不连续时可能需要合并(复制);- 需要 TLS 或边读边发大文件时,用
ChunkedWriteHandler + ChunkedFile,把文件分块写入 pipeline;它避免了"整文件加载进内存",但不是 sendfile 意义上的零拷贝。
3. Netty 零拷贝全景(图 9)
把两层放在一起看:
OS 级:FileRegion → FileChannel.transferTo → sendfile → 网卡
用户态:CompositeByteBuf / slice / duplicate / wrappedBuffer / DirectByteBuf
一句话:FileRegion 省的是内核态 ↔ 用户态之间的拷贝;ByteBuf 这些省的是 JVM 用户态内部的拷贝。两者不是一回事。
六、常见误区
下面这几条,是我觉得最容易踩的坑:
- "零拷贝 = 一次都不拷贝":不对。DMA 拷贝仍然存在,零拷贝主要减少 CPU 拷贝和上下文切换。
- 把 Netty 的用户态零拷贝当成 sendfile :
CompositeByteBuf、slice是用户态共享内存;FileRegion才是 OS 级零拷贝。 - FileRegion 搭配 SSL / 压缩 :TLS 需要在用户态加密,压缩需要改数据,
FileRegion无法胜任;要用ChunkedFile或普通 ByteBuf。 - 零拷贝一定更快:不一定。小文件、协议解析、加密压缩、频繁建连的场景,收益可能被其他开销吃掉。
- DirectByteBuf 一定更快:分配/回收成本高,必须配合池化;不正确释放还会内存泄漏。
- 共享视图可以随便 release :
slice/duplicate/CompositeByteBuf共享引用计数,重复 release 会导致非法访问。 - 零拷贝 = 零缓冲:数据仍会经过内核 Socket 缓冲区;零拷贝不等于没有缓冲区。
- CompositeByteBuf 一定不复制 :组件数超过
maxNumComponents会 consolidate,反而产生复制。
写到最后,想留一句话:零拷贝 解决的是"CPU 在搬字节"这一种瓶颈;如果你的瓶颈在协议解析、在加锁、在数据库,那换什么 I/O 模型都没用。先找瓶颈,再谈优化。
文章结束,喜欢就给个一键三连吧,你的肯定是我最大的动力,点赞上一千我就是脑瘫也出下章