Netty 的零拷贝是怎么实现的?


一则或许对你有用的小广告

欢迎加入小哈的星球,你将获得:专属的实战项目(4个项目都能学) / 1v1 提问 / 简历修改 / Java 学习路线 / 社群讨论 / 学习打卡 / 每月赠书

  • 《Spring AI 项目实战(问答机器人、RAG 智能客服、联网搜索)》已完结,基于 Spring AI + Spring Boot 3.x + JDK 21...查看介绍

  • 《从零手撸:仿小红书(微服务架构)》 已完结,基于 Spring Cloud Alibaba + Spring Boot 3.x + JDK 17...查看介绍;演示链接:http://116.62.199.48:7070/

  • 《从零手撸:前后端分离博客项目(全栈开发)》 2 期已完结,演示链接:http://116.62.199.48/

  • 新开坑项目:《从零手撸:秒杀系统高并发优化实战》 正在更新中...,查看介绍

截止目前,星球内专栏累计输出 150w+ 字,讲解图 5110+ 张,还在持续爆肝中.. 后续还会上新更多项目,已有 4700+ 小伙伴加入学习,欢迎点击围观

面试考察点

  1. 概念区分能力:面试官想看你是否能把 “操作系统级别的零拷贝” 和 “Netty 应用层的零拷贝” 分开讲。很多人一上来就背 sendfilemmap,结果答非所问——那是 Linux 内核的能力,Netty 只是用了它。

  2. 底层原理理解:是否清楚传统 I/O 为什么慢(4 次上下文切换 + 4 次数据拷贝),mmapsendfile 又是怎么减少开销的。

  3. 源码与 API 掌握度:是否熟悉 CompositeByteBufslice()duplicate()FileRegion 这些核心组件,能不能说出它们各自实现零拷贝的机制。

  4. 实践意识:在业务代码里有没有用过这些能力,比如粘包拆包、文件下发、协议拼装这些场景。

核心答案

Netty 的“零拷贝”分成两个维度,很多人答这道题栽就栽在只讲一个:

层面 实现方式 是否 Netty 原创
OS 层零拷贝 FileRegion(封装 FileChannel.transferTo(),底层是 sendfile)、DirectByteBuffer(堆外内存) 否,借助 Linux 内核
应用层零拷贝 CompositeByteBuf 组合、slice() 切片、duplicate() 浅拷贝、Unpooled.wrappedBuffer() 包装 是,Netty 的核心亮点

结论先放在这儿:Netty 零拷贝 ≠ sendfilesendfile 只是其中一个手段,真正的精髓在于 Netty 在用户态用一堆 “逻辑合并、物理不拷贝” 的设计,省掉了大量内存拷贝。

深度解析

一、先把 OS 层的零拷贝搞明白

要理解 OS 层的零拷贝,得先看传统 I/O 到底慢在哪。

假设你要把磁盘上的文件发到网络,传统做法是这样:

FileInputStream fis = new FileInputStream(file);
FileOutputStream fos = new FileOutputStream(socket);
byte[] buf = new byte[1024];
while (fis.read(buf) > 0) {
    fos.write(buf);
}

这段代码看着没毛病,但底层发生了 4 次数据拷贝 + 4 次上下文切换

Netty 零拷贝之传统 IO 四次拷贝
Netty 零拷贝之传统 IO 四次拷贝

上图是传统 I/O 的完整数据流:

  • 第一次拷贝:DMA 把数据从磁盘读到内核的 PageCache
  • 第二次拷贝:CPU 把数据从 PageCache 拷贝到用户态 Buffer(read() 返回)
  • 第三次拷贝:CPU 把数据从用户态 Buffer 拷贝到内核的 Socket Buffer(write() 调用)
  • 第四次拷贝:DMA 把数据从 Socket Buffer 拷到网卡

中间还要穿插 read()write() 两次系统调用,每次都涉及用户态/内核态切换,加起来就是 4 次上下文切换

这一整套流程里,数据在用户态根本没被用过,纯粹是 “走个过场”,但却白白搭进去 2 次 CPU 拷贝和 4 次上下文切换,性能开销可想而知。

mmap + write:省一次 CPU 拷贝

Netty 零拷贝之 mmap 内存映射
Netty 零拷贝之 mmap 内存映射

mmap(Memory Mapped File)的精髓在于:内核 PageCache 和用户态 Buffer 映射同一段物理内存,所以省掉了第二次 CPU 拷贝。整体是 3 次拷贝 + 4 次上下文切换。

但还有一次 CPU 拷贝存在,从用户态拷到 Socket Buffer。

sendfile:彻底消掉 CPU 拷贝

Netty 零拷贝之 sendfile 系统调用
Netty 零拷贝之 sendfile 系统调用

