引言
今天想记录的不是什么高深的理论,而是上周处理的一个真实案例:一台线上应用服务器频繁告警,内存使用率持续飙升直至95%以上,导致服务响应缓慢,最终不得不重启来暂时缓解。这个过程耗费了将近一天的时间,现在把排查思路、使用的工具和最终的解决方案梳理一下,也算是个人的一次复盘。
问题现象与初步判断
监控系统(我们用的是Prometheus + Grafana)最先拉响警报。图表清晰地显示,服务器的内存使用量在几天内呈现稳定的阶梯式上涨,即使在没有高并发请求的凌晨时段,内存也丝毫不降。
- 现象1:
free -h命令查看,可用内存(available)持续减少,缓冲/缓存(buff/cache)占用正常。 - 现象2: 重启Java应用服务后,内存迅速回落,但几个小时后又开始缓慢增长。
基于这些,我初步判断这不是系统级的内存泄露,而是应用程序本身的内存泄露,大概率是JVM堆内存中某些对象无法被垃圾回收(GC)导致的。
排查工具与步骤
1. 确认GC情况
首先,我通过应用启动时配置的GC日志来观察垃圾回收情况。我们的JVM参数中已经包含了 -XX:+PrintGCDetails -XX:+PrintGCTimeStamps -Xloggc:/path/to/gc.log。
分析GC日志发现,Full GC发生的频率越来越高,但每次回收后,老年代(Old Generation)的占用率下降得越来越少。这是一个典型的内存泄露信号——有对象在持续地“泄漏”到老年代,并且无法被回收。
# 示例GC日志片段(简化)
123.456: [Full GC (Ergonomics) ...
[PSYoungGen: 8192K->0K(9216K)]
[ParOldGen: 153600K->153344K(153600K)] 161792K->153344K(162816K),
[Metaspace: 43216K->43216K(1087488K)]
可以看到,老年代(ParOldGen)在这次Full GC后几乎没释放什么空间。
2. 生成并分析堆转储(Heap Dump)
光看GC日志还不够,我们需要知道到底是哪些对象在“作祟”。于是,我决定生成一个堆转储文件来进行离线分析。
生成Heap Dump的方法:
使用 jmap 命令(在线生成):
jmap -dump:live,format=b,file=heapdump.hprof <pid>注意:在生产环境执行此命令可能会引起应用短暂暂停,需选择业务低峰期。在JVM启动参数中预配置(推荐):
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/dump/directory这样当发生OOM时,JVM会自动生成Dump文件。
我使用了jmap命令生成了Dump文件,然后用 Eclipse MAT(Memory Analyzer Tool) 这个强大的工具进行分析。
3. 使用MAT进行深度分析
- 打开Heap Dump文件后,MAT首先会给出一个疑似泄露的报告(Leak Suspects Report)。 这次它直接指向了一个自定义的
UserSessionCache类,其实例保留了接近1.5GB的内存! - 点击详情,查看支配树(Dominator Tree)。 在支配树中,可以清晰地看到这个
UserSessionCache对象持有一个巨大的HashMap,而这个Map中存放了大量的UserSession对象。 分析引用链(Path to GC Roots)。 右键点击这个巨大的HashMap,选择 "Path To GC Roots" -> "with all references"。通过查看引用链,我发现问题的根源:
我们的会话缓存设计为LRU(最近最少使用)策略,但当用户执行某个特定操作后,该用户的Session会被添加到一个“活动列表”中。而这个“活动列表”的引用阻止了LRU缓存正常地淘汰这些Session,导致它们永远无法被GC回收,随着时间推移,缓存变得无比臃肿。
解决方案与修复
找到根因后,修复就相对简单了:
代码修复: 修改了“活动列表”的逻辑,确保它只持有对Session的弱引用(WeakReference)或软引用(SoftReference),这样就不会影响GC对主要缓存对象的回收。
// 修复前 private List<UserSession> activeSessions = new ArrayList<>(); // 强引用,导致无法GC // 修复后 private List<WeakReference<UserSession>> activeSessions = new ArrayList<>(); // 弱引用- 添加监控: 在修复后的代码中,我们加强了对缓存大小的监控,通过JMX暴露了缓存数量的指标,并集成到Prometheus中,以便后续能够实时监控。
- 测试与上线: 在测试环境充分模拟相关操作后,将修复部署到生产环境。后续几天的监控显示,内存使用曲线恢复了正常的“锯齿状”(GC的正常表现),问题得到彻底解决。
经验总结
- 监控是眼睛: 没有完善的监控,这种缓慢泄露的问题很难在早期发现。
- GC日志是重要线索: 养成分析GC日志的习惯,它能提供很多关于应用健康度的信息。
- Heap Dump + MAT 是终极武器: 对于Java内存问题,这套组合拳几乎无往不利。
- 谨慎使用强引用: 在设计缓存、全局列表等长期存在的结构时,一定要思考对象的生命周期,考虑是否可以使用弱引用/软引用。
- 复盘的价值: 每一次线上问题的解决,都是极好的学习机会。记录下来,既能加深理解,也能为团队积累知识。
这次排查过程虽然折腾,但把理论知识在实践中完整地走了一遍,收获颇丰。希望这篇记录对遇到类似问题的你也有所帮助。
暂无评论