从症状到解决方案:排查 Java 内存泄漏与内存溢出错误
即使对于经验丰富的工程师而言,排查内存问题(如内存泄漏和内存溢出错误)也可能是一项颇具挑战性的任务。在本文中,我们将分享一些简单的技巧、工具和方法,以便即使是新手工程师也能快速隔离并解决内存问题。
可能导致内存溢出错误的 Java 内存泄漏,有哪些常见迹象?
在应用抛出内存溢出错误(OutOfMemoryError)之前,通常会给出一些预警信号。如果能尽早发现这些信号,就能避免应用宕机和对用户造成影响。以下是需要重点关注的内容:
1. 内存随时间逐渐增长
你是否注意到,即使在垃圾回收之后,应用的堆内存仍在缓慢增加?在健康的应用环境中,内存使用率会以“锯齿状”模式上下波动。但如果内存持续增长,且在垃圾回收后仍无法回落,这可能就是内存泄漏的迹象。最终,完全垃圾回收(Full GC)可能会更频繁地执行,但释放的内存却非常少——这意味着本应被回收的对象仍被持续持有。
(图1:存在内存泄漏的应用的垃圾回收行为)
(图2:健康应用的垃圾回收行为(堆内存使用率无增长))
如果你是 SRE,重点是发现内存泄露即可,之后交给研发人员处理,所以上面这两张图尤其关键,如果你是研发则建议继续阅读下去。
2. CPU 占用率骤升
你的 CPU 占用率是否会毫无征兆地突然飙升?当应用开始出现内存泄漏时,Java 虚拟机(JVM)会更频繁地触发垃圾回收。垃圾回收是一个消耗 CPU 资源的过程,它需要扫描内存以识别并清除未使用的对象。这种额外的工作负载会导致 CPU 占用率急剧上升,有时甚至会达到 100%。
3. 响应时间下降与超时
如果应用突然变慢或出现超时情况,频繁的垃圾回收暂停可能是背后的原因。在垃圾回收执行期间,应用的线程会被暂停。这会导致事务延迟、健康检查失败,甚至可能导致用户请求超时。此时应用虽未完全宕机,但已无法正常响应请求。
4. 内存溢出错误(OutOfMemoryError)
当内存耗尽且 JVM 无法回收足够空间时,就会抛出内存溢出错误。一旦出现这种情况,通常已来不及避免对应用造成影响。OOM 错误通常在系统日志中会有体现,执行 dmesg -T | grep -i "out of memory" 命令也能帮助你查找相关信息。或者直接 grep 系统日志:
grep -i "out of memory" /var/log/syslog
grep -i "out of memory" /var/log/messages
如果你遇到了上述任何一种情况,或许就需要更深入地排查内存问题了。你可以在《内存泄漏的症状》一文中了解更多相关内容。
如何通过分析堆转储(Heap Dump)来识别内存泄漏?
内存泄漏的隔离往往被弄得看似复杂,但在大多数业务应用中,只需遵循以下三个简单步骤,就能实现内存泄漏的隔离:
1. 自动内存泄漏检测
堆转储分析工具会利用机器学习算法自动检测内存泄漏,并将这些泄漏问题在报告顶部突出显示,如下图所示:

(图:HeapHero 工具的自动内存泄漏检测界面)
此部分报告中的每个问题描述都包含超链接。点击这些链接,可获取更深入的信息,例如哪些对象在泄漏、它们占用了多少内存,以及哪些引用在维持这些对象的存活状态。这使得内存泄漏的识别过程更快、更简单。
2. 支配树(Dominator Tree)
支配树部分会列出应用中占用内存最大的对象。由于泄漏对象会占用大量内存,它们通常会出现在列表顶部。可使用“传入引用”(Incoming References)功能查看哪些对象在维持这些泄漏对象的存活状态,也可通过“传出引用”(Outgoing References)查看泄漏对象的子对象及其包含的原始数据——有时这些原始数据能直接指向泄漏的根源。
3. 增长对象
还可以使用类直方图(Class Histogram)来排查内存泄漏。在不同时间点获取 2-3 个堆转储快照,然后对比它们的直方图。在所有快照中数量持续增长的对象,往往暗示着内存泄漏。
注意:确保这些快照是在相似的负载模式下获取的(例如,在同一次负载测试期间,或生产环境中流量相近的时段)。否则,可能会将正常的内存使用增长误判为内存泄漏。
识别出内存问题后,下一步该如何修复?
找到内存问题只是完成了一半工作,接下来需要安全地修复问题并验证问题是否已解决。以下是后续可采取的步骤:
1. 隔离根本原因
利用支配树、类直方图和 GC 根分析(GC Root Analysis),隔离内存问题的根本原因。明确问题是否由以下因素导致:无界缓存、未关闭的监听器、ThreadLocal 误用,还是静态引用?只有准确了解对象被持续持有的原因,才能采取正确的修复措施。
2. 在受控环境中复现问题
如有可能,在较低级别的环境(如预发布环境或本地环境)中,通过相似的流量或测试场景复现内存问题。这有助于验证修复方案的有效性,并确保不会意外引入新的回归问题。
3. 在代码或配置中应用修复
确认根本原因后,对代码或配置进行必要的修改。常见的修复措施包括:
- 限制缓存大小或使用缓存淘汰策略
- 移除未使用的静态引用
- 注销监听器或回调函数
- 避免在长期存活的集合或会话中不必要地持有对象
- 替换实现不佳的单例模式
4. 验证修复效果
应用修复方案后,在相似的负载下重新运行应用,并获取新的堆转储快照。将新快照与之前的快照进行对比,查看是否有改善迹象,例如对象数量减少、保留内存大小降低,以及之前识别出的泄漏可疑对象消失。
5. 在生产环境中监控
修复方案部署后,需在生产环境中持续监控一段时间,以确保其有效性。重点关注关键的内存指标,如堆内存使用趋势、垃圾回收活动、完全垃圾回收频率以及应用响应时间。借助工具,可以在实际场景中跟踪并验证修复方案的影响。
结语
内存泄漏和内存溢出错误会悄无声息地消耗应用性能,最终导致应用陷入停滞。通过正确的工具分析堆转储,你能够找出问题所在、定位内存滞留的位置,并有效解决这些问题。
原文链接:https://dzone.com/articles/troubleshooting-java-memory-leaks-oom/