sendfile 一次系统调用就搞定,用户态全程不参与:

  • Linux 2.1+:3 次拷贝(DMA → CPU → DMA),2 次上下文切换
  • Linux 2.4+ 配合 SG-DMA(Scatter-Gather DMA):内核 PageCache 的数据根本不拷贝,DMA 直接根据描述符把 PageCache 的数据搬到网卡,全程 2 次 DMA 拷贝,0 次 CPU 拷贝

这才是真正意义上的 “零拷贝”,CPU 完全不参与数据搬运,只负责发号施令。

Netty 里对应的就是 FileRegion

// Netty 的文件传输零拷贝
FileChannel fileChannel = new FileInputStream(file).getChannel();
DefaultFileRegion fileRegion = new DefaultFileRegion(fileChannel, 0, fileChannel.size());
channel.writeAndFlush(fileRegion);
// 底层最终调用 fileChannel.transferTo()

注意:FileRegion 底层依赖操作系统的 sendfile 系统调用,所以 Netty 在 OS 层零拷贝上其实是 “借力”,而非自己实现。这点一定要说清楚,不然面试官会觉得你没理解透。另外有个坑:一旦 Channel 上开启了 SSL/TLS,DefaultFileRegion用不了了,因为加密必须把数据拉到用户态处理,这种场景只能退回 ChunkedNioFile 分块传输。

二、应用层零拷贝——这才是 Netty 的真功夫

OS 层的零拷贝 JVM 也能用(NIO 的 FileChannel.transferTo() 就行),那 Netty 为什么还被人称道?因为它在用户态自己造了一套零拷贝,省掉的是 JVM 堆内的内存拷贝。这块的杀手锏是 ByteBuf 这套 API。

1. CompositeByteBuf:逻辑合并,物理不动

最经典的场景:自定义协议有 header 和 body 两部分,传统做法是把两个数组合并成一个,再发出去:

// 传统做法:合并两个 byte 数组,必然有一次内存拷贝
byte[] header = ...;
byte[] body = ...;
byte[] message = new byte[header.length + body.length];
System.arraycopy(header, 0, message, 0, header.length);
System.arraycopy(body, 0, message, header.length, body.length);

但 Netty 可以这么干:

ByteBuf header = ...;
ByteBuf body = ...;

// 组合成一个逻辑上的 ByteBuf,物理上还是两块
CompositeByteBuf message = ByteBufAllocator.DEFAULT.compositeBuffer();
message.addComponents(true, header, body);

// 使用方完全感知不到这是两块,读、写、发都当成一个整体
channel.writeAndFlush(message);

CompositeByteBuf 内部维护了一个 Component 数组,每个 Component 指向一个底层的 ByteBuf,对外提供连续的读写接口。没有发生任何字节级别的拷贝,只是多维护了几个指针。

Netty 零拷贝之 CompositeByteBuf 逻辑合并
Netty 零拷贝之 CompositeByteBuf 逻辑合并

这张图就是 CompositeByteBuf 在玩的事:对外是一段连续的 1040 字节,对内是两个独立的 ByteBuf,所有读写操作都通过 Component 表做索引映射,零拷贝。

2. slice():切片共享底层

解析协议时经常需要拆出某一段单独处理,比如 TLV 格式里取 value 字段:

ByteBuf buf = ...; // 完整数据
ByteBuf type = buf.slice(0, 1);    // 取 type
ByteBuf length = buf.slice(1, 4);  // 取 length
ByteBuf value = buf.slice(5, 100); // 取 value

// 注意:slice 出来的 ByteBuf 和原始 buf 共享同一块内存
// 改 type 的字节,原始 buf 也会变!

slice() 返回的新 ByteBuf 有独立的 readerIndexwriterIndex,但底层数组还是原来那块,完全没拷贝

对应的还有 duplicate(),比 slice() 更激进,它共享整个底层 buffer(不只是某一段),但有自己的读写指针。Netty 还提供了 retainedSlice()retainedDuplicate(),切出来就自带 +1 的引用计数,省得手动 retain(),写业务代码时更顺手。

3. Unpooled.wrappedBuffer():包装数组

有时候业务侧拿到的是 byte[](比如从别的库传过来的),要丢给 Netty 处理。传统做法是把数组拷贝进 ByteBuf,Netty 直接包:

byte[] data = someExternalApi.getBytes();
// 包装,不拷贝。注意:之后不能再修改 data,否则 ByteBuf 内容会跟着变
ByteBuf buf = Unpooled.wrappedBuffer(data);

4. DirectByteBuffer:堆外内存

严格说这是 NIO 的能力,但 Netty 默认用的就是堆外 ByteBuf

