Netty 的 ByteBuf 为什么好用?
一则或许对你有用的小广告
欢迎加入小哈的星球,你将获得:专属的实战项目(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,更想看看你能不能说清楚
ByteBuf比 NIO 的ByteBuffer强在哪。 -
实践意识:能不能讲清楚 ByteBuf 的池化机制、读写指针分离、引用计数这些核心设计背后的考量。
-
原理深度:ByteBuf 是 Netty 高性能的关键之一,面试官想看你是否理解零拷贝、内存池这些工程化设计。
核心答案
直接给结论:Netty 自己搞了一套 ByteBuf,不用 NIO 的 java.nio.ByteBuffer,主要是为了解决 NIO ByteBuffer 在网络编程场景下的几个核心痛点:
| 维度 | NIO ByteBuffer |
Netty ByteBuf |
|---|---|---|
| 读写指针 | 单 position 指针,需 flip() 切换 |
readerIndex + writerIndex 双指针 |
| 容量 | 固定,不能扩容 | 动态扩容 |
| 内存管理 | 手动 GC,频繁分配有压力 | 池化(基于 jemalloc 思想)+ 引用计数 |
| 零拷贝 | 不支持 | CompositeByteBuf、slice() 等 |
| 实现类型 | 单一 | 堆内、堆外、组合、只读等多种 |
| 扩展性 | final 类,无法扩展 | 接口设计,可自定义 |
下面展开讲讲几个关键设计。
深度解析
一、读写指针分离——告别 flip()
先说最直观的痛点。NIO 的 ByteBuffer 只有一个 position 指针,读和写都靠它,所以读写切换必须手动调 flip():
// NIO ByteBuffer 写完要读,必须 flip
ByteBuffer buffer = ByteBuffer.allocate(1024);
buffer.put("hello".getBytes()); // 写
buffer.flip(); // 切换为读模式
byte[] data = new byte[buffer.remaining()];
buffer.get(data); // 读
这块当年坑了不少人——忘了 flip() 直接读出来全是空数据,debug 半天才发现。
Netty 的 ByteBuf 直接上两个指针:
ByteBuf 的内部内存布局被两个指针自然分成三段:
- 已读区域(
0 ~ readerIndex):已经被读过的字节,调用discardReadBytes()可以回收这块空间 - 可读区域(
readerIndex ~ writerIndex):还没读的数据,readableBytes()返回这段大小 - 可写区域(
writerIndex ~ capacity):还能写的空间,writableBytes()返回这段大小
读和写各走各的,互不干扰,再也不会忘了 flip()。
ByteBuf buf = Unpooled.buffer(1024);
buf.writeBytes("hello".getBytes()); // 写
byte[] data = new byte[buf.readableBytes()];
buf.readBytes(data); // 读,无需 flip
这个设计在网络协议解析场景下特别好用,比如处理半包/粘包时,经常是边读边写、边写边读,单指针根本玩不转。
二、池化设计——Netty 4 的性能利器
Netty 4 默认使用 PooledByteBufAllocator,所有 ByteBuf 都从内存池里分配。
为什么要池化?
网络框架里 ByteBuf 的创建和销毁频率极高(每来一个连接、每收一个包都要 new 一个),频繁的 GC 会导致应用卡顿。Netty 借鉴了 jemalloc 算法的思想,把内存划分成不同规格的 PoolChunk、PoolSubpage,分配和回收都走池子:
Netty 内存池按申请大小走不同的分配路径:
- Tiny(小于 512B):走
PoolSubpage,按更小粒度切片分配(16 字节对齐),避免浪费 - Small(512B ~ 8KB):走
PoolSubpage,按 page(默认 8KB)的等分切片分配 - Normal(8KB ~ 16MB):走
PoolChunk(默认 16MB),按 Page 分配 - Huge(大于 16MB):太大不走池,直接分配
池化之后,分配和释放的开销被摊薄,GC 压力也大幅降低,这是 Netty 高性能的核心来源之一。
三、堆内 vs 堆外——按需选择
ByteBuf 提供两种存储:
| 类型 | 类 | 特点 | 适用场景 |
|---|---|---|---|
| 堆内 | UnpooledHeapByteBuf / PooledHeapByteBuf |
在 JVM 堆上,受 GC 管理 | 业务逻辑处理、便于调试 |
| 堆外 | UnpooledDirectByteBuf / PooledDirectByteBuf |
直接内存,不走 JVM 堆 | 网络传输、文件 IO |
为什么网络传输要用堆外内存? 堆内内存做 IO 时,JVM 得先把它 copy 到直接内存里才能交给操作系统,多了一次 copy。堆外内存直接给操作系统,省了这次 copy,这就是所谓的 "零拷贝" 的一部分。
Netty 默认走堆外(PooledByteBufAllocator.DEFAULT 默认分配 directBuffer)。
四、零拷贝的多种实现
Netty 的零拷贝(zero-copy)在 ByteBuf 层面体现在这几个地方:
CompositeByteBuf:把多个ByteBuf逻辑上合并成一个,不真实 copy 数据。比如收到两个半包,合成一个完整的消息:
CompositeByteBuf composite = Unpooled.compositeBuffer();
composite.addComponents(true, header, body);
// 后续可以当单个 ByteBuf 用
-
slice():切片,共享底层数组,不 copy 数据 -
duplicate():返回一个共享内容的副本(指针独立) -
copy():这个才是真实 copy(深拷贝) -
FileRegion:文件传输用
sendfile,绕过用户空间
注意区分:slice()、duplicate() 这种是共享内容(修改会影响原 buffer),用的时候要注意;copy() 才是真正独立的副本。
五、引用计数——主动管理内存
ByteBuf 实现了 ReferenceCounted 接口,靠引用计数来管理生命周期:
ByteBuf buf = ...; // 初始 refCnt = 1
buf.retain(); // refCnt = 2,常用于传递给其他处理器
// ...
buf.release(); // refCnt = 1
buf.release(); // refCnt = 0,内存归还到池
好处:可以精确知道什么时候该释放内存,不用等 GC。
配套机制:ResourceLeakDetector 检测泄漏,生产环境一般配 ADVANCED 级别,发现 buf 没 release 就会告警。
六、丰富的 API
列举一些常用的便捷方法(NIO ByteBuffer 没有的):
readInt()/writeInt():基本类型读写,自动处理字节序getBytes()/setBytes():随机位置读写markReaderIndex()/resetReaderIndex():标记 + 回滚readableBytes()/writableBytes():直接查可读可写字节数clear():重置两个指针到 0(不清数据)discardReadBytes():丢弃已读区域,回收空间
面试高频追问
-
追问一:ByteBuf 是线程安全的吗?
ByteBuf本身不是线程安全的,单线程内用就行。如果多线程访问,需要外部同步,或者用Unpooled.wrappedBuffer(...)之类的不可变形式。但
ByteBufAllocator是线程安全的,每个线程有自己的缓存(PoolThreadCache)。 -
追问二:池化和非池化的 ByteBuf 怎么切换?
通过
ByteBufAllocator选择:PooledByteBufAllocator.DEFAULT(池化,默认)vsUnpooledByteBufAllocator.DEFAULT(非池化)。也可以通过系统参数
-Dio.netty.allocator.type=unpooled切换。 -
追问三:什么场景下用堆内,什么场景用堆外?
- IO 密集(网络传输、文件):堆外,省 copy
- 业务处理:堆内,方便 GC 和调试
注意:堆外内存不受 JVM GC 管理,必须手动
release(),否则会内存泄漏。 -
追问四:ByteBuf 内存泄漏怎么排查?
- 开启
ResourceLeakDetector,日志级别设到ADVANCED或PARANOID - 应用启动时加
-Dio.netty.leakDetection.level=ADVANCED - 日志会打印泄漏的分配栈
- 开启
常见面试变体
- "Netty 为什么不直接用 NIO 的 ByteBuffer?"
- "Netty 的零拷贝体现在哪里?"
- "ByteBuf 的引用计数怎么用的?"
- "Netty 是怎么实现内存池的?"
记忆口诀
核心三件套:双指针(读写分离)+ 内存池(jemalloc)+ 引用计数(手动释放)
零拷贝四板斧:CompositeByteBuf(合并)+ slice()(切片)+ duplicate()(共享副本)+ FileRegion(sendfile)
总结
Netty 的 ByteBuf 好用,本质上是从工程实战出发重新设计了一遍:双指针把 flip() 的坑堵上了,池化加上引用计数把高频分配带来的 GC 压力压了下去,再加上一堆零拷贝实现优化 IO 性能。NIO 的 ByteBuffer 只是个 "能用" 的基础工具,Netty 的 ByteBuf 才是真正为高性能网络框架量身打造的。
