老年代只涨不降:一次堆泄漏从 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容器必须配容量上限。 - 改完要验证命中率,有界缓存最常见的副作用是回源压力上升。
正在加载评论…