乐于分享
好东西不私藏

ByteBuf 源码:池化内存、读写索引与引用计数(源码系列第8篇)

ByteBuf 源码:池化内存、读写索引与引用计数(源码系列第8篇)

ByteBuf 是 Netty 性能设计的核心:读写索引减少模式切换,池化分配器减少内存申请,直接内存贴近网络 I/O,引用计数负责生命周期。但这四个能力叠加后,ByteBuf 不再是普通 byte 数组;它被当成有所有权、有生命周期、可共享视图的资源对象来管理。

很多 Netty 内存问题不是“不会用 ByteBuf”,而是“把 ByteBuf 当成普通对象”。普通对象交给 GC,方法返回后忘记也没关系;ByteBuf 尤其是池化直接内存,必须在引用计数归零后归还池。忘记 release 会泄漏,提前 release 会非法访问,跨线程异步使用不 retain 会偶发崩。

Netty 设计 ByteBuf 是为了替代 JDK ByteBuffer 的几个痛点:

  • • position/limit 模式切换复杂。
  • • 堆内存和直接内存使用不统一。
  • • 切片和组合不够贴合网络协议。
  • • 高频分配释放成本高。
  • • 网络框架需要显式管理非堆内存。

源码基线:

  • • Netty netty-4.1.135.Final
  • • Commit:f05f765d81460799c53123a207f665bf3b465171
  • • 文件:ByteBuf.javaAbstractByteBuf.javaPooledByteBufAllocator.java
  • • 链接:https://github.com/netty/netty/tree/netty-4.1.135.Final/buffer/src/main/java/io/netty/buffer

这张架构图可以从线程缓存开始读:小块内存优先命中 PoolThreadCache,未命中才进入 Arena、Chunk、Page/Subpage 的层级分配。它解释了为什么 Netty 释放 ByteBuf 后进程内存不一定立刻下降——很多内存被留在池里等待复用。判断泄漏时不能只看 RSS,要结合活动分配、写缓冲积压和引用计数。

1. ByteBuf 用两个索引消除 flip 思维

JDK ByteBuffer 读写共用 position,需要写完 flip() 再读,读完 compact() 或 clear()。对网络协议来说,这非常容易错。

ByteBuf 采用:

0 <= readerIndex <= writerIndex <= capacity

区域含义:

0 -------- readerIndex -------- writerIndex -------- capacity| 已读可丢弃 |      可读字节      |      可写空间      |

写入推进 writerIndex,读取推进 readerIndex。因此可以一边接收字节,一边解析帧,不需要整体切换模式。

典型读取:

if (buf.readableBytes() < 4) {    return;}int len = buf.getInt(buf.readerIndex());if (buf.readableBytes() < 4 + len) {    return;}buf.skipBytes(4);ByteBuf frame = buf.readRetainedSlice(len);

注意 getInt 不推进 readerIndex,适合窥探长度;readInt 会推进索引。Decoder 中先用 get 方法判断完整性,再用 read 方法消费,是避免半包误推进的关键。

2. 容量扩展不是无限制复制

AbstractByteBuf.ensureWritable() 会在写入前确保有足够空间。如果容量不够,计算新容量并调用具体实现扩容。不同 ByteBuf 类型扩容成本不同:

  • • Unpooled heap:可能新建 byte[] 并复制。
  • • Unpooled direct:可能重新申请直接内存并复制。
  • • Pooled ByteBuf:可能从池中重新分配或调整。
  • • CompositeByteBuf:可能通过组件组合减少复制。

协议设计应尽量避免频繁小步扩容。能预估长度时预分配;大对象传输尽量分块或使用 FileRegion;Decoder 中不要把无上限数据一直累积在一个 Buffer。

3. Direct Memory 的收益和风险

网络 I/O 最终需要和内核交互。直接内存可以减少某些堆内到直接内存的中间复制,尤其适合 SocketChannel 读写。Netty 服务端通常默认偏向使用 direct buffer。

风险在于直接内存不由普通 Java 堆 GC 直接约束。对象引用被 GC 后 Cleaner 可能释放直接内存,但池化和高性能框架不能依赖不确定的 GC 时机。因此 Netty 用引用计数显式管理。

如果线上看到堆内存正常、进程 RSS 或 direct memory 持续增长,排查方向应包括:

  • • ByteBuf 泄漏。
  • • Pooled allocator 缓存增长。
  • • 大连接写缓冲积压。
  • • CompositeByteBuf 组件未释放。
  • • 异常分支吞掉消息。

