Administrator
发布于 2021-12-23 / 1974 阅读
15

MAT 分析堆转储文件的正确姿势

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 你永远找不到泄漏。

顺藤摸瓜,找到代码

右键那个 HashMapList 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 泄漏从"缓慢"变成"必然"。用 Reactor Context 传上下文。

参考