Skip to content

OOM 与 StackOverflow 的常见原因与案例

当 Java 说"OutOfMemoryError"时,你该看哪里?

Java 开发者迟早会遇到一个场景:应用变慢、接口超时、日志突然不写了,然后控制台或者告警系统抛出一个 OutOfMemoryErrorStackOverflowError。这两个异常是 JVM 最典型的"内存告警",但它们的成因、排查思路、解决手段完全不同。新手容易混为一谈,理解背后原理才能快速定位。


问题:StackOverflowError 是怎么产生的?

先看一段代码:

java
public class StackOverflowDemo {
    public static void main(String[] args) {
        recursiveCall(0);
    }

    static void recursiveCall(int depth) {
        System.out.println("depth: " + depth);
        recursiveCall(depth + 1);
    }
}

运行后,不到两秒就会输出:

depth: 0
depth: 1
...
depth: 10000
Exception in thread "main" java.lang.StackOverflowError

原理:每个线程都有一个独立的 Java 虚拟机栈,每个方法调用对应一个栈帧(Stack Frame)。栈帧里存了局部变量表、操作数栈、动态链接、方法出口等信息。当栈帧深度超过线程栈的默认大小(Linux 默认 1024KB,可通过 -Xss 调整),JVM 就会抛出 StackOverflowError

常见触发场景:

  • 递归无终止条件——不止是代码写错,JSON 序列化中 A 引用 B、B 引用 A 而没加 @JsonIgnore,一样会触发无限递归导致栈溢出
  • 深层次方法调用链——Spring 或 MyBatis 的 AOP 代理嵌套过深(比如同一个 Service 内部方法调用导致多个代理叠加)
  • 复杂 XML/JSON 解析——Jackson 或 Gson 反序列化循环引用对象时,如果配置不当,会陷入无限递归
  • 线程栈帧太小——-Xss 设得过低(比如 128KB),正常业务逻辑也可能达到栈深度上限

OOM 的六大类型与实战案例

1. Java heap space

最常见。堆内存不足,对象分配失败。

实战案例:一个数据报表系统,每天凌晨跑批生成报表。某天报表接口返回 503,查看日志发现大量 java.lang.OutOfMemoryError: Java heap space

排查过程:用 jmap -dump:format=b,file=heap.hprof <pid> 导出堆快照,导入 MAT 分析。发现一个 HashMap 里存了 2000 万条用户行为记录,只入不出。溯源代码发现是用户 session 级别的缓存,没有设置最大容量或过期时间。修复方案:引入 Guava CachemaximumSize + expireAfterWrite,限制最大 10000 条,2 小时后自动过期。

根因总结对象泄漏——数据结构无限制增长,最典型的生产 OOM 成因。

2. Metaspace

JDK 8 中元空间取代了永久代,使用本地内存,默认无上限。但频繁加载类且不卸载,会让元空间持续膨胀。

实战案例:一个 Spring Boot 微服务部署在 Jenkins 上,每次构建后自动部署。连续部署 20 次后,容器突然 OOM 重启。

排查过程jmap -clstats <pid> 查看 ClassLoader 信息,发现活动 ClassLoader 数量从正常的 50 个增长到 2000+ 个。每个部署创建新的类加载器,而旧的类加载器因为被 Log4j2 的 LoggerContext 引用,无法被 GC 回收。加上 -XX:+TraceClassUnloading-XX:+TraceClassLoading 确认,每次部署新加载了约 5000 个类,几乎不卸载。

修复方案-XX:MaxMetaspaceSize=256m 限制元空间上限,同时修复了 Log4j2 的 LoggerContext 持有旧 ClassLoader 引用的问题(升级 Log4j2 版本,避免 LoggerContext 跨 ClassLoader 引用)。

3. Direct buffer memory

NIO 堆外内存泄漏,DirectByteBuffer 未释放时会持续占用堆外内存。

实战案例:一个基于 Netty 的网关应用,运行 3 天后吞吐量从 10000 QPS 降为 2000 QPS,最终 OOM。

排查过程jmap -dump:format=b 导出堆快照,堆内对象正常(堆占用不到 30%),但 pmap <pid> 看到进程的 RSS 已达 8GB(-Xmx 只有 2GB)。用 NMT-XX:NativeMemoryTracking=summary)确认 Direct Memory 使用异常。MAT 中查看 DirectByteBuffer 的引用链,发现一个业务 Handler 在异常流程中调用了 ctx.write() 但没调用 ctx.flush(),导致 ByteBuf 一直积压在 Channel 的写缓冲区里。

