当系统抛出 OutOfMemoryError(OOM)异常时,你通常会如何排查和解决?
排查和解决 OutOfMemoryError(OOM)是高级开发人员和架构师必备的核心技能。OOM 并不单单意味着“内存不够了”,它通常是代码缺陷、架构不合理或 JVM 参数配置不当的集中体现。
我通常会按照 “确认异常类型 -> 保护现场与恢复服务 -> 分析 Dump 文件 -> 定位根因 -> 修复与验证 -> 预防” 这一套标准化的流程来进行排查。
以下是具体的排查和解决步骤:
第一步:确认 OOM 的具体类型
看到 OOM,第一步必须看日志中的具体错误信息。Java 中的 OOM 主要分为以下几种,不同类型的排查方向完全不同:
java.lang.OutOfMemoryError: Java heap space(最常见)- 含义:堆内存不足。对象不断创建,且无法被垃圾回收(GC)。
- 方向:排查内存泄漏,或者是否有超大对象/大批量数据被加载到内存。
java.lang.OutOfMemoryError: GC overhead limit exceeded- 含义:GC 效率极低。JVM 花了 98% 的时间做 GC,但只回收了不到 2% 的内存。通常是堆内存即将被撑爆的前兆。
- 方向:同 Heap Space,重点排查内存泄漏。
java.lang.OutOfMemoryError: Metaspace(或老版本的 PermGen space)- 含义:元空间不足。加载的类信息过多。
- 方向:排查是否有大量的动态类生成(如 CGLIB、Spring AOP 反复生成代理类),或者热部署频繁导致类加载器未被回收。
java.lang.OutOfMemoryError: Direct buffer memory- 含义:直接内存(堆外内存)溢出。
- 方向:排查使用 NIO 的框架(如 Netty、Kafka 客户端),是否有未释放的
ByteBuffer。
java.lang.OutOfMemoryError: unable to create new native thread- 含义:无法创建新的本地线程。
- 方向:检查代码中是否有死循环创建线程(未用线程池),或者服务器的 OS 限制(如
ulimit -u设置过小)。
第二步:保护现场与恢复服务 (应急响应)
线上发生 OOM 时,第一要务是恢复业务,同时必须保留证据供后续分析。
- 保留现场(获取 Heap Dump):
- 推荐做法(事前配置):生产环境的 JVM 启动参数中必须加上
-XX:+HeapDumpOnOutOfMemoryError和-XX:HeapDumpPath=/path/to/dump.hprof。这样发生 OOM 时会自动生成快照。 - 手动获取(事发当时):如果进程死锁或假死但未完全崩溃,可以通过命令手动导出:
jmap -dump:live,format=b,file=heap.hprof <PID>。
- 推荐做法(事前配置):生产环境的 JVM 启动参数中必须加上
- 恢复服务:
- 在确保 Dump 文件生成或导出后,立即重启服务,或者将故障节点从负载均衡(如 Nginx/网关)中摘除,让流量打到健康节点。
第三步:深度分析 Dump 文件 (定位根因)
拿到 .hprof 文件后,通常使用 Eclipse MAT (Memory Analyzer Tool)、JProfiler 或 VisualVM 进行离线分析。以 MAT 为例,我的标准操作步骤是:
- 查看 Leak Suspects (泄漏疑点报告):
- MAT 会自动分析出占用内存最大的几个饼图,直接告诉你哪些对象可能导致了内存泄漏。
- 查看 Dominator Tree (支配树):
- 按照 Retained Heap(深堆,即该对象被回收后能释放的总内存)进行降序排列。
- 找到占用内存最大的几个实例对象。
- 追踪 GC Roots (到 GC Roots 的最短路径):
- 在可疑对象上右键 ->
Path To GC Roots->exclude all phantom/weak/soft etc. references(排除弱引用、软引用等,只看强引用)。 - 这一步能清晰地看到:究竟是哪个类、哪个集合、哪个线程持有了这个对象,导致 GC 无法回收它。
- 在可疑对象上右键 ->
- 结合业务代码审查:
- 定位到具体的类名或集合变量后,回到代码库中查找引用位置。
(如果是堆外内存溢出或线程溢出,我会使用 Arthas 或操作系统的 top -H -p <pid>、jstack 等工具来排查线程状态和堆外内存分配情况。)
第四步:常见原因与解决方案 (修复)
根据排查结果,采取相应的修复措施。常见的 OOM 场景及解法如下:
- 大数据量一次性加载 (非内存泄漏,而是撑爆)
- 场景:
select *没有加分页;一次性将百万级数据加载到内存中导出 Excel。 - 解决:数据库查询改用分页(LIMIT)或游标(Cursor/MyBatis流式查询);Excel 导出改用 EasyExcel 等基于磁盘/流式处理的库。
- 场景:
- 集合对象内存泄漏
- 场景:将数据不断放入静态
HashMap或List中,但从来不remove。 - 解决:检查业务逻辑,确保集合有容量上限,或者及时清理无用数据;考虑使用
WeakHashMap或 Guava Cache/Caffeine 等带有淘汰机制的缓存。
- 场景:将数据不断放入静态
- ThreadLocal 内存泄漏
- 场景:在线程池中使用
ThreadLocal存储用户信息,请求结束后没有调用remove()。 - 解决:在拦截器或 AOP 的
finally块中强制调用ThreadLocal.remove()。
- 场景:在线程池中使用
- 不合理的连接池/线程池配置
- 场景:线程池的阻塞队列使用了
LinkedBlockingQueue且未指定大小(默认Integer.MAX_VALUE),导致请求突增时任务堆积,耗尽内存。 - 解决:必须使用有界队列,并配置合理的拒绝策略(RejectedExecutionHandler)。
- 场景:线程池的阻塞队列使用了
- JVM 参数配置不合理
- 场景:年轻代、老年代比例失调,或者初始内存太小。
- 解决:合理调整
-Xms和-Xmx(通常建议设为一致,避免扩容开销),调整垃圾回收器(如升级为 G1)。
第五步:验证与预防机制建立 (闭环)
解决完 Bug 后,还需要建立预防机制,避免再次发生:
- 完善监控告警:
- 接入 Prometheus + Grafana 或其他 APM 工具(如 SkyWalking、Pinpoint)。
- 设置阈值告警:例如“堆内存使用率持续 5 分钟超过 85%”、“频繁触发 Full GC”时,通过钉钉/飞书/邮件报警,做到防患于未然。
- 代码规范与审查:
- 禁止在代码中出现未限制大小的集合操作。
- 规范线程池的使用(禁止直接使用
Executors.newFixedThreadPool,强制使用ThreadPoolExecutor并明确队列大小)。
- 性能/压测体系:
- 重大版本上线前,必须通过 JMeter 等工具进行压力测试,观察内存波动曲线,确保在高并发下没有内存泄漏的趋势。
总结:
面对 OOM,不要慌张重启。核心在于 “留好 Dump 现场 -> 用好 MAT 等工具找 GC Roots -> 结合业务代码改逻辑 -> 上线监控防微杜渐”。这是一个非常考验基本功和工程经验的过程。
右滑查看面试常问