Netty 零拷贝原理与实战

文章目录

    • 一、概述
    • 二、预备知识:虚拟内存、内核态/用户态、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):

  1. DMA 拷贝:磁盘 → 内核页缓存(Page Cache)
  2. CPU 拷贝:内核页缓存 → 用户缓冲区(JVM 堆)
  3. CPU 拷贝:用户缓冲区 → Socket 发送缓冲区
  4. 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):

  1. DMA:磁盘 → 内核页缓存
  2. (映射,无拷贝) 用户直接访问页缓存
  3. CPU:页缓存 → Socket 发送缓冲区
  4. 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):

  1. DMA:磁盘 → 内核页缓存
  2. CPU:页缓存 → Socket 发送缓冲区
  3. 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):

  1. DMA:磁盘 → 内核页缓存
  2. 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 用户态内部的拷贝。两者不是一回事。


六、常见误区

下面这几条,是我觉得最容易踩的坑:

  1. "零拷贝 = 一次都不拷贝":不对。DMA 拷贝仍然存在,零拷贝主要减少 CPU 拷贝和上下文切换。
  2. 把 Netty 的用户态零拷贝当成 sendfile :CompositeByteBuf、slice 是用户态共享内存;FileRegion 才是 OS 级零拷贝。
  3. FileRegion 搭配 SSL / 压缩 :TLS 需要在用户态加密,压缩需要改数据,FileRegion 无法胜任;要用 ChunkedFile 或普通 ByteBuf。
  4. 零拷贝一定更快:不一定。小文件、协议解析、加密压缩、频繁建连的场景,收益可能被其他开销吃掉。
  5. DirectByteBuf 一定更快:分配/回收成本高,必须配合池化;不正确释放还会内存泄漏。
  6. 共享视图可以随便 release :slice / duplicate / CompositeByteBuf 共享引用计数,重复 release 会导致非法访问。
  7. 零拷贝 = 零缓冲:数据仍会经过内核 Socket 缓冲区;零拷贝不等于没有缓冲区。
  8. CompositeByteBuf 一定不复制 :组件数超过 maxNumComponents 会 consolidate,反而产生复制。

写到最后,想留一句话:零拷贝 解决的是"CPU 在搬字节"这一种瓶颈;如果你的瓶颈在协议解析、在加锁、在数据库,那换什么 I/O 模型都没用。先找瓶颈,再谈优化。


文章结束,喜欢就给个一键三连吧,你的肯定是我最大的动力,点赞上一千我就是脑瘫也出下章

相关推荐
国科安芯1 小时前
商业光学遥感载荷焦平面供电链的低噪声稳压器研究综述——以国科安芯ASP7A84AS型3A低压差线性稳压器为器件级参照
大数据·网络·嵌入式硬件·平面·卫星·商业航天·抗辐射
ai_coder_ai1 小时前
AIOps 智能运维架构设计及其应用
运维
静以修身zzz1 小时前
计算云服务
运维·网络·云计算
星恒随风1 小时前
Linux网络编程:应用层协议究竟是什么?从网络计算器到TCP Socket抽象基类完整实现
linux·网络·笔记·tcp/ip
万联WANFLOW2 小时前
异地组网速度忽快忽慢到底怎么回事?直连与中继的判断方法和处理步骤
运维·网络·网络协议·p2p
jimmyleeee2 小时前
Agent安全之六:(ASI06)记忆与上下文投毒——当 AI 把“昨天的错误”当成“今天的真理”
网络·人工智能·安全
SDWAN_Cheap2 小时前
物理服务器跟云服务器有什么区别,使用于什么场景
运维·服务器
Smoothcloud润云2 小时前
GPU算力平台推荐:复现实验时,如何判断租用资源是否合适?
服务器·人工智能·深度学习·gpu算力·gpu
辰辰观影2 小时前
字幕导入教程:外挂字幕一键加载方法
网络·经验分享