4.2 G 的 hprof,MAT 自己先 OOM 了
12 月 22 号,营销服务的一个 Pod 因为 java.lang.OutOfMemoryError: Java heap space 挂了。运维在容器被重启前抓到了堆转储,文件大小 4.2 G。我把文件 scp 到本地,双击打开 MAT,进度条走到 70% 左右,MAT 自己弹了一个 Internal Error 然后退出。
看了下日志,是 MAT 自己堆不够。默认配置只给了 1 G。
第一步:先把 MAT 自己的内存调大
MAT 是 Eclipse RCP 应用,JVM 参数在 MemoryAnalyzer.ini 里(macOS 下在 MemoryAnalyzer.app/Contents/MacOS/):
-startup
../Eclipse/plugins/org.eclipse.equinox.launcher_1.6.0.v20200915-1508.jar
--launcher.library
../Eclipse/plugins/org.eclipse.equinox.launcher.cocoa.macosx.x86_64_1.2.0.v20200915-1442
-vmargs
-Xmx6144m
-Dorg.eclipse.swt.internal.carbon.smallFonts
-XstartOnFirstThread
我改成 -Xmx6144m。经验值是 dump 文件的 1.2 到 1.5 倍,4.2 G 的 dump 给 6 G。机器只有 16 G 内存的,解析 4 G 以上 dump 会非常吃力,只能上Ubuntu 服务器跑 MAT 的命令行版本。
顺带说一下,如果是生产环境不方便下载,或者机器内存不够,可以先用 jhat 的替代方案——MAT 自带的 ParseHeapDump.sh 在无图形界面的机器上先把索引生成好:
./mat/ParseHeapDump.sh /data/dump/heap.hprof org.eclipse.mat.api:suspects \
org.eclipse.mat.api:overview org.eclipse.mat.api:top_components
它会生成一堆 *.index 文件和三个 HTML 报告。索引生成完再打开,速度从 20 分钟变成 30 秒。我们这次 4.2 G 的 dump 在 8 核 32 G 的一台跳板机上解析了 11 分 40 秒。
Leak Suspects:先看结论,但别全信
打开之后的第一屏是 Leak Suspects,它自动给你几个"疑似泄漏点"。这次它给出的结论是:
Problem Suspect 1
One instance of "org.apache.catalina.loader.WebappClassLoader" loaded by
"com.xxx.MarketingApplication" occupies 3,102,884,712 bytes (86.42%) of memory.
The instance is referenced by io.netty.util.concurrent.FastThreadLocalThread
@ 0x7a1c3f880 , and is a Thread.
Keywords: org.apache.catalina.loader.WebappClassLoader
io.netty.util.concurrent.FastThreadLocalThread
这条线索很有价值但也很容易误导:WebappClassLoader 占 86% 是正常的,因为几乎所有业务类都是它加载的,它只是个"根节点",Retained Heap 自然巨大。真正的线索在后面那行——FastThreadLocalThread。
所以 Leak Suspects 我一般只用来找方向,不在它上面下结论。
Shallow Heap 和 Retained Heap 到底差在哪
这是 MAT 里最基础也最容易理解错的一对概念。
- Shallow Heap:对象自身占用的内存,不含它引用的其他对象。一个
String的 shallow heap 就是对象头 + hash 字段 + value 引用,24 字节左右,跟这个字符串多长无关。 - Retained Heap:这个对象被 GC 回收后,能连带释放的总内存,也就是"以它为根的子树里,不被子树外任何对象引用的那部分大小之和"。
严格的定义叫 retained set:对象 A 的 retained set = A 被回收后,所有变成不可达的对象集合。如果某个对象同时还被外部引用,它就不在 A 的 retained set 里。
MAT 里有个便捷操作:在 Histogram 里右键某个类 → List Objects → with outgoing references / with incoming references。前者看"我引用了谁"(往下挖),后者看"谁引用了我"(往上找源头)。排查泄漏主要用 incoming references。
Dominator Tree:本次真正定位到问题的视图
点开 Dominator Tree,它按 Retained Heap 从大到小排列,并且把"支配关系"展开成树。支配的意思是:到达对象 B 的每一条路径都必须经过对象 A,那 A 支配 B。
排在最前面的是:
Class Name Shallow Heap Retained Heap Percentage
--------------------------------------------------------------------------------------------------
io.netty.util.concurrent.FastThreadLocalThread @ 0x7a1c3f880 120 1,842,331,920 51.31%
|- value of io.netty.util.concurrent.FastThreadLocalThread 32 1,842,331,888 51.31%
| |- java.util.HashMap @ 0x7b0021a40 48 1,839,204,104 51.22%
| | |- java.util.HashMap$Node[1048576] @ 0x7c11aa000 16,777,232 1,839,204,056 51.22%
| | | |- java.util.HashMap$Node @ 0x7c2000140 32 1,822,445,320 50.75%
| | | | |- com.xxx.marketing.vo.CouponContext @ 0x7c2000160 56 1,776 0.00%
| | | | '- Total: 25 entries
'- Total: 2 entries
--------------------------------------------------------------------------------------------------
一个 Netty 的 FastThreadLocalThread,通过 ThreadLocal 持有一个 HashMap,map 里 104 万个桶,塞了大概 52 万个 CouponContext。Shallow Heap 只有 120 字节的线程对象,Retained Heap 有 1.84 G。
这就是 Retained Heap 的威力:它告诉你"干掉这个对象能回收多少",而 Shallow Heap 只告诉你"这个对象本身多大"。看 Shallow Heap 你永远找不到泄漏。
顺藤摸瓜,找到代码
右键那个 HashMap → List Objects → with incoming references,再对引用链做 Merge Shortest Paths to GC Roots(记得勾选 exclude weak/soft references,否则会被一大堆 WeakReference 淹没):
java.util.HashMap @ 0x7b0021a40
← io.netty.util.concurrent.FastThreadLocal .value
← io.netty.util.concurrent.FastThreadLocalThread @ 0x7a1c3f880 [Stack Local]
← "reactor-http-nio-4" thread
线程名是 reactor-http-nio-4,说明是 WebFlux 的 Netty 事件循环线程。拿着 CouponContext 去代码里搜,找到这段:
public class CouponContextHolder {
private static final ThreadLocal<Map<Long, CouponContext>> HOLDER =
ThreadLocal.withInitial(HashMap::new);
public static void put(Long couponId, CouponContext ctx) {
HOLDER.get().put(couponId, ctx);
}
public static CouponContext get(Long couponId) {
return HOLDER.get().get(couponId);
}
// 没有 remove()
}
这个类是某个同事从老的 Tomcat 项目里复制过来的。在 Tomcat 那种一个请求一个线程、线程用完后归还线程池的模型下,只要每次请求结束不做清理,ThreadLocal 里的 Map 就会跟着线程一起活下去——而 WebFlux 的 Netty 事件循环线程是永不销毁的,只有 8 个(CPU 核数)。
从 9 月这个类上线,到 12 月 22 号,8 个事件循环线程累计攒了 52 万个 CouponContext,平均每个 3.5 KB,总共 1.84 G。堆总共 3 G,所以到 12 月必然爆。
OQL 辅助统计
为了确认 CouponContext 的分布,我用 MAT 的 OQL(类似 SQL 的查询语言)又核了一遍:
SELECT * FROM com.xxx.marketing.vo.CouponContext
SELECT c.couponId, c.createTime.toString()
FROM com.xxx.marketing.vo.CouponContext c
WHERE c.createTime > java.util.Date.parse("2021/12/01")
结果:总数 521,338,其中 12 月创建的只有 31,204。大部分是 9 月和 10 月的,说明确实是只增不删,符合"缓慢泄漏"特征,而不是某次突发流量灌进来的。
修复
WebFlux 里不能用 ThreadLocal 传递上下文,要用 Reactor 的 Context,它是跟着订阅链走的,请求结束自动释放:
public Mono<CouponResult> apply(Long couponId, Mono<UserContext> userMono) {
return userMono
.flatMap(user -> loadContext(couponId))
.flatMap(ctx -> doApply(ctx)
.contextWrite(Context.of("couponCtx", ctx)));
}
如果改动太大来不及,短期方案是在调用链的入口和出口强制清理。我们当时先上了这个救急:
@Override
public <T> Mono<T> filter(ServerWebExchange exchange, WebFilterChain chain) {
return chain.filter(exchange)
.doFinally(signal -> CouponContextHolder.clear()); // 补上 remove()
}
public static void clear() {
HOLDER.remove(); // 关键:是 remove() 不是 get().clear()
}
必须是 remove()。get().clear() 只清空 Map,ThreadLocal 里的 ThreadLocalMap.Entry 还在,如果 value 是弱引用外的设计,或者像 Netty 的 FastThreadLocal 那样有额外优化,清不干净。而且 remove() 顺带把 key 也摘了,避免 ThreadLocalMap 里的 stale entry。
上线后第二天观察,堆占用从 2.8 G 稳定在 780 MB,Young GC 频率从 8 次/分钟降到 2 次/分钟。
小结
- MAT 默认堆太小,解析大 dump 前先改
MemoryAnalyzer.ini的-Xmx,经验值是 dump 的 1.2~1.5 倍。机器跑不动就用ParseHeapDump.sh在服务器上预生成索引。 - Leak Suspects 只看方向,不下结论。它经常报
WebappClassLoader占大头,那是因为几乎所有类都由它加载,不是泄漏点。 - Shallow Heap 看不出泄漏,Retained Heap 才是"回收它能释放多少"。排查时按 Retained Heap 排序。
- Dominator Tree 用来定位"谁是那棵大树的根",配 Merge Shortest Paths to GC Roots(排除弱/软引用)找引用链。
- 引用方向别搞反:
incoming references是"谁引用了我"(找源头),outgoing references是"我引用了谁"(看结构)。 - 清理 ThreadLocal 要用
remove(),不是get().clear()。 - 响应式框架里 Netty 的
FastThreadLocalThread永不销毁,ThreadLocal 泄漏从"缓慢"变成"必然"。用 ReactorContext传上下文。