当 Dominator Tree 失效时,OQL 才是字符串泄漏的真正入口。4GB dump 里 6000 万个 String 的排查实录。
14:02,告警弹窗:Old Gen 82.5%,FullGC 32 分钟一次。
第一反应——查 Dominator Tree(支配树视图)。几乎所有 MAT 教程教的第一件事就是它——找出"谁 retain 了最多内存",然后自顶向下追。
但这次 Dominator Tree 给的答案全是char[],没有任何业务对象。六千多万个 String 挂在那里,你问它"谁 retain 了它们"——它说"它们自己"。排查到这里,有鬼——常规路线走不通了。
场景故事
- 系统背景:订单推送网关,JDK 8,堆 8GB,ParNew+CMS。近一周 FullGC 从每4小时一次恶化到每30分钟一次。
- 时间线:14:02 CMS 告警→14:05 jmap 导出→14:12 MAT OOM→14:18 调参重开→14:30 Histogram 发现 String 异样→14:45 OQL 定位重复 substring→14:50 代码确认
- 冲突:堆 8GB,dump 4.2GB。MAT 默认 -Xmx1024m 直接撑爆。调大后 Dominator Tree 看到的全是 char[],找不到业务层面的泄漏点
- 转折点:切换到 OQL
SELECT toString(s) FROM java.lang.String s WHERE s.value.length > 200才发现大量重复的 JSON 片段子串。不是"字符串太多",而是"每个字符串都是大字符串的残片" - 技术关键点:MAT 参数调优、OQL(Object Query Language)语法、retained size(保留集) vs shallow size(浅堆)、substring 在 JDK 7+ 的底层行为变化
- 修复:
Matcher.group()返回的 String 替换为input.subSequence(start, end)+intern()
告警
截图类型: metric — CMS 老年代增长曲线,Old Gen 从 2.1GB 在 6 小时内爬升到 6.8GB
告警信号
14:02,告警弹窗:
CMS Old Gen 使用率 82.5%,FullGC 间隔已缩短至 32 分钟,预计 2 小时后触发 Concurrent Mode Failure。
这不是偶然的 GC 波动——是堆在持续增长,且从来没有降下来过。CMS 的 remark 阶段耗时从 200ms 涨到了 1.8s。线程 Dump 显示大部分线程卡在ReferenceProcessor的引用处理上——典型的字符串引用过多信号。
团队群里的反应很一致——"要不要先重启?"这是线上出问题时最常见的对话——重启能止血,但不能定位。跳过重启直接分析,是因为即使重启了,根因没找到,半小时后告警还会回来。
但重启只能止血,不能定位。拿到堆转储才是排查内存泄漏的第一步——没有 dump,后面的所有分析都是空谈。
从监控上看,YoungGC 频率正常(每秒约 0.8 次),但每次晋升(promotion)的对象量在增加。说明不是临时对象太多,而是有东西留在老年代不走了。这一步排除了"GC 参数不合理导致晋升过快"的可能——问题出在业务层,不是 GC 配置层。
起手
截图类型: server — jmap 导出命令 + MAT MemoryAnalyzer.ini 配置
导出堆转储
先拿堆——排查内存问题,第一步永远是导出堆转储。
$ jps-l|grepOrderPushGateway31472cn.opencao.push.OrderPushGateway $ jmap -dump:live,format=b,file=/tmp/heap-1430.hprof31472Dumping heap to /tmp/heap-1430.hprof... Heap dumpfilecreated,4.2GBin38seconds用-dump:live而不是-dump:all——先触发一次 FullGC,只保留有引用链存活的对象。这一步排除了垃圾对象对分析的干扰,让 dump 文件只包含"真正泄漏的"数据。4.2GB,全是活的。
MAT 初探:Histogram
MAT(Eclipse Memory Analyzer Tool)默认的-Xmx1024m显然不够打开 4.2GB dump——直接报了 OOM。
# MemoryAnalyzer.ini — 调大 MAT 堆-Xmx6g-XX:-UseGCOverheadLimit调大后重开,第一个入口——Histogram。按 Retained Heap(保留集,即该对象+它引用的所有后代的总大小)排序,前两行触目惊心:
| Class | Objects | Shallow Heap | Retained Heap |
|---|---|---|---|
char[] | 8,312,044 | 2.1 GB | 2.1 GB |
java.lang.String | 6,140,651 | 245.6 MB | 2.3 GB |
byte[] | 1,203,488 | 348.2 MB | 348.2 MB |
java.util.HashMap$Node | 1,872,446 | 89.8 MB | 1.2 GB |
String + char[] 超过 4.5GB,堆里 60% 以上是字符串。到这里已经可以确定:这是一个字符串泄漏问题。
但问题的关键在于:谁在引用这些字符串?正常思路是切到 Dominator Tree——找出 retain 最多内存的"根"。但 Dominator Tree 按 retained set 排序,String 之间互相引用少(每个 String 只 retain 自己的 char[]),树顶看到的全是 char[] 实例,看不到业务对象。知道字符串多,但不知道谁在引用它们——这是第一个岔路。
收敛
截图类型: diagram — 排查路径决策图
OQL 按长度分类
Dominator Tree 走不通,换个思路——不追对象图拓扑。
MAT 的 OQL(Object Query Language,对象查询语言)支持从值语义维度做聚合。也就是说,不关心"谁引用了谁",直接按字符串长度分组统计:
SELECTs.value.lengthASlen,COUNT(*)AScntFROMjava.lang.String sWHEREs.value!=nullGROUPBYlenORDERBYcntDESCLIMIT20结果:长度为 248、312、476 的三个区间占了 240 万 String 对象。这不是自然分布。自然分布应该是均匀的长尾,而不是三个尖峰。这把"字符串多"细化成了"特定长度的字符串特别多"——说明这些字符串来自同一个源头。
OQL 按内容抽样
既然长度集中,看内容:
SELECTtoString(s)AScontent,s.value.lengthASlenFROMjava.lang.String sWHEREs.value.length=248LIMIT10结果令人惊讶——这些 248 字符的 String 全是同一个 JSON 的子串片段:
"status":"PROCESSING","orderId":"OR2026061400001","timestamp":...每一个都像从一个更大的 JSON 里切出来的子串。既不像正常业务字符串,也不像日志——它是substring()的结果。这一步排除了"日志框架字符串保留"和"JSON 序列化缓存"的可能——问题锁定在字符串截取上。
追溯调用链
知道内容长什么样了——接下来回答"谁留下了这些字符串"。
用 OQL 按内容匹配,然后追溯 incoming reference:
SELECT*FROMjava.lang.String sWHEREtoString(s)LIKE"%PROCESSING%"ANDs.value.length>100在结果集上任选一个 String → 右键 →Calculate Minimum Retained Set(计算最小保留集)→ 追踪到调用链:
OrderExportHandler.extractOrderId()→ java.util.regex.Matcher.group()→ String.substring()到这里,出现了一个让人停下来的时刻:
直觉:substring() 返回的是小字符串,应该很快被 GC,怎么进了老年代?
真相:不是一个大字符串在泄漏。是每个请求都会创建新的 substring 对象,它们被积累在了一个没有过期策略的 ConcurrentHashMap 里。日积月累,数百万个唯一的 orderId String 塞满了老年代。
单看一段代码看不出问题——matcher.group(1)太常见了。但 OQL 让这个"时间累积效应"现了形。用 OQL 而不是 Dominator Tree 的关键原因:当几百个 char[] 不知道属于谁时,按值聚合比按拓扑追溯更快。
定位
截图类型: code — 根因代码,group()+substring() 行高亮
根因代码定位到了OrderExportHandler。红色高亮的两行就是元凶:
①matcher.group(1)做了什么?
JDK 8 的Matcher.group()内部调用subSequence(),最终走的是String.substring()。JDK 7+ 之后,substring()不再共享父字符串的char[],而是每次拷贝一份新的char[]:
publicStringsubstring(intbeginIndex,intendIndex){intsubLen=endIndex-beginIndex;returnnewString(value,beginIndex,subLen);// new char[subLen]}每个group(1)对应一次new char[24]——orderId 虽然短,每天百万级请求,累积到一定周期就成了问题。
②taskCache为什么是永久的?
代码用ConcurrentHashMap做处理器级缓存,但没有 put 上限、没有 TTL、没有 LRU。业务逻辑漏了删除已完成的条目,所有历史 orderId 留在了 Map 里。
问题不在单个对象——单个 String 不过几十字节。问题在缓存模型选型与业务生命周期不匹配。如果缓存对象数不超过 1 万,用ConcurrentHashMap没问题;但这个场景下 map 键数会每日增长,注定爆炸。
复盘
截图类型: diff — 修复前后 OQL 查询对比
复盘要点
整篇排查从告警到定位耗时 48 分钟。以下是完整时间线:
"有鬼"时刻复盘:为什么 Dominator Tree 不好使?
Dominator Tree 按 retained heap 排序,但 String 的 retained set 几乎只有自己的 char[]——每个 String 浅小但独立,树顶被成千上万的 char[] 占据,看不出业务聚合。
OQL 用的思维不同:值语义聚合——不关心"谁保留谁",关心"哪些值重复出现"。对字符串泄漏排查来说,值语义比对象拓扑更直接。
三个止血点(按实施难度排序):
- 最便宜:缓存过期策略——
Caffeine/Guava Cache/expireAfterWrite,一行配置解决绝大多数缓存泄漏 - 中等:
Matcher.group()→input.subSequence(start, end),但不适用于需要独立 String 的场景 - 最彻底:用
CharBuffer/ 偏移量引用替代字符串切分,零拷贝
OQL 不是替代码审查的工具,它是代码审查的放大器。一行group(1)写下去,不会想到 6 个月后它在堆里长成 600 万个 String。OQL 能让你看到这个"时间累积效应"。
修复方案
修复 1:缓存过期策略
privatefinalCache<String,ExportTask>taskCache=Caffeine.newBuilder().maximumSize(10_000).expireAfterWrite(30,TimeUnit.MINUTES).build();修复 2:用subSequence替代group()
CharSequenceorderId=payload.subSequence(matcher.start(1),matcher.end(1));不产生新 String 对象,零拷贝。
修复效果
| 指标 | 修复前 | 修复后 |
|---|---|---|
char[]数量 | 831 万 | 220 万 |
String数量 | 614 万 | 118 万 |
| 堆中字符串占比 | 60%+ | 18% |
| FullGC 间隔 | 32 min | 6h+ |
附:完整命令清单
# 1. 导出堆jmap -dump:live,format=b,file=/tmp/heap.hprof<pid># 2. MAT 调参(MemoryAnalyzer.ini)-Xmx6g-XX:-UseGCOverheadLimit# 3. MAT OQL 查询(按长度分布)SELECT s.value.length AS len, COUNT(*)AS cnt FROM java.lang.String s GROUP BY len ORDER BY cnt DESC;# 4. MAT OQL 查询(查内容)SELECT toString(s)FROM java.lang.String s WHERE s.value.length=248LIMIT10;# 5. MAT OQL 查询(查来源追溯)SELECT * FROM java.lang.String s WHERE toString(s)LIKE"%PROCESSING%"AND s.value.length>100;# 6. Maven 依赖(Caffeine 缓存)# <dependency># <groupId>com.github.ben-manes.caffeine</groupId># <artifactId>caffeine</artifactId># <version>3.1.8</version># </dependency>