JVM虚拟机异常排查的核心思路是:先确定OutOfMemoryError的具体类型,再结合堆转储分析、GC日志和监控工具定位内存泄漏点,最后通过代码修复或参数调优解决问题。本文围绕最常见的堆内存溢出、元空间溢出、栈溢出和直接内存溢出四种场景,给出完整的排查命令和调优方案。
先分清OutOfMemoryError的四种类型
很多开发者在遇到OOM时第一反应是加大-Xmx参数,但这样做往往治标不治本,JVM规范定义了多种OutOfMemoryError类型,每种类型的处理方式完全不同,如果连异常类型都没看清就盲目调参,要么问题复现,要么掩盖了更严重的代码缺陷。
以下是四种最常见的类型及触发场景:
- java.lang.OutOfMemoryError: Java heap space:堆内存耗尽,常见于对象创建过多、集合类持有大量数据、代码中存在内存泄漏。
- java.lang.OutOfMemoryError: Metaspace:元空间耗尽,常见于动态生成类、大量使用CGLib/ASM字节码增强、JSP热部署场景。
- java.lang.OutOfMemoryError: unable to create new native thread:操作系统线程数超限,常见于无限制创建线程、操作系统线程数配置过低。
- java.lang.OutOfMemoryError: Direct buffer memory:堆外直接内存耗尽,常见于NIO应用中ByteBuffer分配过多且未释放。
Java堆内存溢出的原因及解决办法
堆内存溢出是最常见的情况,也是大家问得最多的“jvm虚拟机异常排查”场景,先看现象:应用运行一段时间后,接口响应越来越慢,紧接着抛出Java heap space异常,服务失去响应。
常见的堆内存泄漏场景包括:
- 数据库连接池或HTTP连接池关闭不彻底,每次请求都残留连接对象。
- 使用了ThreadLocal但在线程复用场景下未清理,导致对象被线程对象长期持有。
- 静态集合类不断添加数据,比如把某些缓存放在
static Map中且没有过期策略。 - 第三方SDK内部缓存无上限,例如某些日志组件的异步队列。
排查堆内存溢出时,建议按以下流程操作:
-
第一步,确认堆内存使用趋势,使用
jstat -gcutil <pid> 5000每5秒输出一次GC情况,观察Old区是否持续增长且GC后不下降,也可以使用jmap -heap <pid>查看当前堆使用量。 -
第二步,生成堆转储文件,在启动参数中加入
-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/data/logs/,让JVM在OOM时自动生成dump文件,如果服务还没挂,可以手动执行jmap -dump:format=b,file=heap.hprof <pid>。 -
第三步,分析dump文件,使用MAT(Memory Analyzer Tool)打开堆转储文件,重点查看"Dominator Tree"和"Leak Suspects"报告,通常能直接看到哪个对象实例数量异常、被谁引用。
-
第四步,定位泄漏代码,根据MAT分析结果,找到持有对象的GC Root引用链,对应到业务代码中的具体位置,修复资源未释放或逻辑错误。
堆内存调优参数的合理设置方式如下:
| 参数 | 作用 | 建议值 |
|---|---|---|
-Xms |
初始堆大小 | 与-Xmx设相同值,避免运行期扩容 |
-Xmx |
最大堆大小 | 根据服务器物理内存的50%-70%设置 |
-Xmn |
新生代大小 | 堆大小的1/3到1/4 |
-XX:MaxMetaspaceSize |
元空间上限 | 根据类加载量设置,一般512m-1g |
大多数情况下,堆内存溢出不是因为堆太小,而是代码有缺陷,单纯调大-Xmx只会延缓OOM的出现时间,不会消除隐患,正确的做法是先定位泄漏点,再决定是否调整参数。
元空间溢出的典型场景与处理
元空间(Metaspace)用于存放类的元数据信息,JDK 8之后永久代(PermGen)被移除,元空间改为使用本地内存,默认情况下无上限,因此Metaspace OOM往往意味着类加载器泄漏或动态类生成失控。
易触发元空间溢出的常见场景:
- Spring Boot应用使用
spring-boot-devtools热部署功能,每次代码变更都会创建新的类加载器。 - 大量使用动态代理(JDK Proxy、CGLib)且代理类没有被回收。
- MyBatis等框架的Mapper XML文件数量巨大或反复加载。
- Groovy脚本、JavaScript引擎等在运行时频繁编译执行代码。
排查元空间溢出的基本思路是限制元空间大小并观察类加载情况:
在启动参数中加入-XX:MaxMetaspaceSize=256m,让问题快速暴露,使用jstat -gcutil或jmap -clstats <pid>查看类加载器数量和Metaspace使用量,如果类加载器数量持续增加且回收不掉,基本可以断定是类加载器泄漏。
解决方案通常围绕以下三方面:
- 关闭不必要热部署功能,生产环境不使用devtools。
- 检查自定义类加载器是否正确卸载,
loadClass和defineClass的使用是否规范。 - 对Metaspace设置合理上限,避免无限增长拖垮整个操作系统。
栈溢出和线程数超限的排查思路
java.lang.StackOverflowError出现在递归调用没有终止条件、或者方法调用层级过深的场景,这种异常不是OutOfMemoryError,但经常会和OOM混淆在一起排查,解决方式较为简单:检查递归边界条件,将深层递归改为循环或迭代。
unable to create new native thread则表示JVM向操作系统申请创建新线程时失败,触发原因比较复杂:
- 应用代码无限制创建线程,比如线程池未设置最大线程数。
- 操作系统层面限制了进程可创建的线程数(
ulimit -u)。 - 服务器物理内存不足以支持更多线程的栈空间分配。
查看某台Linux服务器上进程的线程数,可以使用命令:ls /proc/<pid>/task | wc -l,如果是业务代码问题,需要排查线程池配置和并发逻辑;如果是操作系统限制,需要调整ulimit -u的值,或者在JVM参数中减少-Xss线程栈大小。
关于直接内存溢出的那些事
使用Netty框架或原生NIO编程时,ByteBuffer会分配堆外内存,这类内存不受-Xmx控制,默认上限等于-XX:MaxDirectMemorySize的值,而该参数不设置时默认等于-Xmx的大小。
直接内存溢出的典型症状是: 堆内存看起来一切正常,GC也很健康,但进程内存占用持续攀升,最终抛出Direct buffer memory异常,排查时重点检查代码中是否有ByteBuffer.allocateDirect()的调用而缺少对应的cleaner机制或DirectByteBuffer.release()方法。
给使用Netty的团队一个建议: 如果遇到线上JVM OOM排查而堆转储内没有明显异常,请先检查Netty的PooledByteBufAllocator和UnpooledByteBufAllocator的使用是否匹配,以及自定义Decoder中缓冲区是否及时释放。
线上JVM OOM排查的具体操作步骤
说到这里,需要梳理一套完整的线上JVM OOM排查流程,方便大家在实际生产环境中按步骤操作,这套流程适用于绝大多数Java服务:
执行以下命令收集现场数据:
top查看进程CPU和内存占用,确认是否是JVM进程本身耗尽内存。jps -l找到目标Java进程的PID。jmap -heap <pid>查看堆内存配置和使用量。jmap -dump:format=b,file=heap.hprof <pid>生成堆转储快照。jstack <pid> > thread.log抓取线程快照,观察是否有线程死锁或大量BLOCKED状态。jstat -gcutil <pid> 1000持续观察GC频率和回收效果。
拿到这些数据后的分析顺序:
- 先用
MAT或jhat分析堆转储,判断是否存在明显的对象泄漏。 - 再看GC日志,判断是内存泄漏还是内存分配速率过快导致的OOM。
- 结合线程快照确认是否有请求并发量突刺、慢SQL积累等问题。
这套流程基本能覆盖绝大多数“jvm虚拟机异常排查”场景,也是阿里的Java开发手册中推荐的常规思路,业内专家指出,把现场数据第一时间保留下来,是成功排查OOM的第一原则。
如何避免内存溢出的常态化机制
与其每次出事都救火,不如建立预防机制,建议从以下层面入手:
- 监控层面: 对JVM堆内存、GC耗时、Full GC次数等指标设置告警阈值,重点监控Old区使用率,超过70%就触发提醒。
- 测试层面: 上线前做压力测试,模拟高峰流量下的内存使用情况,观察是否出现内存占用只升不降的情况。
- 代码层面: 制定编码规范,严禁静态集合无限增长、确保所有连接流的关闭逻辑写在finally块中、正确处理ThreadLocal的remove方法。
关于JVM调优工具有个小发现: 很多人面对“Java内存溢出用什么工具好”时会反复纠结选MAT还是VisualVM还是Arthas,实际上不必过度纠结工具,选一个顺手的就行,MAT强大但上手难度稍高;VisualVM适合初学者,可视化界面友好;Arthas能在不重启服务的情况下在线排查,推荐用Arthas的dashboard命令快速查看内存和GC情况,再配合MAT做深度分析。
面对OOM时的常见误区
排查过程中,有几个误区容易让人走弯路:
- 先调大堆内存再观察。 如上文所述,这可能掩盖真正的代码问题。
- 忽视操作系统层面的限制。 有些OOM(比如native thread耗尽、直接内存溢出)和JVM参数关系不大,需要结合操作系统资源综合考虑。
- 测试环境复现不了就认为问题不存在。 生产环境的数据量和并发模式很难在测试环境完全模拟,需要从代码中先找出所有可能的内存持有点,逐一排除。
最终建议
OOM的根因虽然各有不同,但排查路径是高度一致的:看清异常类型,保留现场数据,用工具分析,最后落到代码层面修复,团队是否具备一套成熟的JVM异常排查流程,直接决定了故障的恢复时长。建议将上述排查步骤固化到团队的应急预案中,让每位开发同学都掌握基本分析手段。
JVM OutOfMemoryError是什么原因:高频问题解答
问:发生了OOM之后有没有必要重启服务?
如果服务已经无法响应且堆转储已保留,直接重启恢复业务没有问题,但重启之前务必把dump文件和GC日志留存下来,否则问题复现时没有排查依据,如果服务还能维持,建议先抓取现场数据再决定是否重启。
问:为什么加大了堆内存OOM仍然频繁出现?
堆内存增大后,GC时长会变长,Full GC触发的阈值也被抬高,问题暴露的周期被拉长,但根本的代码缺陷不会因为内存变大而消失,多数情况下,加大内存只是给了你更多观察时间,而不是解决方案。
问:同一台服务器上部署多个Java应用时如何避免互相影响?
先给每个应用设置明确的堆内存上限并确保总和不超过物理内存的60%-70%,操作系统本身需要预留内存,使用容器部署时,要对JVM可见的CPU和内存做限制,避免某个应用的无限制增长拖垮整台服务器。