本节目标:知道泄漏检测器四个级别各自的代价,能从一份
LEAK报告里读出分配点和最后访问点,并在 linkd-v2 上按「限时开启、到点回退」的流程使用它。
适用版本:JDK 25 + Netty 4.2.19.Final(netty-common里的io.netty.util.ResourceLeakDetector)。
承接的问题
5.2 讲过引用计数的语义:ByteBuf 的 refCnt 归零才会归还内存,忘了 release() 的对象要等 GC 回收时才暴露。那一节留了两个没有展开的问题:检测器默认开的是哪一档,以及在生产上怎么开、开多久、怎么退回来。12.3 结尾也把「连接能跑」到「内存不涨」这一步交给了本章。
先纠正一个常见误解:泄漏检测器不是内存分析器。它只在对象被 GC 回收、而 refCnt 仍大于 0 时写一条日志,既不统计总量,也不阻塞分配。所以它擅长回答「谁分配了却没释放」,完全不擅长回答「现在占了多少」。后者是 13.2 的指标要解决的问题。
这一节按「级别语义 → 级别从哪来 → 报告怎么读 → 项目里怎么用」的顺序走,所有代码都在本机编译运行过。
核心机制一:四个级别差在采样与记录
ResourceLeakDetector.Level 只有四个值,差别不在「检测精度」,而在「跟踪多少对象」和「记录多少动作」:
| 级别 | 跟踪范围 | 记录内容 | 相对开销 | 适用场景 |
|---|---|---|---|---|
DISABLED | 不跟踪 | 无 | 零 | 确认无泄漏后的长期生产 |
SIMPLE | 采样一部分分配 | 只记录分配点 | 低 | 默认值,长期生产 |
ADVANCED | 与 SIMPLE 同口径采样 | 分配点 + 最近访问轨迹 | 中 | 怀疑泄漏但还没定位时 |
PARANOID | 每一次分配 | 分配点 + 全量访问轨迹 | 高 | 短时间复现,通常只在预发 |
两处容易记反的地方:
SIMPLE与ADVANCED的采样口径相同,区别是后者会给被跟踪的缓冲区套上带访问记录的包装类,报错时多出Recent access records。不是「ADVANCED 采样更多」。PARANOID才是「每个都跟」。5.2 的实验一次漏 2000 个缓冲区只出一条报告,不是因为只跟踪了一个,而是同一处分配点的泄漏只报一次。
采样比例由实现内部决定,可以用 -Dio.netty.leakDetection.samplingInterval 调整,但不要把它当成稳定契约:不同小版本可能改默认值。判断「有没有开」只看 ResourceLeakDetector.getLevel(),不要靠猜。
核心机制二:级别从 property 来,也可能被代码覆盖
检测器在类初始化时读一次 io.netty.leakDetection.level,之后可以用静态方法 ResourceLeakDetector.setLevel(Level) 改。写一段最小程序把这两条路径都走一遍:
import io.netty.util.ResourceLeakDetector;
import io.netty.util.ResourceLeakDetector.Level;
public class LevelDemo {
public static void main(String[] args) {
System.out.println("initial=" + ResourceLeakDetector.getLevel());
ResourceLeakDetector.setLevel(Level.ADVANCED);
System.out.println("afterSet=" + ResourceLeakDetector.getLevel());
ResourceLeakDetector.setLevel(Level.DISABLED);
System.out.println("afterDisable=" + ResourceLeakDetector.getLevel());
}
}
不传任何参数运行,本机输出:
initial=SIMPLE
afterSet=ADVANCED
afterDisable=DISABLED
initial=SIMPLE 证实了 4.2 的默认值就是 SIMPLE,不需要额外配置。setLevel 是全局静态开关,写完立刻影响后续分配;已经在跟踪中的对象不会因为降级而补一份报告,也不会因为升级而把之前漏掉的分配补回来。
property 的解析是大小写不敏感的,paranoid 和 PARANOID 等价。但拼错不会报错。把参数写成 simpl,本机输出仍然是:
initial=SIMPLE
afterSet=ADVANCED
afterDisable=DISABLED
输出里没有任何告警,initial 停在 SIMPLE。从结果上无法区分「参数被忽略」和「回退到默认值」,两者效果一样,都是 SIMPLE。这一条在排查时很坑:你以为开了 ADVANCED,实际跑的是 SIMPLE,报告照样出现,只是少了访问轨迹,于是没人怀疑参数。启动时把 getLevel() 打出来,是唯一可靠的确认方式。
核心机制三:一个故意不释放的最小程序
下面这个类模拟 handler 的正常分支和异常分支:偶数轮记得 release(),奇数轮忘了。
import io.netty.buffer.ByteBuf;
import io.netty.buffer.ByteBufAllocator;
import io.netty.buffer.PooledByteBufAllocator;
import io.netty.util.ResourceLeakDetector;
public class LeakDemo {
public static void main(String[] args) throws Exception {
System.out.println("level=" + ResourceLeakDetector.getLevel());
ByteBufAllocator alloc = PooledByteBufAllocator.DEFAULT;
for (int round = 0; round < 3; round++) {
handle(alloc, round);
}
for (int i = 0; i < 3; i++) {
System.gc();
Thread.sleep(400);
}
ByteBuf trigger = alloc.directBuffer(64);
trigger.release();
Thread.sleep(500);
System.out.println("done");
}
static void handle(ByteBufAllocator alloc, int round) {
ByteBuf frame = alloc.directBuffer(256);
frame.writeInt(0x4C4B4446);
if (round % 2 == 0) {
frame.release();
return;
}
// 忘了 release
}
}
用 -Dio.netty.leakDetection.level=paranoid 运行,本机输出(JUL 时间戳保留原样,日志前缀是中文 locale):
level=PARANOID
10月 28, 2026 7:28:38 下午 io.netty.util.ResourceLeakDetector reportTracedLeak
严重: 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.writeInt(AdvancedLeakAwareByteBuf.java:569)
LeakDemo.handle(LeakDemo.java:25)
LeakDemo.main(LeakDemo.java:11)
Created at:
io.netty.buffer.PooledByteBufAllocator.newDirectBuffer(PooledByteBufAllocator.java:408)
io.netty.buffer.AbstractByteBufAllocator.directBuffer(AbstractByteBufAllocator.java:168)
io.netty.buffer.AbstractByteBufAllocator.directBuffer(AbstractByteBufAllocator.java:159)
LeakDemo.handle(LeakDemo.java:24)
LeakDemo.main(LeakDemo.java:11)
done
第一行 LEAK: ByteBuf.release() was not called before it's garbage-collected 是定论:对象已经被 GC 回收,但引用计数没有归零,内存不会回到池子里。后面两段栈要对着看:
Recent access records是最后动过这个对象的地方。这里指向AdvancedLeakAwareByteBuf.writeInt,说明它最后一次被使用是在写入时,之后没有任何release调用,也没有被转交出去。奇数轮只有写入、没有释放,和代码对得上。Created at是分配点,指出内存来自PooledByteBufAllocator.newDirectBuffer。这一行决定了它是不是池化直接内存,也决定了它应该由谁归还。
两段栈之间的每一行业务代码都是嫌疑范围。真正要改的地方通常不在 writeInt,而在 handle 的奇数分支缺少 release,或者更常见的:把 frame 传给了下一个 handler 却没有说明所有权转移。
这份报告只出现一次,因为程序里只有第 1 轮满足「未释放且不可达」。换成 2000 个对象、同一处分配点,仍然只有一条报告。报告条数不等于泄漏数量,不要用它估算影响面。
还有两个前提值得写进排查手册:
- 必须发生 GC。生产环境堆很大、GC 很久才来一次,注入流量后要等,或者主动触发一次(只在预发做)。
- 对象必须不可达。5.2 里那个静态
Map缓存的例子,用PARANOID跑也一条报告都没有,因为对象还被根引用着,检测器只处理「被回收且未归零」的对象。
核心机制四:开销来自哪里
PARANOID 贵在两头:每次 directBuffer() / heapBuffer() 都要抓一遍栈,每次读写都要生成一条访问记录,直到对象被释放或者报告写完。栈抓取的成本和栈深度成正比,访问记录的成本和读写次数成正比,所以「大缓冲区、少量读写」和「小缓冲区、高频读写」两种业务的放大倍数完全不同,没有一个可以直接套用的百分比。
SIMPLE 只对采样到的那部分对象抓分配栈,ADVANCED 才对采样到的对象附加访问记录,DISABLED 连包装类都不套。这就是生产长期用 SIMPLE、排查时临时升到 ADVANCED 的原因:两者的采样量一样,附加成本只在被采样的对象上。
另一个被忽略的成本是日志。报告走 JUL,一次泄漏就是一段几十行的栈;如果代码里有循环泄漏,PARANOID 会把磁盘和日志采集一起打满。开启时同时确认三件事:日志轮转开着、单条日志长度有限制、观察窗口有截止时间。
在 linkd-v2 里落地
linkd-v2 不在业务代码里到处调用 setLevel,而是集中到一个启动组件,并强制打印生效值:
package com.plume.linkd.v2.app;
import io.netty.util.ResourceLeakDetector;
import io.netty.util.ResourceLeakDetector.Level;
import java.util.Locale;
public final class LeakDetectionConfig {
private static final System.Logger LOG =
System.getLogger(LeakDetectionConfig.class.getName());
private LeakDetectionConfig() {
}
public static Level apply() {
String raw = System.getProperty("linkd.leak.level", "simple");
Level level = switch (raw.toLowerCase(Locale.ROOT)) {
case "disabled" -> Level.DISABLED;
case "advanced" -> Level.ADVANCED;
case "paranoid" -> Level.PARANOID;
case "simple" -> Level.SIMPLE;
default -> {
LOG.log(System.Logger.Level.WARNING,
"unknown linkd.leak.level={0}, fallback to SIMPLE", raw);
yield Level.SIMPLE;
}
};
ResourceLeakDetector.setLevel(level);
LOG.log(System.Logger.Level.INFO, "leakDetection effective={0} requested={1}",
ResourceLeakDetector.getLevel(), raw);
return level;
}
}
自己解析而不用 io.netty.leakDetection.level 的原因有两个:一是拼错时要有一条 WARNING,二是启动日志里能同时看到「请求值」和「生效值」,避免前面那个 simpl 的静默回退。LinkdV2Main 在任何 ServerBootstrap 创建之前调用一次:
LeakDetectionConfig.apply();
临时排查用启动参数,不改代码:
export JAVA_HOME=~/.jdks/jdk-25.0.5+8/Contents/Home
"$JAVA_HOME/bin/java" \
-Xms2g -Xmx2g -XX:MaxDirectMemorySize=2g \
-Dlinkd.leak.level=advanced \
-cp "linkd-app/target/linkd-app-0.2.0-SNAPSHOT.jar:linkd-app/target/lib/*" \
com.plume.linkd.v2.app.LinkdV2Main
开启与回退按四步走,每一步都有判据:
- 确认现状:启动日志里
leakDetection effective=SIMPLE。如果这里不是SIMPLE,先查是谁在代码里调了setLevel。 - 限时升级:加
-Dlinkd.leak.level=advanced重启,观察窗口建议 30 分钟;复现路径明确时直接用paranoid,但只在一台预发实例上开。 - 收集证据:按
LEAK:过滤日志,每条记录保存Created at与Recent access records两段栈,连同当时的请求特征一起归档。修一个点就重跑一次,直到窗口内不再出现。 - 回退:去掉参数重启即回到
SIMPLE。如果不想重启,临时降级可以在启动组件里加一个定时任务,用GlobalEventExecutor到点自动调回:
GlobalEventExecutor.INSTANCE.schedule(
() -> ResourceLeakDetector.setLevel(Level.SIMPLE), 30, TimeUnit.MINUTES);
这个定时降级只适合「忘了手动回退」的兜底,不能替代重启:setLevel 改的是后续分配的跟踪级别,已经产生的报告仍然留在日志里,占用的记录对象要等对象释放才消失。
拿到报告之后,第一件事是把栈里的类名映射回模块,而不是直接改代码。linkd-v2 的几条常见来源:
Created at 的形态 | 含义 | 该查哪里 |
|---|---|---|
PooledByteBufAllocator.newDirectBuffer + AbstractByteBufAllocator.directBuffer | 某个 handler 通过 ctx.alloc() 主动分配 | 该 handler 的 channelRead 与异常分支 |
AbstractByteBufAllocator.ioBuffer | 写响应用的缓冲区,来自 ctx.alloc().ioBuffer() | 出站 handler 与 writeAndFlush 的失败回调 |
CompositeByteBuf 相关帧 | 聚合或切片产生的视图,引用计数与底层共享 | HttpObjectAggregator 之后的处理逻辑 |
PooledByteBufAllocator.newHeapBuffer | 堆内缓冲区,多半是编解码过程中复制出来的 | LinkdFrameDecoder 的 out.add(...) 分支 |
第二件事是确认所有权。LinkdHttpHandler 继承 SimpleChannelInboundHandler<FullHttpRequest>,入站消息默认由父类释放;如果 channelRead0 里把 req.content() 直接交给异步任务,就必须先 retain(),并让异步任务在结束时 release()。反过来,LinkdFrameDecoder.decode 里的入参 in 由父类管理,自己 release() 会造成重复释放,日志里出现的是 IllegalReferenceCountException,不是 LEAK。两种错误的方向相反,看报错文本就能区分。
ConnectionRegistry.broadcast(ByteBuf frame) 是最容易出问题的一处:同一个 frame 要写给 N 个 channel,每次 writeAndFlush 都会消耗一次引用计数。正确做法是每个 channel 写 frame.retainedDuplicate(),写完后由 pipeline 释放;如果直接写同一个实例,第一次 writeAndFlush 之后对象就可能归零,后面的连接拿到的是 refCnt=0 的缓冲区。LEAK 报告不会直接指出这一点,但 Created at 会指向广播前的分配点,而 Recent access records 会显示最后一次 writeAndFlush 发生在哪个 handler。
常见错误与边界
错误一:把 -Dio.netty.leakDetection.level 和 setLevel 同时用,以为前者优先。 类初始化先读 property,随后 main 里的 setLevel 会覆盖它。现象是参数写了 paranoid,报告却只有分配栈、没有访问轨迹——那其实是 SIMPLE。以启动日志打印的 getLevel() 为准。
错误二:长期开 PARANOID 观察。 采样从一部分变成全部,访问记录从无变成全量,吞吐下降和日志放大同时发生;如果日志采集有采样或限流,报告还会在链路上丢。它适合「复现一次、拿到栈、马上关」。
错误三:以为检测器能发现所有泄漏。 对象还被静态集合、Channel 的 AttributeKey 或长生命周期的 handler 字段引用时,GC 不会回收,检测器不会报。这类问题要用 13.2 的 usedDirectMemory 曲线或者堆 dump 里的引用链来查。
错误四:只修报告里最后一帧。 Recent access records 指向 writeInt,不代表要在 writeInt 前后加 release;所有权可能在更早的 channelRead 里就已经转移。判断依据是「谁 retain、谁负责 release」这条链,不是栈顶那一行。
边界一:包装类不入账。 自己 ByteBuffer.allocateDirect() 再用 Unpooled.wrappedBuffer() 包起来,分配点是 JDK 而不是 Netty 分配器;报告里的 Created at 只能指到包装这一步,13.2 的 usedDirectMemory 也不统计它。
边界二:报告只覆盖池化与 Netty 创建的缓冲区。 通过 ByteBufAllocator 走的所有路径都会被包装;绕过分配器直接操作 ByteBuffer 的代码不在保护范围内。
小结
ResourceLeakDetector.Level四档的核心差别是采样范围与访问记录:SIMPLE/ADVANCED同口径采样,PARANOID跟踪每一次分配。- 默认级别是
SIMPLE;property 大小写不敏感,拼错不会有告警,必须用getLevel()确认生效值。 setLevel是全局静态开关,只影响之后的分配;已泄漏对象不会补报,也不会因为降级而消失。- 一份报告读三段:
LEAK结论、Recent access records最后一次使用、Created at分配点;中间的业务帧就是排查范围。 - 同一处泄漏只报一次,报告条数不能当成泄漏数量;对象仍可达时检测器沉默,这类问题交给 13.2 的指标。
- linkd-v2 用
LeakDetectionConfig集中设置并打印生效值,排查窗口限时 30 分钟,到点重启回退。
13.1 解决的是「有没有忘 release」。下一节换一个方向:release 都写对了,直接内存为什么还在涨,metric() 里的数字又该怎么读。
阅读导航:上一节:12.3 Epoll 在 Linux 的配置 · 下一节:13.2 堆外内存与 PoolArena 指标 。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。