老年代只涨不降:一次堆泄漏从 jstat 查到支配树

服务三天重启一次,Full GC 后老年代只从 96% 降到 88%。先用两个 dump 确认是泄漏,再在 MAT 里绕过 byte[] 看支配树,最后落在一个静态缓存的无界 key 上。

订单查询服务每隔三天左右就要重启一次。不是崩溃,是「越跑越慢」:上线第一个小时 P99 是 80ms,第二天 300ms,第三天开始每隔几分钟就有一次 3 秒以上的毛刺,重启之后立刻恢复。

一开始大家怀疑是数据库连接或者下游抖动,因为毛刺时间和下游的慢日志对不上。直到有一次重启前拉了一段 GC 日志,才发现 P99 的毛刺和 Full GC 的时间点完全重合,一次 Full GC 停 2.8 秒。

这台机器配的是 -Xmx4g,按理说 4G 堆不该这么狼狈。真正的问题是:Full GC 之后老年代几乎不下降。也就是说,这不是「对象太多回收不过来」,而是「对象根本没被回收」。

一、先确认到底是不是泄漏

判断泄漏和判断「内存高」是两件事。内存高可能只是缓存用得凶,泄漏的特征是「每次 GC 后的水位都在抬升」。

第一步看 jstat,重点是 Full GC 之后的 OU(老年代使用量)有没有回落:

jstat -gcutil 12345 5000 20
#   S0     S1     E      O      M     CCS    YGC   YGCT    FGC    FGCT     GCT
#  0.00  98.71  41.20  91.83  94.12  92.40   812  18.402    37  100.24  118.64

O 是 91.83%,FGC 37 次,累计 GC 时间 100 秒——平均一次 2.7 秒。再看 GC 日志里 Full GC 前后的老年代:

观察项 正常表现 当时的实测
Full GC 后老年代 降到 20% 以下 从 96% 降到 88%
两次 Full GC 间隔 数小时 从 6 小时缩到 40 分钟
单次 Full GC 停顿 200ms 以内 2.4–3.1 秒
Young GC 后晋升量 稳定 每次约 12 MB,持续上升

回收掉的那 8% 大概 320 MB,而三次 Full GC 之间老年代又涨回 96%。按频率折算,每小时净增长约 60 MB,一天 1.4 GB。这不是抖动,是稳定进水。

顺带说一句,jstat 只能看趋势,看不出「谁在占」。要定位到对象,只有 heap dump 一条路。

二、dump 要在对的时候抓

第一次抓 dump 就踩了坑。我用的是最常见的命令:

jmap -dump:format=b,file=/tmp/heap.hprof 12345

结果文件 4.2 GB,直接把容器的临时目录写满,Pod 被驱逐。更糟的是 jmap -dump 不带 live 时会连垃圾对象一起 dump,文件大且分析噪声多;带上 live 又要先做一次 Full GC,STW 时间直接翻倍。

正确的姿势是用 jcmd,并且提前把 dump 路径指到有空间的地方:

jcmd 12345 GC.heap_dump /data/dump/heap-$(date +%H%M).hprof

生产环境更稳的做法是让 JVM 自己抓——OOM 时自动落盘,报障后直接拿文件,不用在故障现场执行命令:

-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/data/dump
-XX:+ExitOnOutOfMemoryError

这次为了确认是泄漏而不是「堆配小了」,我间隔四十分钟抓了两个 dump 做对比。这一步很关键:如果两个 dump 里同一批对象的数量都在涨,就是泄漏;如果总量差不多,只是分配峰值高,那就是调参的事。

三、MAT 里看支配树,不看最大对象

把 dump 拖进 MAT,第一眼看到的是 Histogram,排第一的是 byte[],占 1.6 GB,第二是 java.lang.String,占 900 MB。很容易得出「字符串太多」的结论然后去查日志拼接,那就跑偏了。

byte[] 和 String 是几乎所有堆的常客,它们只是「载体」。真正要看的是支配树(Dominator Tree):一个对象的 Retained Heap 是指「把它干掉能释放多少内存」,这才是有效信息。

在 Dominator Tree 里按 Retained Heap 排序,第一名是一个 ConcurrentHashMap,Retained 2.3 GB,占整个堆的 58%。展开后是四百多万个 HashMap$Node,每个 Node 挂一个内层 HashMap,内层大概是十几个字段的条目。

用 Merge Shortest Paths to GC Roots 排除掉弱引用、软引用之后,路径是干净的:

java.util.concurrent.ConcurrentHashMap
  <- com.example.order.support.DictCache.CACHE   (static final)
  <- com.example.order.support.DictCache  (class)