不要只通过调大 MaxDirectMemorySize 掩盖问题。

4. 池化分配器按 Arena 与线程缓存降低竞争

PooledByteBufAllocator 不是一个全局大锁池。它把内存分配拆成多个层次:

PooledByteBufAllocator  -> HeapArena / DirectArena    -> PoolChunkList      -> PoolChunk        -> Page / Subpage  -> PoolThreadCache

线程优先从自己的 PoolThreadCache 获取小块内存,未命中再进入 Arena。Arena 负责从 Chunk 中切分 Page 或 Subpage。这样既减少系统调用,也降低多线程争用。

为什么要区分 tiny/small/normal/huge 等尺寸类别?因为网络消息大小分布通常不均匀。大量小 Buffer 适合缓存和 Subpage,大块 Buffer 需要从 Chunk 切分,超大对象可能不适合进入普通池结构。

池化不是免费午餐。它会让已释放内存留在池里复用,监控上看起来进程内存不一定马上下降。因此判断泄漏不能只看“释放后 RSS 是否立即降低”,要结合 Netty allocator 指标、active allocations 和 leak detector。

5. 引用计数是所有权协议

ByteBuf 实现 ReferenceCounted

int refCnt();ByteBuf retain();boolean release();

基本规则:

allocate -> refCnt = 1retain   -> refCnt + 1release  -> refCnt - 1refCnt=0 -> 内存释放或归还池

谁拿到所有权,谁负责释放。Pipeline 中常见所有权转移:

  • • 入站 read 成功后,Unsafe 把 ByteBuf 交给 Pipeline。
  • • Handler 继续传播,就不释放。
  • • Handler 消费终止,就释放。
  • • Decoder 产出新消息,要处理旧 Buffer 与新消息的引用关系。
  • • 出站 write 后,Netty 在发送完成或失败时释放。

引用计数最容易在异步场景出错:

