OutOfMemoryError 与 StackOverflowError
OutOfMemoryError 表示一次资源申请失败,StackOverflowError 表示线程调用栈深度超限。本文先按错误消息区分资源,再说明各类故障的原因、排查证据与恢复边界。分析时应保留完整错误消息,因为不同资源不足需要不同的处理方式。
1. 面试核心
先记住这几条,再读正文- 堆与元数据耗尽。
Java heap space指堆分配失败,Metaspace指类元数据分配失败。卡 · Java heap space - 线程资源耗尽。
unable to create native thread指平台线程资源申请失败。 - 调用栈超限。
StackOverflowError表示调用栈深度超限,常见于无终止递归和toString、equals的循环调用。 - 自动保留现场。
-XX:+HeapDumpOnOutOfMemoryError可在 HotSpot 支持的 OOM 路径导出堆。卡 · 按错误类型选择证据 - 进程被内核终止。 操作系统 OOM Kill 可直接终止进程,JVM 来不及生成堆转储。卡 · 操作系统 OOM Kill
2. Java heap space
Java heap space 表示 Java 堆无法满足对象分配。例如,下面的列表不断保留新数组,导致它们始终无法被回收:
List<byte[]> retained = new ArrayList<>();
while (true) {
retained.add(new byte[1024 * 1024]);
}
每次循环都创建一个 1 MiB 数组,并把它加入列表。列表持续强引用这些数组,堆耗尽后就会抛出 OOM。实际业务中,需要检查以下四类原因:
- 无界缓存、队列或批次持续持有对象,占用随时间增长。
- 单次请求尝试创建过大的数组或对象图。
- 业务正常运行所需的存活对象已经超过堆容量。
- 短时间分配过多对象,收集器来不及提供足够空间。
堆 OOM 本身不足以证明泄漏。若对象都仍有业务用途,需要考虑容量或处理规模;若稳定负载下多次 GC 后占用仍增长,则要检查无用对象被谁持有。
如何结合对象生命周期区分泄漏、容量不足与分配峰值,见 堆转储。
Requested array size exceeds VM limit 则表示请求的数组超过 JVM 实现上限。这时需要缩小数组或拆分处理,增大堆无法解除数组长度限制。
3. GC overhead limit exceeded
这条消息表示进程把大量时间花在 GC 上,却只能释放极少空间。应用因此难以继续完成业务工作,HotSpot 会触发进展不足保护;它描述的是回收效果,并非独立内存区域。
以 Java 17、21 的 Parallel 默认策略为例,GC 时间超过约 98%,回收空间低于约 2%。持续满足条件后就会触发保护。这组比例需要结合收集器和版本解释。
定位时,应同时观察回收效果与业务进展:
- Full GC 或老年代回收后,还有多少存活对象。
- 对象分配和晋升到老年代的速率有多高。
- GC 消耗多少 CPU,应用吞吐量是否持续下降。
- 堆容量是否过小,或是否存在大批对象滞留。
-XX:-UseGCOverheadLimit 可以关闭保护,但会让进程继续停留在大量回收、很少推进的状态。真正需要处理的是存活对象规模和可用空间之间的矛盾。
4. 元空间与压缩类空间
Metaspace 和 Compressed class space 都与类元数据分配有关。元空间保存类元数据,启用压缩类指针时,类元数据中的一部分放在压缩类空间中。
排查时,重点检查以下三类来源:
- 程序是否动态生成了大量类。
- 热部署是否反复创建类加载器,而旧加载器仍被线程或静态注册表持有。
- 空间上限是否与正常业务需要加载的类规模匹配。
-XX:MaxMetaspaceSize 管理元空间上限,-XX:CompressedClassSpaceSize 配置压缩类空间大小。可以用下面的命令查看类加载器和元空间:
jcmd <pid> VM.classloader_stats
jcmd <pid> VM.metaspace
先比较加载器数量,以及每个加载器定义的类数量,再观察类是否能卸载。旧加载器本应释放却持续存活时,就要沿引用链检查是谁持有它。单纯提高容量会推迟报错,却留下加载器滞留问题;如果数量符合业务预期,则应重新评估空间配置。
5. 直接内存与本地分配失败
Java 堆之外的内存不足,也会导致分配失败。直接缓冲区受到限制时,可以抛出 Direct buffer memory。应检查缓冲区规模与释放时机:
- 缓冲区池没有容量上限,持续保留直接内存。
- 单次申请的缓冲区过大。
- 已无用途的缓冲区释放滞后,来不及满足新申请。
其他本地内存申请失败时,错误信息还可能包含 Out of swap space 或 Native method。本地代码也会因内存问题崩溃,并留下 hs_err_pid 错误日志。这里的本地内存指 JVM 或 native 库在 Java 堆之外申请的内存,所以此时堆转储仍可很小。
排查需要组合不同范围的内存证据:
- BufferPool 指标显示直接缓冲区池的数量与占用。
- NMT(Native Memory Tracking)追踪 HotSpot 管理的本地内存。
- 操作系统内存映射和 native profiler 帮助定位第三方本地库的分配。
各工具的覆盖范围见 Java 直接内存。
6. 平台线程创建失败
unable to create native thread 表示平台线程所需的本地资源申请失败。平台线程需要本地线程结构和栈空间,因此要检查以下资源限制:
- 应用无限创建线程,或大量线程长期阻塞,迟迟无法结束。
- 栈配置过大,使地址空间或可用内存不足。
- 操作系统对用户或进程设置的线程数配额已用尽。
- 容器的 PID 数量或内存限制已达到上限。
可以先用线程转储查看线程数量和调用栈:
jcmd <pid> Thread.print -l
随后把线程增长趋势与操作系统限制、容器事件对应起来。-Xss 影响平台线程栈容量,减小它可以降低每个线程的栈预算,但也会降低可承受的调用深度。
修复还要控制线程生命周期和并发数量。任务积压时,应限制接收速度或设置拒绝策略,让上游感知处理能力已满。这种做法称为背压,可以避免持续堆积任务并催生更多线程。
7. StackOverflowError
每次方法调用都需要栈帧,用来保存局部变量和方法执行信息。调用链不断加深时,会超过线程栈能够容纳的范围。例如:
static void recurse() {
recurse();
}
这段代码没有递归终止条件,会不断增加栈帧,最终抛出 StackOverflowError。排查时还要考虑以下调用链来源:
toString、equals等方法相互调用,形成循环。- 异常处理过程中再次递归,反复进入同一处理路径。
- 算法递归深度随输入增长,超出当前栈容量。
- 栈配置过小,正常递归也无法完成。
定位时先看重复栈帧,找到调用环或递归入口。无终止递归应修正终止条件;深度随输入增长的算法,可以改为迭代和显式栈。只有最大深度可控、内存预算也允许时,才适合通过增大 -Xss 容纳调用链。
8. 按错误类型选择证据
错误消息确定了资源类别,下一步再选择对应证据。堆转储主要用于对象与引用分析,其他资源需要相应的线程、类加载或操作系统证据。
| 错误或现象 | 主要证据 |
|---|---|
Java heap space | GC 日志、直方图、堆转储 |
Metaspace | 加载器统计、类卸载趋势 |
Direct buffer memory | BufferPool、NMT 与系统内存 |
unable to create native thread | 线程数、栈预算、OS 与 PID 配额 |
StackOverflowError | 重复调用栈帧 |
| 操作系统 OOM Kill | 内核或容器事件、进程内存趋势 |
采集前,先保存首次故障信息:
- 完整错误消息与首次发生时间。
- JVM 启动参数和容器资源限制。
- 同一时间窗口的负载、GC、线程数和进程内存指标。
元空间问题还可结合 NMT 看类元数据占用,操作系统 OOM Kill 则要比较进程驻留内存(RSS)与限额。RSS 表示进程当前驻留在物理内存中的页所占的空间。
需要在 OOM 时自动留下堆转储,可以提前配置:
java -XX:+HeapDumpOnOutOfMemoryError \
-XX:HeapDumpPath=/safe/path -jar app.jar
上线前应在受控环境验证路径可写,并按堆规模预留磁盘。堆转储需要遍历对象和写文件,会带来额外停顿,所以生产采集要评估业务能否承受。进程已经失稳时,反复触发 Full GC 或连续导出大堆会进一步拖慢服务。
这个开关覆盖部分 HotSpot OOM 路径。应用手动抛出的 OOM、线程创建失败等情况不保证自动产出堆转储,需要保留其他证据。
8.1 操作系统 OOM Kill
进程直接消失时,需要区分 JVM 抛出 OOM 与操作系统杀死进程。容器内存计入 Java 堆,也包含线程栈和其他本地内存。所以,即使堆未到 -Xmx,总占用仍会先触及容器限额,内核此时可直接终止进程。
OOM Kill 使用 SIGKILL,这是一种让进程立即终止的信号,JVM 来不及执行清理。预先配置的堆转储和 shutdown hook(关闭钩子)也无法在这个阶段运行。
退出码 137 通常表示进程被 SIGKILL 终止,但信号也能来自管理员或其他程序。确认是否由内存不足引起,需要核对内核日志或容器 OOM 事件。
9. 常见问题
9.1 泄漏与溢出
泄漏是程序仍在持有已经无用的对象或资源,溢出则是一次资源申请失败。泄漏持续积累可以导致溢出,正常业务需要的对象超过容量时也会溢出。因此,需要结合对象生命周期和容量判断原因。
9.2 OOM 后的进程状态
OOM 不一定使整个进程退出。OutOfMemoryError 属于 Error,可以被捕获,未捕获时也可以只终止触发它的线程。其他线程虽然还在运行,错误处理代码却仍可能因资源紧张而再次分配失败。
关键服务应让外部健康检查判断实例是否还能工作,再由监督机制安排重启。在进程退出前,尽量保留采集成本较低的错误信息和指标。
9.3 显式 GC 与容量
System.gc() 只是向 JVM 提交 GC 请求,JVM 可以忽略它。仍被强引用持有的对象会继续存活,因此它无法消除意外持有,也无法扩大容量。频繁请求 GC 还会增加停顿,处理时应分别修正资源释放责任和容量配置。
10 · 面试题卡片
10.1 StackOverflowError 和不同 OOM 分别怎样产生
出现公司:腾讯
考察重点:区分调用栈深度超限与各类资源申请失败,并根据完整错误消息选择证据。
相关内容:Java heap space、StackOverflowError。
参考回答
StackOverflowError 来自调用栈深度超限,OOM 则来自某类资源无法满足申请。
无终止递归和循环调用会不断增加栈帧,从而耗尽可用栈空间。
OOM 的完整消息决定了应检查的资源类别:
Java heap space表示堆无法分配对象,检查 GC 趋势、对象直方图和堆转储。Metaspace表示类元数据分配失败,检查类加载器和类卸载情况。Direct buffer memory表示直接缓冲区受到限制,结合 BufferPool、NMT 和系统内存分析。unable to create native thread表示平台线程创建失败,检查线程数量、栈预算和系统配额。
堆 OOM 还需要区分无用对象滞留、正常容量不足和短时分配峰值,才能确定修复方式。
10.2 线上发生 OOM 应该怎样定位
出现公司:高德
考察重点:说明如何按错误分类选择证据,区分泄漏与容量问题,并评估采集造成的停顿和磁盘占用。
相关内容:按错误类型选择证据。
参考回答
线上 OOM 应先保留首次故障信息,再按资源类别采集证据。
定位过程可以围绕分类、采集与归因展开:
- 保存完整错误、首次时间、启动参数和容器事件,并关联同时段指标。
- 堆问题检查 GC 后存活占用、分配率、直方图和堆转储。元空间问题则检查类加载器、类数量和卸载趋势。
- 直接内存问题结合 BufferPool、NMT 和系统映射,线程创建失败则检查线程数、栈预算和系统配额。
- 根据趋势区分意外持有、容量确实不足和短时分配峰值,再决定处理方式。
自动采集可提前配置 -XX:+HeapDumpOnOutOfMemoryError,并用 -XX:HeapDumpPath 指定可写目录。生产采集还要预估停顿和磁盘成本,避免在失稳进程上连续导出大堆。
10.3 堆未达到 Xmx,进程为何被杀死且没有 heap dump?
出现公司:暂无已核验的公开来源(本站自测)
考察重点:解释容器内存为何会先于堆达到上限,以及 SIGKILL 为什么会阻止 JVM 自动转储。
相关内容:操作系统 OOM Kill。
参考回答
容器总内存还包含线程栈和其他本地内存,因此可以在堆达到 -Xmx 前触及限额。
如果内核因此执行 OOM Kill,就会发送 SIGKILL 直接终止进程,JVM 来不及执行自动堆转储。
定位时应核对内核或容器 OOM 事件,再比较进程内存与各类内存占用的趋势。退出码 137 也可来自其他来源的 SIGKILL,单靠退出码不足以确认是内存不足。