基于本文回答
0
评论

当系统抛出 OutOfMemoryError(OOM)异常时,你通常会如何排查和解决?

知识点图片

排查和解决 OutOfMemoryError(OOM)是高级开发人员和架构师必备的核心技能。OOM 并不单单意味着“内存不够了”,它通常是代码缺陷、架构不合理或 JVM 参数配置不当的集中体现。

我通常会按照 “确认异常类型 -> 保护现场与恢复服务 -> 分析 Dump 文件 -> 定位根因 -> 修复与验证 -> 预防” 这一套标准化的流程来进行排查。

以下是具体的排查和解决步骤:


第一步:确认 OOM 的具体类型

看到 OOM,第一步必须看日志中的具体错误信息。Java 中的 OOM 主要分为以下几种,不同类型的排查方向完全不同:

  1. java.lang.OutOfMemoryError: Java heap space (最常见)
    • 含义:堆内存不足。对象不断创建,且无法被垃圾回收(GC)。
    • 方向:排查内存泄漏,或者是否有超大对象/大批量数据被加载到内存。
  2. java.lang.OutOfMemoryError: GC overhead limit exceeded
    • 含义:GC 效率极低。JVM 花了 98% 的时间做 GC,但只回收了不到 2% 的内存。通常是堆内存即将被撑爆的前兆。
    • 方向:同 Heap Space,重点排查内存泄漏。
  3. java.lang.OutOfMemoryError: Metaspace (或老版本的 PermGen space)
    • 含义:元空间不足。加载的类信息过多。
    • 方向:排查是否有大量的动态类生成(如 CGLIB、Spring AOP 反复生成代理类),或者热部署频繁导致类加载器未被回收。
  4. java.lang.OutOfMemoryError: Direct buffer memory
    • 含义:直接内存(堆外内存)溢出。
    • 方向:排查使用 NIO 的框架(如 Netty、Kafka 客户端),是否有未释放的 ByteBuffer
  5. java.lang.OutOfMemoryError: unable to create new native thread
    • 含义:无法创建新的本地线程。
    • 方向:检查代码中是否有死循环创建线程(未用线程池),或者服务器的 OS 限制(如 ulimit -u 设置过小)。

第二步:保护现场与恢复服务 (应急响应)

线上发生 OOM 时,第一要务是恢复业务,同时必须保留证据供后续分析。

  1. 保留现场(获取 Heap Dump)
    • 推荐做法(事前配置):生产环境的 JVM 启动参数中必须加上 -XX:+HeapDumpOnOutOfMemoryError-XX:HeapDumpPath=/path/to/dump.hprof。这样发生 OOM 时会自动生成快照。
    • 手动获取(事发当时):如果进程死锁或假死但未完全崩溃,可以通过命令手动导出:jmap -dump:live,format=b,file=heap.hprof <PID>
  2. 恢复服务
    • 在确保 Dump 文件生成或导出后,立即重启服务,或者将故障节点从负载均衡(如 Nginx/网关)中摘除,让流量打到健康节点。

第三步:深度分析 Dump 文件 (定位根因)

拿到 .hprof 文件后,通常使用 Eclipse MAT (Memory Analyzer Tool)JProfilerVisualVM 进行离线分析。以 MAT 为例,我的标准操作步骤是:

  1. 查看 Leak Suspects (泄漏疑点报告)
    • MAT 会自动分析出占用内存最大的几个饼图,直接告诉你哪些对象可能导致了内存泄漏。
  2. 查看 Dominator Tree (支配树)
    • 按照 Retained Heap(深堆,即该对象被回收后能释放的总内存)进行降序排列。
    • 找到占用内存最大的几个实例对象。
  3. 追踪 GC Roots (到 GC Roots 的最短路径)
    • 在可疑对象上右键 -> Path To GC Roots -> exclude all phantom/weak/soft etc. references(排除弱引用、软引用等,只看强引用)。
    • 这一步能清晰地看到:究竟是哪个类、哪个集合、哪个线程持有了这个对象,导致 GC 无法回收它
  4. 结合业务代码审查
    • 定位到具体的类名或集合变量后,回到代码库中查找引用位置。

(如果是堆外内存溢出或线程溢出,我会使用 Arthas 或操作系统的 top -H -p <pid>jstack 等工具来排查线程状态和堆外内存分配情况。)


第四步:常见原因与解决方案 (修复)

根据排查结果,采取相应的修复措施。常见的 OOM 场景及解法如下:

  1. 大数据量一次性加载 (非内存泄漏,而是撑爆)
    • 场景select * 没有加分页;一次性将百万级数据加载到内存中导出 Excel。
    • 解决:数据库查询改用分页(LIMIT)或游标(Cursor/MyBatis流式查询);Excel 导出改用 EasyExcel 等基于磁盘/流式处理的库。
  2. 集合对象内存泄漏
    • 场景:将数据不断放入静态 HashMapList 中,但从来不 remove
    • 解决:检查业务逻辑,确保集合有容量上限,或者及时清理无用数据;考虑使用 WeakHashMap 或 Guava Cache/Caffeine 等带有淘汰机制的缓存。
  3. ThreadLocal 内存泄漏
    • 场景:在线程池中使用 ThreadLocal 存储用户信息,请求结束后没有调用 remove()
    • 解决:在拦截器或 AOP 的 finally 块中强制调用 ThreadLocal.remove()
  4. 不合理的连接池/线程池配置
    • 场景:线程池的阻塞队列使用了 LinkedBlockingQueue 且未指定大小(默认 Integer.MAX_VALUE),导致请求突增时任务堆积,耗尽内存。
    • 解决:必须使用有界队列,并配置合理的拒绝策略(RejectedExecutionHandler)。
  5. JVM 参数配置不合理
    • 场景:年轻代、老年代比例失调,或者初始内存太小。
    • 解决:合理调整 -Xms-Xmx(通常建议设为一致,避免扩容开销),调整垃圾回收器(如升级为 G1)。

第五步:验证与预防机制建立 (闭环)

解决完 Bug 后,还需要建立预防机制,避免再次发生:

  1. 完善监控告警
    • 接入 Prometheus + Grafana 或其他 APM 工具(如 SkyWalking、Pinpoint)。
    • 设置阈值告警:例如“堆内存使用率持续 5 分钟超过 85%”、“频繁触发 Full GC”时,通过钉钉/飞书/邮件报警,做到防患于未然。
  2. 代码规范与审查
    • 禁止在代码中出现未限制大小的集合操作。
    • 规范线程池的使用(禁止直接使用 Executors.newFixedThreadPool,强制使用 ThreadPoolExecutor 并明确队列大小)。
  3. 性能/压测体系
    • 重大版本上线前,必须通过 JMeter 等工具进行压力测试,观察内存波动曲线,确保在高并发下没有内存泄漏的趋势。

总结:
面对 OOM,不要慌张重启。核心在于 “留好 Dump 现场 -> 用好 MAT 等工具找 GC Roots -> 结合业务代码改逻辑 -> 上线监控防微杜渐”。这是一个非常考验基本功和工程经验的过程。

右滑查看面试常问