public void channelRead(ChannelHandlerContext ctx, Object msg) {    ByteBuf buf = (ByteBuf) msg;    executor.submit(() -> parse(buf)); // 错:可能已被释放    ctx.fireChannelRead(msg);}

如果异步任务需要使用:

ByteBuf retained = buf.retainedDuplicate();executor.submit(() -> {    try {        parse(retained);    } finally {        retained.release();    }});ctx.fireChannelRead(buf);

6. slice、duplicate 与 copy 的差别决定安全性

常见方法:

方法
是否复制内存
是否共享内容
引用计数关系
slice()
通常共享
duplicate()
通常共享
retainedSlice()
retain 后返回
retainedDuplicate()
retain 后返回
copy()
新 Buffer

slice() 很快,因为不复制,只创建视图。但它共享底层内存和生命周期。若原 Buffer release 到 0,slice 也不可访问。需要把切片交给下游或异步保存时,优先用 readRetainedSlice() 或 retainedSlice()

协议解码中,readSlice(len) 和 readRetainedSlice(len) 差别非常重要。前者适合在当前调用栈内立即消费,后者适合把帧作为独立消息传给后续 Handler。

7. CompositeByteBuf 支持逻辑连续,避免物理复制

TCP 半包和粘包处理中,多个 ByteBuf 可以组合成一个逻辑连续 Buffer。CompositeByteBuf 维护组件列表,让读取跨组件进行,而不一定把所有字节复制到新数组。

这对大消息和聚合协议有价值,但组件数量过多也会增加索引查找和释放复杂度。Netty 的 ByteToMessageDecoder 提供 MERGE_CUMULATOR 和 COMPOSITE_CUMULATOR,前者合并复制,后者组合减少复制。选择哪一个要看消息大小、碎片程度和 CPU/内存权衡。

小消息高频场景,合并复制可能更简单快速;大消息或零拷贝敏感场景,组合更有价值。

8. ResourceLeakDetector 是兜底,不是常态依赖

Netty 的 ResourceLeakDetector 会采样跟踪 ByteBuf 的访问路径。当对象被 GC 发现未 release 时,打印泄漏报告。级别包括 disabled、simple、advanced、paranoid 等。

高级别能提供更多访问记录,但开销也更大。生产环境常用 simple 或按需临时提高。压测和开发环境可以打开 advanced 定位具体分支。

泄漏报告要重点看最近的 acquire/touch 记录,而不是只看最终 GC。可以在关键转交点调用:

buf.touch("after decode requestId=...");

为泄漏报告添加业务线索。

9. ByteBuf 与 Pipeline 的所有权边界

结合前几篇,入站所有权变化是:

Allocator 分配  -> Channel read 填充  -> pipeline.fireChannelRead  -> Decoder 消费/产出  -> Business 消费或继续传播  -> 最终 release

出站所有权变化是:

Business 创建响应  -> Encoder 创建 ByteBuf  -> unsafe.write 入队  -> doWrite 写出  -> ChannelOutboundBuffer.remove  -> release + promise success

所以业务不应在 writeAndFlush(buf) 之后立即 release;也不应把入站 ByteBuf 保存到成员变量后忘记 retain;更不应在多个 Channel 之间复用同一个 ByteBuf 实例。

这张生命周期图是写 Netty Handler 时最需要反复对照的检查表。retain 表示多一个所有者,release 表示当前所有者交还资源,refCnt=0 后再访问就是非法。slice/duplicate 只是视图共享,不会天然复制内存;如果要把切片传给下游或异步线程,必须使用 retainedSliceretainedDuplicate 或显式 retain。

10. 常见内存故障与判断

第一,直接内存泄漏。表现为运行时间越长 direct memory 越高,Full GC 无明显改善,ResourceLeakDetector 可能报警。重点检查异常分支和自定义 Decoder。

第二,提前释放。表现为 IllegalReferenceCountException: refCnt: 0。重点检查 SimpleChannelInboundHandler 自动释放后仍异步使用,或者 write 后手动 release。

第三,池缓存导致“看似不释放”。释放逻辑正确,但 allocator 缓存保留内存以备复用。结合活动分配数和吞吐变化判断,不要误判为泄漏。

第四,写缓冲积压。ByteBuf 没泄漏,只是大量消息还在 ChannelOutboundBuffer 等待慢客户端。此时 refCnt 正常,pending outbound bytes 高,writability 变 false。

第五,Composite 组件泄漏。聚合时只释放外层或只释放内层都可能出错,应遵循 CompositeByteBuf 对组件所有权的 API 约定。

11. 编写 ByteBuf 代码的原则

  1. 1. 谁最后消费,谁 release。
  2. 2. 要传给异步线程,先 retain 或 retainedDuplicate。
  3. 3. 要切片长期持有,使用 retainedSlice。
  4. 4. Decoder 判断完整性时用 get,不要提前推进 readerIndex。
  5. 5. 出站交给 Netty 后不要手动释放。
  6. 6. 异常分支和 early return 必须清理。
  7. 7. 优先使用 Netty 提供的解码基类,少手写累积 Buffer。
  8. 8. 压测时打开 leak detector 验证。

一个安全模板:

ByteBuf out = ctx.alloc().buffer();try {    encode(msg, out);    ctx.write(out);    out = null; // 所有权转移给 Netty} finally {    if (out != null) {        out.release();    }}

12. 总结:ByteBuf 的强大来自显式生命周期

ByteBuf 的性能优势来自读写索引、池化、直接内存和零拷贝视图;它的复杂性也来自这些设计。普通 Java 对象只需要关心引用可达性,ByteBuf 还要关心所有权份数。

主线记成:

分配 -> 写入 -> 传播 -> 切片/组合 -> retain/release -> 归还池

下一篇进入 ByteToMessageDecoder。它是 ByteBuf 与业务消息之间的关键桥梁:如何保存半包、如何循环解码、如何避免 decode 不前进导致死循环,以及 cumulation 策略如何影响复制和内存占用。

参考资料

  1. 1. ByteBuf.java:https://github.com/netty/netty/blob/netty-4.1.135.Final/buffer/src/main/java/io/netty/buffer/ByteBuf.java
  2. 2. AbstractByteBuf.java:https://github.com/netty/netty/blob/netty-4.1.135.Final/buffer/src/main/java/io/netty/buffer/AbstractByteBuf.java
  3. 3. PooledByteBufAllocator.java:https://github.com/netty/netty/blob/netty-4.1.135.Final/buffer/src/main/java/io/netty/buffer/PooledByteBufAllocator.java
  4. 4. Netty Reference counted objects:https://netty.io/wiki/reference-counted-objects.html