一个静态字段,挂着 2.3 GB。到这里根因就没什么悬念了。

四、根因:一个「看起来很正常」的静态缓存

代码长这样,写在一个工具类里:

public final class DictCache {
    private static final Map<String, Map<String, Object>> CACHE = new ConcurrentHashMap<>();

    public static Map<String, Object> get(String tenantId, String code) {
        String key = tenantId + ":" + code + ":" + LocalDate.now();
        return CACHE.computeIfAbsent(key, k -> loadFromDb(tenantId, code));
    }
}

看着像缓存,实际上是个无界字典。key 里带了 LocalDate.now(),本意是「按天失效」,但它只是让每天的 key 不一样——旧 key 从来不删。每个租户每天有几百个字典项,几十个租户,一天新增几万条;三天下来一百多万个 key,每个 key 是一条 String 加一个十几项的内层 Map,凑出 2.3 GB。

更隐蔽的是这个类的名字叫 DictCache,进去看一眼「有缓存、有失效日期」,容易直接放过。真正的坏味道有三个:static 容器、没有容量上限、没有过期淘汰。三者占一个就该警惕,占三个必然出事。

还有一点值得记下来:泄漏对象的类型是 HashMap$Node 和 String,堆里到处都是,Histogram 里根本不出挑。只有按 Retained Heap 看支配关系,才能把它从一个「常见的集合」识别成「异常大的集合」。

五、修法与灰度验证

修的时候没有直接换框架,先把「无界」这件事掐掉。第一步换成 Caffeine,给容量和过期时间都上硬约束:

private static final Cache<String, Map<String, Object>> CACHE = Caffeine.newBuilder()
        .maximumSize(50_000)
        .expireAfterWrite(Duration.ofMinutes(30))
        .recordStats()
        .build();

第二步把 key 从字符串拼接改成「租户 + 编码」的固定维度,去掉 LocalDate.now()。字典数据本来就有更新入口,让写入路径主动 invalidate 比靠日期滚动可靠得多。改完 key 空间从「按天无限增长」变成「租户数 × 字典项数」,量级从百万降到万级。

灰度验证看三个数,都是在发布后连续观察 24 小时:

指标 改前 改后
老年代 GC 后水位 88% 并持续抬升 稳定在 34%–41%
Full GC 次数(24h) 11 次 0 次
Cache 命中率 无统计 96.2%
P99 延迟 300ms(毛刺 3s) 76ms

命中率 96% 说明容量 5 万够用,没有因为加了上限导致回源压力上升。这一点必须验证——把无界缓存改成有界,最常见的副作用就是命中率掉下来,DB 压力翻倍。

六、把「下次能自己查」做进去

泄漏这类问题的特点是「发现了就很容易修,发现不了就一直重启」。所以修完之后我把三件事固定下来。

一是堆水位告警,判据不是「使用率 > 80%」,而是「Full GC 之后老年代仍 > 75%,持续 10 分钟」。前者在缓存型服务上天天误报,后者才是真正的进水信号。

二是 HeapDumpOnOutOfMemoryError 全量开启,dump 目录挂独立盘并配保留策略。这次最耗时的环节不是分析,是「故障现场没有 dump,只能等下一次复现」。

三是浸泡测试进发布流程:核心服务上线前跑 24 小时恒定压测,观察老年代 GC 后水位是否有单调上升趋势。二十分钟的压测看不出泄漏,只有长时间跑才暴露。

代码层面还加了一条 review 规则:static 修饰的 Map、List、Set 必须显式声明容量上限或淘汰策略,否则不予合入。这条规则很粗暴,但拦住的问题比它误伤的多。

小结

  • 判断泄漏看「GC 后水位」而不是「使用率」。jstat 里 Full GC 后 O 不回落,基本就是进水。
  • 抓 dump 用 jcmd GC.heap_dump,路径要指到有空间的盘;生产上直接开 HeapDumpOnOutOfMemoryError,别等故障现场再执行命令。
  • 间隔一段时间抓两个 dump 对比,是区分「泄漏」和「堆配小了」最快的方法。
  • MAT 里别看 Histogram 的 byte[] 和 String,看 Dominator Tree 的 Retained Heap,再顺着 GC Roots 找静态持有者。
  • 无界静态容器 + 无淘汰策略 = 定时炸弹。static 容器必须配容量上限。
  • 改完要验证命中率,有界缓存最常见的副作用是回源压力上升。
查看「Java 与 JVM」全部文章 →

同分类文章

查看 0 评论 0

评论

正在加载评论…

留言提交后自动审核:正常内容立即显示,含敏感信息的会转人工复核。