OOM 与 StackOverflow 的常见原因与案例
当 Java 说"OutOfMemoryError"时,你该看哪里?
Java 开发者迟早会遇到一个场景:应用变慢、接口超时、日志突然不写了,然后控制台或者告警系统抛出一个 OutOfMemoryError 或 StackOverflowError。这两个异常是 JVM 最典型的"内存告警",但它们的成因、排查思路、解决手段完全不同。新手容易混为一谈,理解背后原理才能快速定位。
问题:StackOverflowError 是怎么产生的?
先看一段代码:
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 Cache 的 maximumSize + 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()),不一次性加载整个文件。
排查工具速查表
| 场景 | 工具 | 命令/用法 |
|---|---|---|
| 看堆内存用量 | jstat | jstat -gcutil (pid) 1s |
| 看线程栈 | jstack | jstack (pid) |
| 导出堆快照 | jmap | jmap -dump:format=b,file=heap.hprof (pid) |
| 看类加载器 | jmap -clstats | jmap -clstats (pid) |
| 看堆外内存 | pmap / NMT | pmap -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