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+ 小伙伴加入学习,欢迎点击围观

面试考察点

  1. 基础掌握度:面试官不只是想确认你有没有用过 Netty,更想看看你能不能说清楚 ByteBuf 比 NIO 的 ByteBuffer 强在哪。

  2. 实践意识:能不能讲清楚 ByteBuf 的池化机制、读写指针分离、引用计数这些核心设计背后的考量。

  3. 原理深度:ByteBuf 是 Netty 高性能的关键之一,面试官想看你是否理解零拷贝、内存池这些工程化设计。

核心答案

直接给结论:Netty 自己搞了一套 ByteBuf用 NIO 的 java.nio.ByteBuffer,主要是为了解决 NIO ByteBuffer 在网络编程场景下的几个核心痛点:

维度 NIO ByteBuffer Netty ByteBuf
读写指针 position 指针,需 flip() 切换 readerIndex + writerIndex 双指针
容量 固定,不能扩容 动态扩容
内存管理 手动 GC,频繁分配有压力 池化(基于 jemalloc 思想)+ 引用计数
零拷贝 不支持 CompositeByteBufslice()
实现类型 单一 堆内、堆外、组合、只读等多种
扩展性 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 直接上两个指针:

Netty ByteBuf 双指针内存布局示意图
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 算法的思想,把内存划分成不同规格的 PoolChunkPoolSubpage,分配和回收都走池子:

Netty 内存池 ByteBuf 分配流程图
Netty 内存池 ByteBuf 分配流程图

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 层面体现在这几个地方:

  1. CompositeByteBuf:把多个 ByteBuf 逻辑上合并成一个,不真实 copy 数据。比如收到两个半包,合成一个完整的消息:
CompositeByteBuf composite = Unpooled.compositeBuffer();
composite.addComponents(true, header, body);
// 后续可以当单个 ByteBuf 用
  1. slice():切片,共享底层数组,不 copy 数据

  2. duplicate():返回一个共享内容的副本(指针独立)

  3. copy():这个才是真实 copy(深拷贝)

  4. 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():丢弃已读区域,回收空间

面试高频追问

  1. 追问一:ByteBuf 是线程安全的吗?

    ByteBuf 本身是线程安全的,单线程内用就行。如果多线程访问,需要外部同步,或者用 Unpooled.wrappedBuffer(...) 之类的不可变形式。

    ByteBufAllocator 是线程安全的,每个线程有自己的缓存(PoolThreadCache)。

  2. 追问二:池化和非池化的 ByteBuf 怎么切换?

    通过 ByteBufAllocator 选择:PooledByteBufAllocator.DEFAULT(池化,默认)vs UnpooledByteBufAllocator.DEFAULT(非池化)。

    也可以通过系统参数 -Dio.netty.allocator.type=unpooled 切换。

  3. 追问三:什么场景下用堆内,什么场景用堆外?

    • IO 密集(网络传输、文件):堆外,省 copy
    • 业务处理:堆内,方便 GC 和调试

    注意:堆外内存受 JVM GC 管理,必须手动 release(),否则会内存泄漏。

  4. 追问四:ByteBuf 内存泄漏怎么排查?

    • 开启 ResourceLeakDetector,日志级别设到 ADVANCEDPARANOID
    • 应用启动时加 -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 才是真正为高性能网络框架量身打造的。