引言

今天想记录的不是什么高深的理论,而是上周处理的一个真实案例:一台线上应用服务器频繁告警,内存使用率持续飙升直至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进行深度分析

  1. 打开Heap Dump文件后,MAT首先会给出一个疑似泄露的报告(Leak Suspects Report)。 这次它直接指向了一个自定义的 UserSessionCache 类,其实例保留了接近1.5GB的内存!
  2. 点击详情,查看支配树(Dominator Tree)。 在支配树中,可以清晰地看到这个 UserSessionCache 对象持有一个巨大的 HashMap,而这个Map中存放了大量的 UserSession 对象。
  3. 分析引用链(Path to GC Roots)。 右键点击这个巨大的HashMap,选择 "Path To GC Roots" -> "with all references"。通过查看引用链,我发现问题的根源:

    我们的会话缓存设计为LRU(最近最少使用)策略,但当用户执行某个特定操作后,该用户的Session会被添加到一个“活动列表”中。而这个“活动列表”的引用阻止了LRU缓存正常地淘汰这些Session,导致它们永远无法被GC回收,随着时间推移,缓存变得无比臃肿。

解决方案与修复

找到根因后,修复就相对简单了:

  1. 代码修复: 修改了“活动列表”的逻辑,确保它只持有对Session的弱引用(WeakReference)或软引用(SoftReference),这样就不会影响GC对主要缓存对象的回收。

    // 修复前
    private List<UserSession> activeSessions = new ArrayList<>(); // 强引用,导致无法GC
    
    // 修复后
    private List<WeakReference<UserSession>> activeSessions = new ArrayList<>(); // 弱引用
  2. 添加监控: 在修复后的代码中,我们加强了对缓存大小的监控,通过JMX暴露了缓存数量的指标,并集成到Prometheus中,以便后续能够实时监控。
  3. 测试与上线: 在测试环境充分模拟相关操作后,将修复部署到生产环境。后续几天的监控显示,内存使用曲线恢复了正常的“锯齿状”(GC的正常表现),问题得到彻底解决。

经验总结

  • 监控是眼睛: 没有完善的监控,这种缓慢泄露的问题很难在早期发现。
  • GC日志是重要线索: 养成分析GC日志的习惯,它能提供很多关于应用健康度的信息。
  • Heap Dump + MAT 是终极武器: 对于Java内存问题,这套组合拳几乎无往不利。
  • 谨慎使用强引用: 在设计缓存、全局列表等长期存在的结构时,一定要思考对象的生命周期,考虑是否可以使用弱引用/软引用。
  • 复盘的价值: 每一次线上问题的解决,都是极好的学习机会。记录下来,既能加深理解,也能为团队积累知识。

这次排查过程虽然折腾,但把理论知识在实践中完整地走了一遍,收获颇丰。希望这篇记录对遇到类似问题的你也有所帮助。