Spring Boot 应用 OOM (OutOfMemoryError) 是严重的线上问题,会导致进程崩溃。排查需要系统性地分析内存使用情况,找出内存无法回收的原因(通常是内存泄漏)。以下是详细的排查指南:
核心原则:
- 立即行动与保留现场:
- OOM 发生后,首要目标通常是尽快恢复服务(如重启实例)。
- 但在重启前,务必尽全力保留现场! 最重要的就是 Heap Dump (堆转储)。JVM 崩溃后,堆内存信息会丢失。
- 配置
-XX:+HeapDumpOnOutOfMemoryError
是黄金法则! 它能在 OOM 发生时自动生成 Heap Dump。
- 理解 OOM 类型: OOM 错误信息会指明内存区域,是排查的关键起点。常见类型:
java.lang.OutOfMemoryError: Java heap space
: 堆内存不足。 最常见,通常是对象太多或存在内存泄漏。java.lang.OutOfMemoryError: Metaspace
/PermGen space
(JDK8 之前): 元空间(类元数据)不足。 可能加载了过多类(如动态生成类、大量反射、类加载器泄漏)。java.lang.OutOfMemoryError: Direct buffer memory
: 直接内存(堆外内存)不足。 由ByteBuffer.allocateDirect()
或 NIO 操作引起。java.lang.OutOfMemoryError: Unable to create new native thread
: 创建新线程失败。 通常因为线程数超过系统限制 (ulimit -u
) 或虚拟内存不足。java.lang.OutOfMemoryError: Requested array size exceeds VM limit
: 尝试分配过大的数组。java.lang.OutOfMemoryError: GC overhead limit exceeded
: GC 效率极低。 连续多次 GC 只回收了极少内存(默认 >98% 时间在 GC,回收 <2% 堆空间)。
- 结合日志: GC 日志和应用日志 是必不可少的辅助信息。
排查步骤:
第一阶段:确认 OOM 类型与收集关键信息
- 查看错误日志:
- 在应用日志(如
application.log
,catalina.out
, 控制台输出)中搜索OutOfMemoryError
或Exception in thread
。精确记录错误类型和堆栈信息的第一行。
- 在应用日志(如
- 检查 Heap Dump (如果已生成):
- 如果配置了
-XX:+HeapDumpOnOutOfMemoryError
,在 OOM 后会在指定路径(或默认当前工作目录)生成.hprof
文件(如java_pid12345.hprof
)。这是最宝贵的证据! - 如果没有自动生成,且进程尚未终止,立即手动生成 Heap Dump:
jmap -dump:format=b,file=oom_heap_dump.hprof <PID>
(可能导致短暂停顿,谨慎使用)jcmd <PID> GC.heap_dump filename=oom_heap_dump.hprof
(推荐,JDK7+)
- 注意: 如果进程已崩溃退出,手动生成的机会就失去了。强调自动配置的重要性!
- 如果配置了
- 检查 GC 日志 (如果已配置):
- GC 日志能展示 OOM 发生前 内存的使用趋势、GC 频率和效果。必须配置!
- 参数示例:
-Xloggc:/path/to/gc.log -XX:+PrintGCDetails -XX:+PrintGCDateStamps -XX:+PrintGCTimeStamps -XX:+UseGCLogFileRotation -XX:NumberOfGCLogFiles=5 -XX:GCLogFileSize=20M
- 分析重点:
- 老年代 (Old Gen) 使用率趋势: 是否持续增长,直到接近 100%?
- Full GC (FGC) 频率和效果: FGC 是否越来越频繁?每次 FGC 后老年代释放的空间是否越来越少?
- GC 原因: 触发 GC 的原因是什么?(如
Allocation Failure
,Metadata GC Threshold
) - 耗时: FGC 的耗时 (
FGCT
) 是否很长?
- 检查应用日志:
- 在 OOM 发生的时间点前后,查找是否有异常、错误、大量重复操作、或特定业务逻辑(如大文件处理、大数据量查询导出)的日志记录。
第二阶段:分析 Heap Dump (针对 Java heap space
OOM 的核心步骤)
- 使用内存分析工具加载 Heap Dump:
- Eclipse Memory Analyzer (MAT): 开源首选,功能强大,擅长泄漏检测。
- VisualVM: JDK 自带,基础分析。
- JProfiler / YourKit: 商业工具,功能全面,分析效率高,可视化好。
- 关键分析路径:
- Leak Suspects Report (MAT): 运行 MAT 的泄漏分析报告。它会尝试找出持有大量内存且可能阻止回收的对象引用链。这是最高效的起点!
- Dominator Tree (支配树): 列出控制着最多内存的对象。关注排名靠前的对象是否是预期内的(如缓存)?或者是不应该存在这么多的对象(如大量重复的某类对象)?查看其 GC Root 路径。
- Histogram (直方图): 按类或类加载器列出对象数量和占用的内存。关注:
- 数量异常多的对象: 特别是业务对象、集合类 (
HashMap
,ArrayList
,HashMap$Node/Entry
)、字符串 (char[]
,String
)、数组。 - 大对象: 占用内存特别大的单个对象。
- 数量异常多的对象: 特别是业务对象、集合类 (
- Thread Overview: 查看线程栈信息(如果堆转储包含)。有时高内存消耗与特定线程正在执行的任务相关。
- Unreachable Objects: 排除不可达对象(MAT 默认排除),专注于活动对象。
- 分析技巧:
- 比较: 如果可能,获取一个正常状态下的 Heap Dump 与 OOM 时的 Dump 进行比较(MAT 支持),更容易发现异常增长的对象。
- 关注引用链: 找到占用内存大的对象后,关键是要看是谁在引用它(GC Root Path)。常见的泄漏根源:
- 静态集合类:
static Map
,static List
等,对象放入后忘记移除。 - 缓存未清理: 使用缓存(如 Guava Cache, Caffeine, Ehcache)但未设置合理的过期策略或大小限制。
- 监听器/回调未注销: 注册了事件监听器或回调函数,但在对象不再需要时未注销。
- 线程局部变量 (
ThreadLocal
): 使用后未调用remove()
,尤其在线程池场景下,线程复用导致ThreadLocal
变量累积。 - 类加载器泄漏: 自定义类加载器加载的类未被卸载,通常因为加载的类实例被其他长生命周期对象引用(如静态变量)。检查
Histogram
按类加载器分组。 - 数据库连接、文件流等资源未关闭: 虽然主要导致资源耗尽,但也可能间接影响内存(如 JDBC 驱动可能缓存对象)。
- 第三方库/框架的已知泄漏: 查阅依赖库的 issue。
- 静态集合类:
- 结合代码: 根据分析工具找到的嫌疑对象和引用链,定位到具体的 Java 类和代码位置进行审查。
第三阶段:分析其他类型 OOM
-
Metaspace
/PermGen
OOM:- 检查加载的类数量: 使用
jcmd <PID> VM.classloader_stats
或工具查看加载的类数量是否异常多。 - 分析 Heap Dump (MAT):
- 在
Histogram
中查看Class
,ClassLoader
的数量和大小。 - 使用
Class Loader Explorer
(MAT) 查看类加载器及其加载的类。关注是否有大量重复类或自定义类加载器实例。
- 在
- 常见原因:
- 频繁动态生成类(如大量使用 ASM, CGLIB, JSP)。
- 框架(如 OSGi, 某些应用服务器)或热部署导致类加载器未卸载(类加载器泄漏)。
- 反射生成过多代理类。
- 元空间大小 (
-XX:MaxMetaspaceSize
) 设置过小。
- 排查: 审查代码中动态生成类的地方;检查框架配置;增加
-XX:MaxMetaspaceSize
(需谨慎,找到根本原因更重要)。
- 检查加载的类数量: 使用
-
Direct buffer memory
OOM:- 检查直接内存使用:
jcmd <PID> VM.native_memory
或NMT
(-XX:NativeMemoryTracking=detail
)。 - 分析代码: 查找使用
ByteBuffer.allocateDirect()
,FileChannel.map()
, NettyPooledByteBufAllocator
等涉及直接内存分配的代码。 - 常见原因: 分配了大量直接内存未释放(忘记调用
Cleaner
或free()
);直接内存泄漏(如 Netty 中未正确释放ByteBuf
);-XX:MaxDirectMemorySize
设置过小。 - 排查: 确保正确释放直接内存;检查第三方库(如 Netty)的使用和配置;适当增大
-XX:MaxDirectMemorySize
。
- 检查直接内存使用:
-
Unable to create new native thread
OOM:- 检查线程数:
pstree -p <PID>
或ps -eLf | grep <PID> | wc -l
(Linux)。jcmd <PID> Thread.print
或jstack <PID>
查看线程数量。
- 检查系统限制:
ulimit -u
(用户最大进程/线程数),/proc/sys/kernel/pid_max
,/proc/sys/kernel/threads-max
,/proc/sys/vm/max_map_count
。 - 常见原因:
- 线程池配置过大或任务堆积导致线程数暴涨。
- 创建了大量未受管理的线程。
- 操作系统资源限制过低。
- 虚拟内存 (
vm.max_map_count
) 不足(每个线程需要栈空间)。
- 排查: 优化线程池配置;修复线程泄漏(未正确关闭线程池或线程);调整系统限制(需评估风险)。
- 检查线程数:
-
GC overhead limit exceeded
OOM:- 分析 GC 日志: 这是最直接的证据。确认是否满足“98%时间GC,回收不到2%堆空间”的条件。
- 分析 Heap Dump: 同
Java heap space
OOM。此错误通常是堆空间 OOM 的一个变种,根本原因也是内存泄漏或堆过小,导致 GC 做无用功。 - 排查: 按
Java heap space
OOM 的步骤排查内存泄漏;适当增大堆 (-Xmx
);调整 GC 策略(有时换更高效的 GC 能缓解)。
第四阶段:代码审查与验证修复
- 定位代码: 根据 Heap Dump 分析、GC 日志、应用日志和 OOM 类型指向的线索,定位到可疑的代码模块或功能。
- 代码审查: 仔细审查相关代码,重点关注:
- 对象生命周期管理(尤其是加入长生命周期集合的对象)。
- 缓存策略(大小、过期时间)。
ThreadLocal
的使用和清理。- 监听器/回调的注册与注销。
- 资源关闭 (
close()
,try-with-resources
)。 - 大对象或大数组的创建。
- 循环或递归中可能累积对象的逻辑。
- 第三方库 API 的正确使用。
- 模拟与测试:
- 尝试在测试环境复现问题(使用相同数据、负载、JVM 参数)。
- 使用 Profiler (JProfiler, YourKit, VisualVM) 在测试环境进行内存分析,监控对象创建和回收情况。
- 对修复后的代码进行压力测试或长时间运行测试,监控内存使用是否稳定。
第五阶段:预防措施
- 强制配置:
-XX:+HeapDumpOnOutOfMemoryError
-XX:HeapDumpPath=/path/to/dumps
(指定目录,确保有权限和空间)- 开启 GC 日志
-XX:+ExitOnOutOfMemoryError
(可选,某些场景下比让应用苟延残喘更好) 或-XX:+CrashOnOutOfMemoryError
(JDK 14+)- 考虑
-XX:NativeMemoryTracking=detail
用于排查堆外内存问题。
- 合理设置 JVM 参数:
-Xms
和-Xmx
设置相同的值,避免堆动态调整的开销和不稳定。- 根据应用特性和硬件设置合适的堆大小。不要盲目设大。
- 设置合理的
-XX:MaxMetaspaceSize
(如 256m)。 - 设置合理的
-XX:MaxDirectMemorySize
(如果需要使用堆外内存)。 - 选择合适的 GC 算法(如 G1)。
- 监控与告警:
- JVM 内存使用率: 堆内存 (各区域)、元空间、直接内存。
- GC 频率与耗时: Young GC, Full GC 次数和时间。
- 线程数。
- 设置阈值告警: 如老年代使用率 >80%,Full GC 次数突增,线程数接近限制等。
- 代码最佳实践:
- 谨慎使用全局缓存和静态集合,明确生命周期和清理机制。
- 正确管理
ThreadLocal
,尤其是线程池场景。 - 及时关闭资源。
- 避免在循环中创建大对象或大量临时对象。
- 对大数据量操作进行分页/分批处理。
- 定期进行代码审查和性能测试。
总结:
OOM 排查的核心在于 Heap Dump 分析 和 GC 日志分析。务必配置自动生成 Heap Dump!通过工具定位到占用内存最多的对象及其引用链,结合代码逻辑找出无法回收的原因(泄漏或设计不当)。针对不同的 OOM 类型,排查侧重点不同。完善的监控和合理的 JVM 参数配置是预防的关键。