news 2026/7/26 23:40:27

MAT OQL 排查字符串泄漏:从 4GB Dump 到一行代码的定位过程

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
MAT OQL 排查字符串泄漏:从 4GB Dump 到一行代码的定位过程

当 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[],找不到业务层面的泄漏点
  • 转折点:切换到 OQLSELECT 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(保留集,即该对象+它引用的所有后代的总大小)排序,前两行触目惊心:

ClassObjectsShallow HeapRetained Heap
char[]8,312,0442.1 GB2.1 GB
java.lang.String6,140,651245.6 MB2.3 GB
byte[]1,203,488348.2 MB348.2 MB
java.util.HashMap$Node1,872,44689.8 MB1.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 用的思维不同:值语义聚合——不关心"谁保留谁",关心"哪些值重复出现"。对字符串泄漏排查来说,值语义比对象拓扑更直接。

三个止血点(按实施难度排序):

  1. 最便宜:缓存过期策略——Caffeine/Guava Cache/expireAfterWrite,一行配置解决绝大多数缓存泄漏
  2. 中等Matcher.group()input.subSequence(start, end),但不适用于需要独立 String 的场景
  3. 最彻底:用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 min6h+

附:完整命令清单

# 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>
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/7/26 23:40:15

遥感AI基础模型:多模态融合与架构设计实践

1. 遥感基础模型的技术演进脉络遥感技术正经历从传统方法向AI驱动的范式转变。过去五年间&#xff0c;基础模型&#xff08;Foundation Models&#xff09;在计算机视觉领域的突破性进展&#xff0c;正在彻底重塑遥感图像处理的技术路线。这种变革并非简单的技术迁移&#xff0…

作者头像 李华
网站建设 2026/7/26 23:37:59

UE5集成Entt ECS架构实战:万单位移动端性能优化与样条线路径系统

1. 项目概述&#xff1a;为什么要在UE5里折腾Entt&#xff1f; 如果你是一个UE5开发者&#xff0c;最近被“性能瓶颈”这个词折磨得够呛&#xff0c;或者对蓝图和C Actor那套“一个对象包揽一切”的架构感到力不从心&#xff0c;那你可能已经听说过ECS&#xff08;Entity-Compo…

作者头像 李华
网站建设 2026/7/26 23:36:22

Java 数据结构 优先级队列(堆)

目录 常用方法 常⽤接⼝介绍 常用方法 常⽤接⼝介绍 于PriorityQueue的使⽤要注意&#xff1a; 1. PriorityQueue中放置的元素必须要能够⽐较⼤⼩&#xff0c;不能插⼊⽆法⽐较⼤⼩的对象&#xff0c;否则会抛出 ClassCastException异常 2. 不能插⼊null对象&#xff0c;否…

作者头像 李华
网站建设 2026/7/26 23:33:36

深度学习中的Affine与Softmax层实现与优化

1. 项目概述 在深度学习领域&#xff0c;误差反向传播算法&#xff08;Backpropagation&#xff09;是神经网络训练的核心机制。今天我们要重点讨论的是神经网络中两个关键计算层——Affine层和Softmax层的实现细节。这两个层在分类任务中扮演着至关重要的角色&#xff0c;Affi…

作者头像 李华
网站建设 2026/7/26 23:31:25

Google Cloud推提示词即代码 大模型提示词终于能版本管理了

做 AI agent 开发的人应该都有这个体验&#xff1a;系统提示词写在一个巨大的文本块里&#xff0c;改一次提心吊胆一次。一个生产环境的 agent&#xff0c;提示词动辄几百行。里面塞了角色设定、工具描述、输出格式约束、few-shot 示例、安全限制、异常处理……但凡多一个工具或…

作者头像 李华
网站建设 2026/7/26 23:26:15

C语言文件操作全指南:文件读写、随机访问与缓冲区机制

#c语言 「 每日一句 Daily Quote 」 “伟大的成就&#xff0c;唯一的方法就是热爱你所做的事。” — 史蒂夫乔布斯 文章目录前言一、为什么使用文件&#xff1f;二、什么是文件&#xff1f;2.1. 程序文件2.2. 数据文件2.3. 文件名三、二进制文件和文本文件四、文件的打开和关…

作者头像 李华