Netty 堆外内存避免额外拷贝
Netty 堆外内存避免额外拷贝

堆内 ByteBuf 做 socket I/O 时,JVM 会先把它拷贝到一个临时的 DirectBuffer,再交给网卡,多一次拷贝。堆外 ByteBuf(Direct)省掉了这次中间拷贝,所以 Netty 默认就是 Direct,这也是它高吞吐的关键之一

注意坑:堆外内存不受 GC 管理,泄漏后排查难度高,要记得 release() 或者走 ByteBufAllocator 池化。

三、把几个组件串起来看

面试时如果能把这张图讲清楚,加分:

Netty 零拷贝 OS 层与应用层总结
Netty 零拷贝 OS 层与应用层总结

一句话总结这张图:OS 层是省内核态/用户态的拷贝,应用层是省 JVM 堆内的拷贝,两个维度合起来才是完整的 Netty 零拷贝。

顺便说一句池化:Netty 4.x 之后默认走 PooledByteBufAllocator,借鉴 jemalloc 的设计,一次申请一大块(默认一个 chunk 16M),用完归还到池里复用,不是丢给 GC。这虽然严格来说不算 “零拷贝”,但配合堆外内存大幅降低了对象分配和 GC 压力,也是 Netty 高吞吐的另一个关键。

面试高频追问

  1. 追问一:slice() 出来的 ByteBuf 调用 release() 会影响原 buffer 吗?

    会的。slice()duplicate()Component 这些共享底层 buffer 的视图,内部都用了引用计数retain()/release())。视图 release() 时只是把引用计数减一,减到 0 才真正释放底层 buffer。所以切出来的 ByteBuf 要单独 release(),但要理解这背后是一套引用计数机制,不是 “谁切谁负责”。

  2. 追问二:CompositeByteBuf 性能一定比合并数组好吗?

    看场景。组件数量多时,每次读都要算跨 Component 的索引,会有额外开销。所以 Netty 内部对组件数做了优化,组件数量特别大时反而不如合并成一个大数组。常规协议拼装场景(2~4 个组件)零拷贝完胜。

  3. 追问三:堆外内存为什么一定要 release()

    堆外内存不走 GC,分配靠 Unsafe.allocateMemory(),释放靠手动 free()。Netty 通过 ReferenceCounted 接口管理,忘 release() 就是内存泄漏。开发时可以开 -Dio.netty.leakDetection.level=ADVANCED 抓泄漏。补充一点:默认级别其实是 SIMPLE,只抽样 1% 的 ByteBuf,开销几乎为零;线上排查时才升到 ADVANCED(同样是 1% 抽样,但会记录访问堆栈),更狠的还有 PARANOID(100% 检测,性能开销大,只在测试环境用)。

  4. 追问四:FileRegion 传输时如果对端很慢,会阻塞事件循环吗?

    默认是阻塞式 FileChannel,所以官方建议用 DefaultFileRegion 时配合 EventLoop 的水位线(ChannelOption.WRITE_BUFFER_HIGH_WATER_MARK)控制。或者直接用 ChunkedNioFile 做分块传输,更适合慢消费场景。顺便说一句,开启 SSL/TLS 的 Channel 也只能走 ChunkedNioFile,因为加密必须把数据拉到用户态,DefaultFileRegion 在这种场景下失效。

常见面试变体

  • “Netty 高性能体现在哪些方面?”(零拷贝只是其中一个点,还有 Reactor 线程模型、无锁串行化、ByteBuf 池化等)
  • ByteBuf 相比 NIO 的 ByteBuffer 有什么改进?”(这个会顺势引出零拷贝、池化、动态扩容、读写指针分离等)
  • “Linux 的 sendfilemmap 区别是什么?”(纯 OS 层,但 Netty 借助的就是这俩)
  • “为什么 Netty 默认用堆外内存?”(避开堆内→堆外的那次拷贝)

记忆口诀

两个层面 + 五个组件

  • 两个层面:OS 层sendfile + 堆外内存)借力内核,应用层Composite/slice/duplicate/wrap)Netty 原创
  • 五个组件:FileRegion、CompositeByteBuf、slice、duplicate、wrappedBuffer

口诀:“系统层靠 sendfile,应用层靠逻辑合”

总结

答这道题别一上来就背 sendfile。先把 “两个层面” 摆出来:OS 层是 FileRegion 借助 Linux 内核的 sendfile,应用层才是 Netty 真正的杀手锏,靠 CompositeByteBufslice()duplicate()wrappedBuffer() 这些 “逻辑合并、物理不拷贝” 的设计省掉 JVM 堆内的内存搬运。再加上默认堆外内存避开堆内→堆外拷贝,配套池化减少对象创建,这才叫 Netty 的零拷贝。