跳到主要内容
约 9 分钟3 张卡

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 spaceGC 日志、直方图、堆转储
Metaspace加载器统计、类卸载趋势
Direct buffer memoryBufferPool、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 后的进程状态​

9.3 显式 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 应先保留首次故障信息,再按资源类别采集证据。

定位过程可以围绕分类、采集与归因展开:

  1. 保存完整错误、首次时间、启动参数和容器事件,并关联同时段指标。
  2. 堆问题检查 GC 后存活占用、分配率、直方图和堆转储。元空间问题则检查类加载器、类数量和卸载趋势。
  3. 直接内存问题结合 BufferPool、NMT 和系统映射,线程创建失败则检查线程数、栈预算和系统配额。
  4. 根据趋势区分意外持有、容量确实不足和短时分配峰值,再决定处理方式。

自动采集可提前配置 -XX:+HeapDumpOnOutOfMemoryError,并用 -XX:HeapDumpPath 指定可写目录。生产采集还要预估停顿和磁盘成本,避免在失稳进程上连续导出大堆。

10.3 堆未达到 Xmx,进程为何被杀死且没有 heap dump?​

出现公司:暂无已核验的公开来源(本站自测)

考察重点:解释容器内存为何会先于堆达到上限,以及 SIGKILL 为什么会阻止 JVM 自动转储。

相关内容:操作系统 OOM Kill。

参考回答

容器总内存还包含线程栈和其他本地内存,因此可以在堆达到 -Xmx 前触及限额。

如果内核因此执行 OOM Kill,就会发送 SIGKILL 直接终止进程,JVM 来不及执行自动堆转储。

定位时应核对内核或容器 OOM 事件,再比较进程内存与各类内存占用的趋势。退出码 137 也可来自其他来源的 SIGKILL,单靠退出码不足以确认是内存不足。

11. 参考资料​