OutOfMemoryError不是“内存不够”的句号,而是排查的起点:先确认错误子类型,再抓堆转储,最后用MAT定位持有链,多数线上OOM都能在半小时内锁定方向。下面按实操顺序拆开讲,涉及参数、命令和工具路径,可以直接照着做。
JVM内存溢出排查步骤:从现象到根因的完整路径
第一步:别急着调大内存,先看异常子类型
JVM抛出的OOM从来不是一句笼统的话,后面跟着后缀,后缀决定了排查方向:
Java heap space:堆内存不够,多半是对象泄漏或一次性加载数据过大GC overhead limit exceeded:GC回收效率极低,回收时间占比超过限制,本质还是堆吃紧Metaspace:类元数据区撑爆,常见于动态生成类、热部署、CGLIB代理滥用Direct buffer memory:堆外内存耗尽,NIO、Netty场景高发unable to create new native thread:线程数超限,和操作系统ulimit -u有关Requested array size exceeds VM limit:一次性申请超大数组
看到日志的第一眼就把后缀抄下来,后面的动作完全不同,业内专家指出,超过一半的误判来自“看到OOM就加-Xmx”,结果把泄漏问题掩盖成更晚的爆发。
第二步:让JVM在崩溃瞬间自己留证据
线上服务不要等人工介入,先把参数配好:
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/dump/heap.hprof -XX:+PrintGCDetails -Xlog:gc:file=/data/logs/gc.log:time
JDK 8用-XX:+PrintGCDetails,JDK 11及以上换成-Xlog:gc,这两个开关成本很低,收益很大,没有它们,事发后只能靠复现。
如果事发时进程还活着,立即手动抓:
jcmd <pid> GC.heap_dump /data/dump/heap_manual.hprof # 或 jmap -dump:format=b,file=heap.hprof <pid>
同时抓一份线程栈:jstack <pid> > thread.txt,堆和线程栈一起看,能快速区分“真泄漏”和“瞬时峰值”。
第三步:用工具读dump,别用眼睛猜
拿到hprof文件后,推荐Eclipse MAT:
- 打开后先看Leak Suspects报告,它会直接给出可疑对象和占比
- 用Dominator Tree找“谁持有谁”,从GC Roots一路往下
- 用Histogram按类统计实例数,重点看业务自定义类,不是
java.lang.String
常见的泄漏模式很固定:静态Map只增不减、ThreadLocal没remove、连接池对象没归还、缓存无上限,把这些类名在代码里搜一遍,往往比读整份dump更快。
四类常见OOM的针对性解法
Java heap space:先区分泄漏还是峰值
- 如果是泄漏:MAT里对象数量随时间线性增长,修代码,比如给缓存加容量上限或改用弱引用
- 如果是峰值:堆确实不够,调
-Xms与-Xmx设为相同值,避免动态扩堆抖动,再配合压测验证
调参不是万能药,行业共识认为,堆大小应与存活对象集匹配,盲目翻倍只会把Full GC的停顿拉长。
Metaspace溢出常见原因与处理
Metaspace溢出在微服务和热部署环境里很常见,几个高频原因:
- 反射、CGLIB、ByteBuddy持续生成代理类,且类加载器没释放
- 应用热部署,旧的ClassLoader被业务代码持有,元数据无法回收
- 大量JSP或动态脚本编译,类数量暴涨
处理思路是先限制再定位:设置-XX:MaxMetaspaceSize=256m让问题尽早暴露,再用jcmd <pid> VM.metaspace或jmap -clstats <pid>看类加载器分布,找到那个一直增长的ClassLoader,顺着引用链改代码。
GC overhead limit exceeded
这个错误的潜台词是“我在疯狂GC但几乎收不回空间”,优先排查:
- 堆设置过小,存活对象已接近上限
- 存在大对象短生命周期反复创建,比如循环里拼字符串、频繁new大数组
- 使用了不适合的垃圾收集器,小堆配G1反而增加开销
Direct buffer memory与线程创建失败
堆外内存问题看-XX:MaxDirectMemorySize,并检查Netty的ByteBuf是否release,线程创建失败则先看ulimit -u和线程池上限,很多事故是线程池没设队列上限,任务堆积又不断新建线程。
堆内存溢出和栈溢出有什么区别?一张表看懂
很多人把两者混为一谈,其实触发位置和现象完全不同。
| 维度 | 堆内存溢出 | 栈溢出 |
|---|---|---|
| 异常类型 | OutOfMemoryError: Java heap space | StackOverflowError |
| 发生区域 | 堆 | 线程栈 |
| 典型原因 | 对象泄漏、缓存无界、大对象 | 递归过深、栈帧过大 |
| 是否进程退出 | 通常导致进程不可用 | 单线程抛出,进程可能存活 |
| 排查工具 | MAT、jmap、GC日志 | jstack、递归调用链审查 |
| 调参方向 | -Xmx、对象生命周期治理 | -Xss、改递归为迭代 |
看清这张表,就不会出现“栈溢出却去加堆内存”的误操作。
线上Java服务OOM怎么快速定位
线上排查讲究顺序,别一上来就上重型工具,一个可复用的路径:
- 看监控:堆使用率、Full GC次数、GC耗时是否在事发前陡增
- 看日志:确认OOM子类型和发生时间点
- 拿dump:自动dump优先,没有就手动补抓
- 本地分析:MAT打开,锁定Top消费者
- 回滚或止血:先降级大流量入口,再改代码
止血和根因分析要分开做,生产环境先恢复可用性,dump留着慢慢看,这两件事不冲突。
预防优于救火:参数配置与压测
上线前把这几件事做了,能挡掉大部分问题:给所有本地缓存设上限并配淘汰策略;对批量查询加分页;对ThreadLocal统一在finally里remove;压测时用和生产相同的堆参数跑满24小时,观察老年代增长曲线,曲线持续上扬就是泄漏信号,比任何告警都直观。
自建监控还是买商业APM:成本账怎么算
团队常纠结这一点,自建基于Prometheus加Micrometer,人力成本主要在搭建和告警调优;商业APM按主机或探针数计费,JVM调优服务的市场报价差异较大,从几千元一次的专项诊断到按年订阅的监控平台都有,判断标准很简单:如果OOM一年发生不到两次,抓dump加MAT足够;如果核心交易链路对停顿敏感,商业工具的火焰图和实时堆分析能省下大量排查时间。
收束
OOM排查的核心不是调参数,而是“先分类、再取证、后定位”这条固定链路,把HeapDumpOnOutOfMemoryError和GC日志当成标配,把MAT当成日常工具,多数问题都不会拖成事故。
Q&A:JVM虚拟机OOM排查常见问题
Q1:没有配置自动dump,事发后进程已经重启,还能查吗?
能查一部分,GC日志、监控里的堆曲线、应用日志中的错误堆栈都能还原趋势;如果开启了jcmd定时采样或APM的堆快照,也能拿到历史数据,完全没留证据时,只能靠复现,成本高很多,所以自动dump参数建议默认开启。
Q2:堆内存调到物理内存的80%合理吗?
不合理,JVM之外还有堆外内存、元空间、线程栈、代码缓存,以及操作系统自身的页缓存,容器环境下更要以容器限额为准,忽略UseContainerSupport会导致JVM按宿主机内存计算,直接触发OOMKilled。
Q3:OOM一定是代码问题吗?
不全是,堆大小配置与业务负载不匹配、容器内存限额低于-Xmx、JDK版本升级后默认收集器变化,都会引发OOM,排查时应先排除配置与运行环境因素,再进入代码层分析,这样能避免在错误的层面上耗费时间。