修复方案:在异常处理中调用 ReferenceCountUtil.release(msg) 确保 ByteBuf 释放,同时设置 -XX:MaxDirectMemorySize=1g 作为兜底保护。

4. GC overhead limit exceeded

GC 占用了 98% 以上 CPU 但只回收了 2% 以下的堆,JVM 会抛出此异常并停止应用。

实战案例:一个电商后台的库存查询接口,高峰期间突然大量超时。

排查过程top -H 看到 CPU 被 GC 线程几乎占满,GC 日志显示 Full GC 每秒触发 2-3 次,每次回收效果极差。jstat -gcutil <pid> 1s 看到老年代始终占用 95% 以上。原因是堆内存 -Xmx 只设了 512MB,而业务高峰期需要 1.5GB 以上。

修复方案-Xmx 调整为 2GB,同时优化了库存查询接口,减少每次请求创建的对象数量(直接使用 long[] 替代 ArrayList<Long> 缓存库存 ID)。

5. Unable to create new native thread

操作系统线程数耗尽。Java 应用中线程池创建过多,或者容器没有设置 PID 限制。

实战案例:一个微服务实例在业务高峰期连续收到 5000+ 请求,线程池配置为 newFixedThreadPool(2000),执行 ps -eLf | grep java | wc -l 看到线程数达到 3000+,操作系统 nproc 限制为 4096,但容器内还有多个其他进程占用线程资源。

排查过程ulimit -u 查看用户进程限制,cat /proc/<pid>/limits 查看进程限制。发现容器内 Max processes 只有 4096,而该 Java 进程已经创建了 3500+ 线程。

修复方案:线程池调整为 newFixedThreadPool(200) + 使用 CompletableFuture 异步编排,减少线程创建数。同时调整容器 PID 限制。

6. Requested array size exceeds VM limit

很少见,但一旦出现就是代码 bug。申请了一个超过 Integer.MAX_VALUE - 8 大小的数组。

实战案例:一个导入工具,List<byte[]> 中每个元素存了 CSV 文件的完整内容,文件大小 2GB,直接 byte[] 无法容纳,JVM 抛出此异常。

修复方案:改用流式读取(BufferedReader.readLine()),不一次性加载整个文件。

排查工具速查表

场景工具命令/用法
看堆内存用量jstatjstat -gcutil (pid) 1s
看线程栈jstackjstack (pid)
导出堆快照jmapjmap -dump:format=b,file=heap.hprof (pid)
看类加载器jmap -clstatsjmap -clstats (pid)
看堆外内存pmap / NMTpmap -x (pid) / -XX:NativeMemoryTracking=summary
看 GC 日志GC 日志文件-Xloggc:gc.log -XX:+PrintGCDetails(JDK 8)
看线程数/proc`cat /proc/(pid)/status

系统级防护

生产环境不可能 100% 杜绝 OOM,但可以做到"出了 OOM 不会死人":

  • -XX:+ExitOnOutOfMemoryError:OOM 时 JVM 直接退出,而不是继续运行在错误状态。配合容器健康检查(liveness probe)自动重启
  • -XX:+UseContainerSupport(JDK 10+):自动识别容器内存限制
  • -Xmx 建议设为容器内存的 70-80%,留余量给堆外内存和元空间
  • Heap Dump 自动导出-XX:+HeapDumpOnOutOfMemoryError -XX:HeapDumpPath=/tmp/dump.hprof,OOM 时自动生成快照,方便事后分析
  • Resilience4j MemoryGuard:在业务代码中限制内存分配,超过阈值提前熔断

总结

异常根因排查突破口
StackOverflowError栈帧溢出jstack 看栈深度,检查递归/循环引用
Java heap space堆内存不足/对象泄漏jmap -dump + MAT 看大对象
Metaspace类加载器泄漏jmap -clstats 看 ClassLoader 数量
Direct buffer memory堆外内存泄漏pmap / NMT 看堆外内存
GC overhead limit堆太小/GC 无效GC 日志看 GC 频率和回收效果
Unable to create native thread线程数耗尽/proc/pid/status 看线程数

没有银弹,但有一套可复用的排查流程:先看 GC 日志 → 看堆内存 → 看线程栈 → 看堆快照 → 看堆外内存。按这个顺序走,90% 的内存问题都能找出根因。

参考:《深入理解 Java 虚拟机》周志明(第 3 版) OOM 排查指南:https://docs.oracle.com/javase/8/docs/technotes/guides/troubleshoot/memleaks.html

手撕 → 框架 → 生产化,一步步把 AI Agent 工程化搞透。