4、内存溢出(OOM)的预防与诊断:OOM的常见类型与dump分析
内存溢出,也就是我们常说的OOM,是Java应用中最让人头疼的问题之一。我见过不少团队,线上突然报警,服务挂了,一看日志是OutOfMemoryError,然后全员手足无措。其实OOM没那么神秘,它分几种类型,每种都有对应的套路去预防和诊断。今天我就把这套东西掰开揉碎了讲给你听。
4.1 OOM的三种常见类型
Java里的OOM,说白了就是内存不够用了。但具体是哪个区域不够,差别很大。我根据经验,把最常见的三种类型列出来:
| 类型 | 触发场景 | 典型错误信息 |
|---|---|---|
| 堆溢出 | 对象创建太多,GC回收不掉 | java.lang.OutOfMemoryError: Java heap space |
| 栈溢出 | 递归调用过深,或线程栈帧太多 | java.lang.StackOverflowError |
| 元空间溢出 | 加载的类太多,或动态生成类太多 | java.lang.OutOfMemoryError: Metaspace |
4.1.1 堆溢出(Heap Space)
这是最常见的OOM。我遇到过最典型的一个案例:一个报表系统,每天凌晨跑批任务,结果跑了两个月后突然挂了。一查,是某个查询条件没加限制,导致一次查出了几百万条记录,全部塞进内存里。堆直接爆了。
堆溢出的本质是:对象存活时间太长,或者对象数量太大。GC线程拼命回收,但回收不掉,因为引用链还连着。最终堆空间被占满,新对象无法分配。
核心判断依据:
- GC日志中频繁出现Full GC,但回收效果很差
- 堆内存占用持续上升,无法回落
- 错误信息明确指向“Java heap space”
4.1.2 栈溢出(StackOverflow)
栈溢出和堆溢出不一样。它不涉及大对象,而是线程的调用栈太深了。每个线程都有自己的栈空间,默认一般是1MB左右。如果递归调用没有终止条件,或者方法调用层级太深,栈帧就会把栈空间撑爆。
我记得有一次帮一个同事排查问题,他的代码里有个递归解析JSON的逻辑,JSON嵌套了100多层,结果直接StackOverflow。解决方案很简单:把递归改成循环,或者增大栈空间(-Xss参数)。
注意:栈溢出通常不会导致整个JVM挂掉,只会导致出问题的那个线程挂掉。但如果这个线程是关键线程(比如主线程),那服务也就挂了。
4.1.3 元空间溢出(Metaspace)
元空间溢出在JDK 8之后才出现。它替代了之前的永久代(PermGen)。元空间存储的是类的元数据,比如类名、方法信息、字段信息等。
什么情况下会元空间溢出?最常见的是:动态生成类太多。比如用CGLIB、Javassist做AOP代理,或者用Groovy动态编译脚本。我见过一个项目,每次请求都动态生成一个代理类,结果运行一天后元空间就满了。
我的建议:如果遇到元空间溢出,先检查是不是有类加载器泄漏。比如Tomcat热部署时,旧的类加载器没有被回收,导致类元数据一直堆积。
4.2 如何预防OOM
预防OOM,说白了就是两件事:控制内存使用和设置合理参数。我总结了几条实战经验:
- 设置堆内存上限:-Xmx不要设得太大,也不要设得太小。一般建议是系统物理内存的50%-70%。
- 开启GC日志:-XX:+PrintGCDetails -XX:+PrintGCDateStamps。这是事后分析的关键。
- 设置堆转储参数:-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/path/to/dump。这样OOM时自动生成dump文件。
- 控制集合大小:比如List、Map,尽量设置初始容量,避免频繁扩容。
- 使用软引用/弱引用:对于缓存场景,用WeakHashMap或者软引用,让GC能及时回收。
我曾经踩过的坑:有个项目没有开启HeapDumpOnOutOfMemoryError,结果OOM后只能靠猜。后来我强制要求所有线上应用都必须加上这个参数。你想想看,没有dump文件,你怎么定位问题?
4.3 使用MAT进行dump分析
MAT(Memory Analyzer Tool)是我最常用的dump分析工具。它能把堆转储文件解析成可视化的报告,告诉你哪些对象占用了最多内存。
4.3.1 基本操作步骤
- 拿到dump文件(一般是.hprof格式)
- 用MAT打开,选择“Leak Suspects Report”
- 查看“Biggest Objects”和“Accumulated Objects”
- 分析GC Roots路径,找到泄漏点
举个例子,你打开报告后,MAT会告诉你:某个HashMap占用了80%的堆内存。然后你顺着GC Roots路径一看,发现这个HashMap被一个静态变量引用着,一直没释放。这就是泄漏点。
小技巧:MAT的“Histogram”视图可以按类名排序,快速找到占用内存最多的类。我一般先看这个,再深入分析。
4.3.2 实战案例
我记得有一次线上OOM,dump文件有2GB。我用MAT打开后,发现一个ArrayList占用了1.5GB。点进去一看,里面全是同一个业务对象。再查引用链,发现是一个定时任务每次执行都往这个List里加数据,但从来没清空过。
修复方案:在定时任务执行完后,调用list.clear()。就这么一行代码,解决了问题。
4.4 使用JProfiler进行dump分析
JProfiler是另一个强大的工具。它比MAT更直观,有实时监控功能。我个人习惯在开发环境用JProfiler,线上环境用MAT。
4.4.1 JProfiler的优势
- 可以实时查看堆内存变化
- 支持CPU和线程分析
- 界面更友好,适合新手
用JProfiler分析dump时,我一般先看“Heap Walker”视图。它会按包名、类名分组显示对象数量和大小。然后我再用“Biggest Objects”视图,找到最大的那几个对象。
对比总结:
| 工具 | 适用场景 | 优点 | 缺点 |
|---|---|---|---|
| MAT | 离线分析大dump | 功能强大,自动泄漏检测 | 界面较老,学习曲线陡 |
| JProfiler | 实时监控+小dump分析 | 界面友好,操作直观 | 商业软件,收费 |
4.5 诊断OOM的通用流程
不管用哪个工具,诊断OOM的流程是固定的。我把它总结成四步:
- 确认OOM类型:看错误日志,是堆、栈还是元空间?
- 获取dump文件:如果没开启自动转储,可以用jmap手动抓取。
- 分析泄漏点:用MAT或JProfiler找到占用内存最大的对象。
- 修复代码:释放引用、控制集合大小、优化递归等。
注意:jmap命令会触发Full GC,生产环境慎用。我建议在低峰期执行,或者直接依赖HeapDumpOnOutOfMemoryError参数。
嗯,到这里,OOM的预防与诊断就讲完了。说白了,OOM不可怕,可怕的是没有准备。只要你开启了GC日志和堆转储参数,手里有MAT或JProfiler这样的工具,再配合我上面说的分析流程,大部分OOM问题都能在半小时内定位到根因。
下一章我会讲内存泄漏的检测与修复,到时候会结合更多实战案例。你先把今天的内容消化一下,尤其是dump分析那部分,多练几次就熟了。