图 1:本文封面,递归帧顶穿栈容量线的一刻。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。「虚拟机栈什么情况下抛 StackOverflowError,什么情况下抛 OutOfMemoryError」是 Java 面试里区分度很高的一题:很多人背过「递归太深会栈溢出」,却说不出栈和 OOM 有什么关系。
先把栈本身说清楚:一个线程一条栈,一次调用一帧。
JVM 规范第 2.5 节把运行时数据区分成线程私有与线程共享两类,虚拟机栈属于前者:每条线程创建时就带上自己的一条虚拟机栈,互不相干。线程每调用一个方法,就往栈里压入一个栈帧;方法返回,帧弹出。一个栈帧里装着局部变量表、操作数栈、动态链接和返回地址——方法「运行时的工作台」都在这一帧里。
所以栈的用量由两个数决定:调用链有多深(帧数),以及每帧有多大(局部变量越多、中间表达式越复杂,帧越大)。
规范写得很明白:同一个区域,两条溢出路径。
JVM 规范第 2.5.2 节给虚拟机栈列了两个异常条件。第一,如果线程的计算需要的栈深度超过允许值,抛出 StackOverflowError。第二,规范允许栈动态扩展,如果扩展时申请不到足够内存,或者创建新线程时连初始栈都分配不出来,抛出 OutOfMemoryError。
翻译成大白话:StackOverflowError 是「容量到顶了」——栈深度撞上了上限;OutOfMemoryError 是「想扩容但没内存了」。HotSpot 的栈容量是固定的,所以日常见到的几乎全是第一种:无限递归,或者合法但过深的递归,深度超过 -Xss 给定的栈容量,直接抛 StackOverflowError。第二种更多出现在另一个场景:疯狂创建线程,每条线程都要占一份栈内存,内存耗尽时抛的是 OOM 而不是 SOE。
跑一段代码,看深度和帧大小的关系。
下面这个程序用两种方式递归到溢出:deep() 几乎不带局部变量,fat() 带八个 long 参数把栈帧撑大。用 JDK 21 编译运行(javac StackDepth.java && java -Xss1m StackDepth),并用三种 -Xss 各跑一遍:
public class StackDepth {
static int depth = 0;
static void deep() { // 几乎不占栈帧的递归
depth++;
deep();
}
static void fat(long a, long b, long c, long d,
long e, long f, long g, long h) { // 局部变量撑大栈帧
depth++;
fat(a + 1, b, c, d, e, f, g, h);
}
public static void main(String[] args) {
try {
deep();
} catch (StackOverflowError err) {
System.out.println("deep() -> StackOverflowError, depth = " + depth);
}
depth = 0;
try {
fat(0, 0, 0, 0, 0, 0, 0, 0);
} catch (StackOverflowError err) {
System.out.println("fat() -> StackOverflowError, depth = " + depth);
}
}
}
实测输出(本机 JDK 21):
== -Xss512k ==
deep() -> StackOverflowError, depth = 6361
fat() -> StackOverflowError, depth = 1803
== -Xss1m ==
deep() -> StackOverflowError, depth = 19708
fat() -> StackOverflowError, depth = 5285
== -Xss4m ==
deep() -> StackOverflowError, depth = 96484
fat() -> StackOverflowError, depth = 25169
两组数字各说明一件事。横向看:同样 1m 的栈,deep() 能递归近两万层,fat() 只有五千多层——栈帧越大,同样容量装下的深度越浅。纵向看:-Xss 从 512k 调到 4m,两个函数的极限深度都成倍增长——溢出时机由「深度 × 帧大小」与栈容量的比值决定,而不是一个固定的递归层数。
图 2:栈帧结构、两条溢出路径与 -Xss 的跷跷板效应。
顺带一个常被追问的点:catch 住 SOE 之后呢。
注意 StackOverflowError 继承的是 Error 而不是 Exception,语义上属于「不该靠 catch 恢复」的严重问题。上面的 demo 能 catch 住并继续打印,是因为溢出瞬间最顶层的递归帧已经全部弹出,栈重新有了余量,打印一条消息绰绰有余。但这绝不意味着可以把 catch SOE 当成递归的终止条件来用:你无法保证下一次操作需要的栈空间恰好够用,靠异常控制流程既脆弱又不可移植。正确姿势始终是事前控制深度——限制递归层数、改迭代,或者按数据规模预估后调 -Xss。
-Xss 是一个跷跷板,不是免费的午餐。
把 -Xss 调大确实能让单线程递归更深,但代价是每条线程占的栈内存更多:同样 1G 可用内存,-Xss1m 理论上能容纳约一千条线程的栈,-Xss4m 就只剩两百多条。深度和线程数,此消彼长。所以面对深度递归——表达式树求值、嵌套 JSON 解析、深层目录遍历——工程上的正解通常是改写成迭代加显式栈(自己用堆上的 ArrayDeque 当栈),而不是一味调大 -Xss。堆可以配得很大,栈的每一分都要按线程数乘回来。
回到面试现场,建议的答题结构:先答出两条路径的规范口径——容量超限抛 StackOverflowError,动态扩展或建线程申请不到内存抛 OutOfMemoryError;再补栈帧 × 深度 × -Xss 的定量关系,最好用上面的实测数字举例;最后落一句工程建议:深递归优先改迭代,调 -Xss 要算线程数这笔账。你在线上遇到过栈溢出吗?当时是真写错了递归,还是合法调用链太深?欢迎聊聊你是怎么定位和收口的。