我曾天真地以为,只要给JVM分配足够大的Heap(堆内存),就能掩盖代码里那些并不优雅的逻辑。直到那个周二凌晨3点,生产环境的报警电话像电钻一样钻进我的耳朵,打破了我的幻想。
那是一次典型的“慢性死亡”:一个核心服务在运行了14天后,16GB的堆内存被吃得只剩几百兆,Full GC(全量垃圾回收)从每小时一次飙升到每分钟一次,CPU直接打满,整个系统陷入瘫痪。
重启服务当然能暂时止血,但如果你和我一样,经历过那种“不知道下一次炸雷是几小时后”的焦虑,你就会明白:掌握Dump文件分析,不是为了炫技,而是为了能睡个安稳觉。
今天,我想复盘那次排查过程,聊聊如何从Dump文件中揪出那些藏在深处的内存“幽灵”。
警惕:监控面板上的“完美曲线”
很多开发同学在收到OOM(内存溢出)报警时的第一反应是:“不可能啊,我看监控,堆内存是缓慢增长的,没有什么突刺。”
我们当时遇到的正是这种情况。那个负责报表导出的服务,在上线初期,Old Gen(老年代)的使用率像一条平滑的斜线,每天增长约5%,非常规律。运维同事甚至开玩笑说:“这内存泄露得还挺有节奏感。”
观点:线性的内存增长往往比突发性暴涨更可怕,因为它意味着存在程序逻辑上的永久性泄漏。
我们当时犯的一个错误是,过度依赖APM(应用性能监控)工具上的图表,而忽略了GC日志中的关键信息。直到我拉出GC日志,才发现虽然每次Full GC后内存会有所回落,但“回落的底线”在不断抬高。
“如果Full GC后的存活对象大小(Live Data Size)在不断增加,不要怀疑,这就是内存泄漏。”
这个判断标准,后来成为了我们团队设定报警阈值的铁律。
实战:Dump文件不仅要拿,还要拿得“巧”
确定了内存泄漏,下一步就是获取现场证据——Heap Dump文件。
这里有个大坑。当时一位年轻的运维为了保留完整现场,直接对那个已经摇摇欲坠的16G进程执行了标准的 jmap -dump 命令。结果,整个JVM进程挂起(STW,Stop The World)了将近40秒,导致上游服务大量超时,引发了二次雪崩。
经验教训:在生产环境,尤其是高负载服务上,获取Dump文件必须小心翼翼。
我现在的做法通常是:
- 摘除流量:先将该节点从负载均衡中下线;
- 只存活对象:使用
live选项,只dump存活的对象,能显著减小文件体积。
# 生产环境推荐操作
# format=b 表示二进制格式,file指定文件名
jmap -dump:live,format=b,file=heap_dump_20231024.hprof <pid>
拿到这个5GB左右的Dump文件后(虽然堆是16G,但压缩和去除非存活对象后会小很多),真正的侦探工作才刚开始。顺便提一句,为了分析这种大文件,我特意申请了一台64G内存的独立工作站,因为在笔记本上跑MAT(Memory Analyzer Tool)简直是折磨。
真相:MAT分析核心——顺藤摸瓜找“真凶”
打开MAT,加载文件,很多人会被复杂的饼图和术语劝退。其实,你只需要关注两个核心概念:Shallow Heap(浅堆) 和 Retained Heap(深堆)。
- Shallow Heap:对象本身占用的内存(通常很小)。
- Retained Heap:对象及其引用的所有对象加起来占用的内存(这才是我们要找的大鱼)。
在那个报表服务的案例中,我直接打开了 Dominator Tree(支配树) 视图。这个视图非常直观,它会按 Retained Heap 对对象进行排序。
排名第一的,赫然是一个 ConcurrentHashMap,占用了近4GB的内存。
点击展开这个Map,发现里面塞满了 byte[] 数组。再看引用链(Path to GC Roots),发现这个Map被一个名为 ExportTaskCache 的静态类引用。
案情还原: 一位离职的同事为了优化报表下载体验,写了一个“临时缓存”功能。当用户生成报表时,由于文件生成较慢,他将生成的二进制流(byte数组)暂时存入这个静态Map,等待用户下载。
然而,他只写了“存”,却忘了写“删”。
// 伪代码还原案发现场
public class ExportTaskCache {
// 静态Map,生命周期与JVM进程一致
private static final Map<String, byte[]> fileCache = new ConcurrentHashMap<>();
public static void cacheFile(String taskId, byte[] data) {
fileCache.put(taskId, data);
// 致命缺陷:缺少过期清理机制
// 哪怕用户下载完了,这个byte[]依然在内存里
}
}
随着用户不断生成报表,这个Map就像一个只进不出的貔貅,慢慢吞噬了所有的堆内存。虽然每个报表只有几MB,但在高并发加持下,两周时间足以填满16G。
找到原因后,修复方案就简单了:引入 Guava Cache 或 Caffeine,设置写入后10分钟自动过期驱逐。
结果: 重新上线后,Old Gen 的使用率稳定在 30% 左右,那条可怕的斜线终于变平了。
总结与落地
内存泄漏排查看起来高深,其实本质就是:发现异常 -> 保留现场 -> 分析引用链 -> 定位代码。
我不建议大家等到出了问题再去现学MAT的使用,这就像不建议在火灾现场学习如何使用灭火器一样。
最后,如果你想避免半夜被报警电话叫醒,我强烈建议你立刻落地以下 3 个动作:
- 防御性配置:在你的启动脚本中,务必加上这两个参数。这能保证在OOM发生的瞬间,JVM自动为你保留最后一张“遗照”,而不是留下一地鸡毛。
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/dump/ - 定期巡检:每周五下午(这是我的个人习惯),花15分钟扫一眼核心服务的Old Gen增长趋势。如果发现有服务重启后内存“回不去”了,建个Ticket排查一下。
- 代码审查:看到
static关键字修饰的Map、List时,脑子里的警铃要响一声。问一句:这东西什么时候删?有没有上限?
互动时间:
在日常排查中,你更倾向于使用哪种工具? A. VisualVM / JConsole(轻量级,实时看) B. Eclipse MAT(重武器,离线深度分析) C. Arthas(命令行神器,线上直接诊断)
欢迎在评论区告诉我你的选择和理由。如果你有被内存泄漏“坑”过的经历,也欢迎分享,让我们一起避坑。