本节目标:能判断一块
ByteBuf在某个时刻归谁释放,能读懂泄漏检测器输出的两段栈,知道ReferenceCountUtil.release什么时候该用、什么时候不该用。
适用版本:JDK 25 + Netty 4.2.19.Final(netty-buffer、netty-common模块)。
承接的问题
5.1 结尾留了一个动作:retainedSlice() 让源缓冲区和视图的 refCnt 都变成 2,然后视图必须自己 release()。谁 retain、谁 release,这条规则如果只靠记忆,迟早会在某条异常分支上漏掉一次。
linkd-v2 的 9093 端口每秒处理数千个 LinkdFrame。每个帧至少对应一块 ByteBuf,池化之后这些缓冲区会被反复复用,不是垃圾回收器想收就能收。多一次 release(),池子会把同一块内存借给两条连接,A 的响应写进 B 的连接;少一次 release(),池子以为这块内存还在用,永远不再借出,进程的堆外内存随连接数单调上涨。
更麻烦的是,这两种错误都不会在出错的那一行报错。多释放通常在下一次写入时抛 IllegalReferenceCountException,少释放要等到内存耗尽或者泄漏检测器采样命中。这一节把引用计数的规则、框架替你做的释放、以及泄漏发生后的定位手段连起来讲。
本机口径:Apple M1 Pro,10 核,32 GB,macOS 26.3 (25D125);Temurin 25.0.5+8,Netty 4.2.19.Final。标注「本机输出」的内容来自这台机器,单次运行,未做统计。
refCnt 与 retain/release 的语义
实现 ReferenceCounted 的对象自带一个计数器,初始值是 1。规则只有三条:
retain()把计数加 1,返回对象本身,便于链式调用。release()把计数减 1,减到 0 时释放底层内存并返回true,否则返回false。- 计数已经为 0 时再调用
retain()或release(),抛IllegalReferenceCountException。
跑一段最小验证:
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.util.IllegalReferenceCountException;
public class RefCntDemo {
public static void main(String[] args) {
ByteBuf buf = Unpooled.buffer(8);
System.out.println("after alloc refCnt=" + buf.refCnt());
buf.retain();
System.out.println("after retain refCnt=" + buf.refCnt());
System.out.println("release -> " + buf.release() + " refCnt=" + buf.refCnt());
System.out.println("release -> " + buf.release() + " refCnt=" + buf.refCnt());
try {
buf.release();
} catch (IllegalReferenceCountException e) {
System.out.println("release again -> " + e.getClass().getSimpleName()
+ ": " + e.getMessage());
}
try {
buf.writeByte('X');
} catch (IllegalReferenceCountException e) {
System.out.println("write after 0 -> " + e.getClass().getSimpleName());
}
}
}
本机输出:
after alloc refCnt=1
after retain refCnt=2
release -> false refCnt=1
release -> true refCnt=0
release again -> IllegalReferenceCountException: refCnt: 0, decrement: 1
write after 0 -> IllegalReferenceCountException
几个细节。第一次 release() 返回 false,因为计数从 2 减到 1,内存还在;第二次返回 true,表示这块内存已经被回收。第三次抛异常,异常消息里带着 refCnt: 0, decrement: 1,比「空指针」有用得多。计数为 0 之后连写操作都不允许,writeByte 同样抛 IllegalReferenceCountException。
所有权:谁 retain,谁 release
Netty 的引用计数不是「谁创建谁释放」这么简单,因为缓冲区会在 pipeline 上流动。可以按四条规则判断:
- 创建者持有初始的那一个引用。
ctx.alloc().buffer()、Unpooled.buffer()、readRetainedSlice()返回的对象,初始引用都归当前代码。 - 把对象交给下游(
ctx.fireChannelRead(msg)、out.add(msg))视为转移所有权,交接之后不要再碰它。 - 需要同时持有(缓存、广播、异步任务),先
retain(),每个持有者各释放一次。 - 框架已经声明会释放的对象,不要重复释放;框架声明不会释放的,必须自己释放。
第 4 条最容易出错,因为它取决于具体基类。下面三处是 linkd-v2 里最常遇到的边界。
SimpleChannelInboundHandler 自动释放。 它在 channelRead 的 finally 里对消息做一次释放,autoRelease 默认为 true。所以 channelRead0 返回之后,FullHttpRequest 和它的 content() 都不能再用:
public final class LinkdHttpHandler extends SimpleChannelInboundHandler<FullHttpRequest> {
@Override
protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest req) {
byte[] body = new byte[req.content().readableBytes()];
req.content().getBytes(req.content().readerIndex(), body);
// 这里只能用 body;req 在方法返回后被基类释放
}
}
如果确实要把请求留到异步任务里处理,必须在 channelRead0 里先 req.retain(),任务结束后 req.release();或者用 super(false) 关掉自动释放,自己接管。前者更局部,推荐前者。
反例是把消息继续往下传:
// 反例:自动释放 + 手动向下传递,所有权变得不清楚
@Override
protected void channelRead0(ChannelHandlerContext ctx, FullHttpRequest req) {
ctx.fireChannelRead(req);
}
fireChannelRead 是同步调用,下游本次处理时对象还活着(refCnt 仍为 1);但 channelRead0 返回后,基类会在 finally 里再释放一次。如果下游也是 SimpleChannelInboundHandler 且保持默认的 autoRelease=true,它处理完先释放一次,上游基类再释放一次,第二次就会碰到 IllegalReferenceCountException。如果下游把引用留到异步任务或缓存里,则必须自己 retain(),否则任务执行时对象已经被释放。要么不要继承 SimpleChannelInboundHandler,要么在 channelRead0 里明确转移所有权并关闭自动释放。
ByteToMessageDecoder 不释放入参。 decode(ctx, in, out) 里的 in 是父类维护的累积缓冲区,decode 返回后父类还要用它做 compaction 和下一次解析。在 decode 里对 in 调用 release(),会让父类在收尾时访问已释放内存。需要消费字节就用 readBytes、skipBytes,它们只移动读游标。
readRetainedSlice() 出来的对象归调用方。 它创建一个新引用,父类不会替你释放。只有在确定要把它放进 out 交给下游时才不需要手动释放,因为所有权已经转移:
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import java.nio.charset.StandardCharsets;
public class RetainedSliceDemo {
public static void main(String[] args) {
ByteBuf in = Unpooled.buffer(16);
in.writeBytes("LKDFPAYLOAD".getBytes(StandardCharsets.US_ASCII));
ByteBuf header = in.readRetainedSlice(4);
System.out.println("in.refCnt=" + in.refCnt() + " header.refCnt=" + header.refCnt());
System.out.println("header=" + header.toString(StandardCharsets.US_ASCII)
+ " in.readerIndex=" + in.readerIndex());
header.release();
System.out.println("after header.release in.refCnt=" + in.refCnt());
in.release();
System.out.println("after in.release in.refCnt=" + in.refCnt());
}
}
本机输出:
in.refCnt=2 header.refCnt=2
header=LKDF in.readerIndex=4
after header.release in.refCnt=1
after in.release in.refCnt=0
readRetainedSlice(4) 既前移了 in 的读游标,又把计数加到 2。header.release() 之后源回到 1,最后由 in.release() 归零。如果中间那行漏掉,源计数会停在 1,等 in 被回收时泄漏检测器就会报「没有 release」。
泄漏反例与 paranoid 排查
最危险的一类泄漏不是「忘了 release 一个局部变量」,而是「对象还被某个长生命周期容器引用着」。linkd-v2 里曾经有一版 FrameCache,把最近处理的帧放进静态 Map 方便排障:
// 反例:静态 Map 持有 ByteBuf,既不 release 也不淘汰
public final class FrameCache {
private static final Map<String, ByteBuf> LAST = new HashMap<>();
public static void put(String key, ByteBuf frame) {
LAST.put(key, frame);
}
}
这段代码有两个问题。第一,LAST 是静态的,帧永远不会被淘汰,堆外内存随请求数增长。第二,因为对象一直被强引用,泄漏检测器也不会报:检测器只在对象被 GC 回收、而 refCnt 仍大于 0 时才生成报告。
用一段代码验证这个盲区:
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import java.util.HashMap;
import java.util.Map;
public class ReachableLeakDemo {
static final Map<String, ByteBuf> CACHE = new HashMap<>();
public static void main(String[] args) throws Exception {
System.out.println("level=" + System.getProperty("io.netty.leakDetection.level"));
ByteBuf buf = Unpooled.directBuffer(1024);
buf.writeInt(0x4C4B4446);
CACHE.put("last-frame", buf);
buf = null;
for (int i = 0; i < 3; i++) {
System.gc();
Thread.sleep(1000);
}
System.out.println("cacheSize=" + CACHE.size()
+ " refCnt=" + CACHE.get("last-frame").refCnt());
}
}
用 -Dio.netty.leakDetection.level=paranoid 运行,本机输出:
level=paranoid
cacheSize=1 refCnt=1
没有任何 LEAK 报告,因为对象仍然可达。这类泄漏要靠 PooledByteBufAllocator 的 usedDirectMemory 指标(5.3)或者堆 dump 里的引用链来发现,不能指望泄漏检测器。
把强引用去掉,让对象变得不可达但计数不为 0,检测器才会工作:
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import io.netty.buffer.PooledByteBufAllocator;
public class LeakTriggerDemo {
public static void main(String[] args) throws Exception {
ByteBufAllocator alloc = PooledByteBufAllocator.DEFAULT;
leak(alloc);
for (int i = 0; i < 3; i++) {
System.gc();
Thread.sleep(500);
}
ByteBuf trigger = alloc.directBuffer(64);
trigger.release();
Thread.sleep(500);
}
static void leak(ByteBufAllocator alloc) {
for (int i = 0; i < 2000; i++) {
ByteBuf b = alloc.directBuffer(256);
b.writeLong(i);
}
}
}
leak 方法返回后,2000 个缓冲区都不可达但计数仍是 1。System.gc() 把它们回收掉,再分配一个缓冲区触发检测器检查引用队列。本机输出(省略 JUL 时间戳):
level=PARANOID
严重: LEAK: ByteBuf.release() was not called before it's garbage-collected. See https://netty.io/wiki/reference-counted-objects.html for more information.
Recent access records:
#1:
io.netty.buffer.AdvancedLeakAwareByteBuf.writeLong(AdvancedLeakAwareByteBuf.java:575)
LeakTriggerDemo.leak(LeakTriggerDemo.java:24)
LeakTriggerDemo.main(LeakTriggerDemo.java:10)
Created at:
io.netty.buffer.PooledByteBufAllocator.newDirectBuffer(PooledByteBufAllocator.java:408)
io.netty.buffer.AbstractByteBufAllocator.directBuffer(AbstractByteBufAllocator.java:168)
LeakTriggerDemo.leak(LeakTriggerDemo.java:23)
LeakTriggerDemo.main(LeakTriggerDemo.java:10)
报告里有两段最重要的信息:
Recent access records:这个对象最后一次被谁动过,通常直接指向忘了 release 的业务方法。Created at:分配栈,能看出它来自PooledByteBufAllocator.newDirectBuffer还是某次readRetainedSlice。
排查流程可以固定成五步:
- 在启动参数加
-Dio.netty.leakDetection.level=paranoid,重启并复现。PARANOID对每次分配都采样,开销大,只在短时排查时用。 - 触发 GC 或等待检测器采样,观察日志里是否有
LEAK: ByteBuf.release() was not called。 - 按
Created at定位分配点,按Recent access records定位最后一次使用点,两者之间的每条异常分支都要检查。 - 同一处泄漏只报一次,看到一条报告不代表只有一处;修完一处后重新跑,直到不再出现。
- 修完把级别调回
SIMPLE,不要在生产长期开PARANOID。
如果打开 PARANOID 后迟迟没有报告,先确认对象是不是仍然可达,再看是不是压根没走 Netty 的分配器(比如自己 ByteBuffer.allocateDirect 之后包成 Unpooled.wrappedBuffer,包装对象不会替原缓冲区做泄漏跟踪)。
ReferenceCountUtil.release 的使用边界
ReferenceCountUtil.release(Object) 是一个空安全、类型安全的释放工具:对象为 null 直接返回 false;对象不是 ReferenceCounted 也返回 false,不做任何事;是引用计数对象才调用 release()。本机用 null 和一个 String 调用,两次都返回 false,没有抛异常。
它适合两种场景:
- 写通用 handler 或工具方法,入参类型是
Object,运行时才知道是不是ByteBuf。典型写法是在finally里ReferenceCountUtil.release(msg)。 - 消费一条消息后提前返回,用一条语句覆盖「可能为空、可能不是引用计数对象」的情况。
它不能解决所有权判断。以下三种用法是错的:
- 已经交给下游的消息,再在
finally里release一次,等于重复释放。 - 在
ByteToMessageDecoder.decode里对in调用ReferenceCountUtil.release(in),父类随后会访问已释放内存。 - 用一个
ReferenceCountUtil.release替代成对的retain/release,把「谁持有」的问题掩盖成「谁最后碰过」。
ReferenceCountUtil.retain(msg) 也是同理:只在确实要延长生命周期时调用,并且保证有对应的 release。
在 linkd-v2 里落地
ConnectionRegistry.broadcast(ByteBuf frame) 是引用计数最容易出错的接口:同一个帧要写给多条连接。writeAndFlush 会把消息的所有权交给 pipeline,写完成后由 Netty 释放;如果直接把同一个 ByteBuf 写给 N 条连接,第一条连接写完后计数归零,后面的连接拿到的是已释放对象。
正确写法是每次写入都用 retainedDuplicate() 拿一个独立引用,循环结束后释放原始引用:
package com.plume.linkd.v2.server;
import io.netty.buffer.ByteBuf;
import io.netty.channel.Channel;
import io.netty.channel.group.ChannelGroup;
public final class ConnectionRegistry {
private final ChannelGroup channels;
public ConnectionRegistry(ChannelGroup channels) {
this.channels = channels;
}
public void broadcast(ByteBuf frame) {
try {
for (Channel ch : channels) {
if (ch.isActive()) {
ch.writeAndFlush(frame.retainedDuplicate());
}
}
} finally {
frame.release();
}
}
}
retainedDuplicate() 让每条连接各持有一个引用,索引互不影响;finally 保证即使某次 writeAndFlush 抛异常,原始引用也会被释放。调用方按约定构造好 frame 后交给 broadcast,不再自己 release。
解码侧对应的是 LinkdFrameDecoder。它用 readRetainedSlice 取出负载后,如果转成 LinkdFrame 的 byte[],必须在拷贝完成后立即释放切片;如果把切片直接放进 out,则由下游负责释放:
ByteBuf payload = in.readRetainedSlice(length);
try {
byte[] bytes = new byte[payload.readableBytes()];
payload.getBytes(payload.readerIndex(), bytes);
out.add(new LinkdFrame(type, flags, requestId, bytes)); // 头字段已在前面读出
} finally {
payload.release();
}
这段代码的所有权是闭合的:readRetainedSlice 加 1,finally 减 1,LinkdFrame 里是拷贝出来的 byte[],不依赖 ByteBuf 的生命周期。代价是一次拷贝;如果负载很大、下游又能直接消费 ByteBuf,可以改成把切片放进 out,省掉拷贝,但要在下游 handler 里明确释放。
常见错误与边界
错误一:静态容器缓存 ByteBuf。 对象一直可达,泄漏检测器不会报,内存却只涨不降。缓存要么存 byte[] 拷贝,要么设置上限并在淘汰时 release。
错误二:SimpleChannelInboundHandler 里把消息传给下一个 handler。 基类仍会在 finally 里释放,下游读到计数为 0 的对象。要传递就不要用自动释放的基类。
错误三:在 ByteToMessageDecoder.decode 里 release in。 累积缓冲区归父类管理,释放后父类的 compaction 会操作已释放内存。只移动读游标,不释放入参。
错误四:对同一个对象 release 两次。 本机异常是 IllegalReferenceCountException: refCnt: 0, decrement: 1。常见来源是 finally 里释放了一次,下游 handler 又释放了一次。
错误五:以为 PARANOID 能发现所有泄漏。 它只能发现「对象被回收时计数仍大于 0」的泄漏。仍被强引用的泄漏、原生库自己 malloc 的内存、Unpooled.wrappedBuffer 包装的外部缓冲区,都不在它的覆盖范围内。
边界:release() 返回 false 不是失败。 它只表示计数还没到 0,内存仍然有效。把返回值当成功/失败判断,会在共享引用时误判。
边界:引用计数与 GC 是两套机制。 计数归零会立即归还池化内存,不等待 GC;计数不归零时,即使对象被 GC 回收,池化内存也不会自动回来,这正是泄漏检测器要报告的情形。
小结
refCnt初始为 1,retain()加 1,release()减 1 并在归零时释放内存;归零后再操作抛IllegalReferenceCountException。- 所有权随
fireChannelRead、out.add转移;需要同时持有时先 retain,每个持有者各释放一次。 SimpleChannelInboundHandler默认自动释放,不能把消息继续往下传;ByteToMessageDecoder不释放in;readRetainedSlice()的返回值归调用方。- 静态容器里的
ByteBuf是「可达泄漏」,paranoid 级别也不会报告;只有不可达且计数大于 0 才会出现 LEAK 日志。 -Dio.netty.leakDetection.level=paranoid用于短时排查,报告里的Created at与Recent access records分别定位分配点和使用点。ReferenceCountUtil.release只做空安全和类型安全检查,不改变所有权规则。
引用计数解决的是「什么时候还」,下一个问题是「从哪里借」。5.3 进入 PooledByteBufAllocator,看 arena、chunk、page 怎么组织内存,以及 metric() 能读出哪些可用于判断泄漏和容量的数字。
阅读导航:上一节:5.1 ByteBuf 索引与视图 · 下一节:5.3 PooledByteBufAllocator 与 Arena 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。