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+ 小伙伴加入学习,欢迎点击围观
面试考察点
-
概念区分能力:面试官想看你是否能把 “操作系统级别的零拷贝” 和 “Netty 应用层的零拷贝” 分开讲。很多人一上来就背
sendfile、mmap,结果答非所问——那是 Linux 内核的能力,Netty 只是用了它。 -
底层原理理解:是否清楚传统 I/O 为什么慢(4 次上下文切换 + 4 次数据拷贝),
mmap和sendfile又是怎么减少开销的。 -
源码与 API 掌握度:是否熟悉
CompositeByteBuf、slice()、duplicate()、FileRegion这些核心组件,能不能说出它们各自实现零拷贝的机制。 -
实践意识:在业务代码里有没有用过这些能力,比如粘包拆包、文件下发、协议拼装这些场景。
核心答案
Netty 的“零拷贝”分成两个维度,很多人答这道题栽就栽在只讲一个:
| 层面 | 实现方式 | 是否 Netty 原创 |
|---|---|---|
| OS 层零拷贝 | FileRegion(封装 FileChannel.transferTo(),底层是 sendfile)、DirectByteBuffer(堆外内存) |
否,借助 Linux 内核 |
| 应用层零拷贝 | CompositeByteBuf 组合、slice() 切片、duplicate() 浅拷贝、Unpooled.wrappedBuffer() 包装 |
是,Netty 的核心亮点 |
结论先放在这儿:Netty 零拷贝 ≠ sendfile。sendfile 只是其中一个手段,真正的精髓在于 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 次上下文切换:
上图是传统 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 拷贝
mmap(Memory Mapped File)的精髓在于:内核 PageCache 和用户态 Buffer 映射同一段物理内存,所以省掉了第二次 CPU 拷贝。整体是 3 次拷贝 + 4 次上下文切换。
但还有一次 CPU 拷贝存在,从用户态拷到 Socket Buffer。
sendfile:彻底消掉 CPU 拷贝
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,对外提供连续的读写接口。没有发生任何字节级别的拷贝,只是多维护了几个指针。
这张图就是 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 有独立的 readerIndex 和 writerIndex,但底层数组还是原来那块,完全没拷贝。
对应的还有 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:
堆内 ByteBuf 做 socket I/O 时,JVM 会先把它拷贝到一个临时的 DirectBuffer,再交给网卡,多一次拷贝。堆外 ByteBuf(Direct)省掉了这次中间拷贝,所以 Netty 默认就是 Direct,这也是它高吞吐的关键之一。
注意坑:堆外内存不受 GC 管理,泄漏后排查难度高,要记得
release()或者走ByteBufAllocator池化。
三、把几个组件串起来看
面试时如果能把这张图讲清楚,加分:
一句话总结这张图:OS 层是省内核态/用户态的拷贝,应用层是省 JVM 堆内的拷贝,两个维度合起来才是完整的 Netty 零拷贝。
顺便说一句池化:Netty 4.x 之后默认走 PooledByteBufAllocator,借鉴 jemalloc 的设计,一次申请一大块(默认一个 chunk 16M),用完归还到池里复用,不是丢给 GC。这虽然严格来说不算 “零拷贝”,但配合堆外内存大幅降低了对象分配和 GC 压力,也是 Netty 高吞吐的另一个关键。
面试高频追问
-
追问一:
slice()出来的ByteBuf调用release()会影响原 buffer 吗?会的。
slice()、duplicate()、Component这些共享底层 buffer 的视图,内部都用了引用计数(retain()/release())。视图release()时只是把引用计数减一,减到 0 才真正释放底层 buffer。所以切出来的 ByteBuf 要单独release(),但要理解这背后是一套引用计数机制,不是 “谁切谁负责”。 -
追问二:
CompositeByteBuf性能一定比合并数组好吗?看场景。组件数量多时,每次读都要算跨
Component的索引,会有额外开销。所以 Netty 内部对组件数做了优化,组件数量特别大时反而不如合并成一个大数组。常规协议拼装场景(2~4 个组件)零拷贝完胜。 -
追问三:堆外内存为什么一定要
release()?堆外内存不走 GC,分配靠
Unsafe.allocateMemory(),释放靠手动free()。Netty 通过ReferenceCounted接口管理,忘release()就是内存泄漏。开发时可以开-Dio.netty.leakDetection.level=ADVANCED抓泄漏。补充一点:默认级别其实是SIMPLE,只抽样 1% 的 ByteBuf,开销几乎为零;线上排查时才升到ADVANCED(同样是 1% 抽样,但会记录访问堆栈),更狠的还有PARANOID(100% 检测,性能开销大,只在测试环境用)。 -
追问四:
FileRegion传输时如果对端很慢,会阻塞事件循环吗?默认是阻塞式
FileChannel,所以官方建议用DefaultFileRegion时配合EventLoop的水位线(ChannelOption.WRITE_BUFFER_HIGH_WATER_MARK)控制。或者直接用ChunkedNioFile做分块传输,更适合慢消费场景。顺便说一句,开启 SSL/TLS 的 Channel 也只能走ChunkedNioFile,因为加密必须把数据拉到用户态,DefaultFileRegion在这种场景下失效。
常见面试变体
- “Netty 高性能体现在哪些方面?”(零拷贝只是其中一个点,还有 Reactor 线程模型、无锁串行化、ByteBuf 池化等)
- “
ByteBuf相比 NIO 的ByteBuffer有什么改进?”(这个会顺势引出零拷贝、池化、动态扩容、读写指针分离等) - “Linux 的
sendfile和mmap区别是什么?”(纯 OS 层,但 Netty 借助的就是这俩) - “为什么 Netty 默认用堆外内存?”(避开堆内→堆外的那次拷贝)
记忆口诀
两个层面 + 五个组件:
- 两个层面:OS 层(
sendfile+ 堆外内存)借力内核,应用层(Composite/slice/duplicate/wrap)Netty 原创 - 五个组件:FileRegion、CompositeByteBuf、slice、duplicate、wrappedBuffer
口诀:“系统层靠 sendfile,应用层靠逻辑合”。
总结
答这道题别一上来就背 sendfile。先把 “两个层面” 摆出来:OS 层是 FileRegion 借助 Linux 内核的 sendfile,应用层才是 Netty 真正的杀手锏,靠 CompositeByteBuf、slice()、duplicate()、wrappedBuffer() 这些 “逻辑合并、物理不拷贝” 的设计省掉 JVM 堆内的内存搬运。再加上默认堆外内存避开堆内→堆外拷贝,配套池化减少对象创建,这才叫 Netty 的零拷贝。
