深蓝黑背景封面:左侧示意图——一个竖直的虚拟机栈外框,栈帧自下而上依次为 main、f(1)、f(2)、f(3)、f(4)、f(5),顶部一条紫色虚线标注「栈容量(-Xss)」,最上面的递归帧顶穿容量线并以青色高亮加感叹号警示;栈右侧两张卡片对比两种异常:StackOverflowError(栈容量固定,递归深度超过容量)与 OutOfMemoryError(规范允许动态扩展,但扩展时申请不到内存)。右侧大标题「栈也会内存溢出?」,副标题「StackOverflowError 和 OOM 不是一回事」;左上标注「面试经典 · Java 后端」,右上圆角胶囊标注「Day 08 · 11-15」 图 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,两个函数的极限深度都成倍增长——溢出时机由「深度 × 帧大小」与栈容量的比值决定,而不是一个固定的递归层数。

深蓝黑背景,分三块。左上卡片「一个栈帧里有什么」:一个竖直栈帧被切成四层——局部变量表、操作数栈、动态链接、返回地址,旁边标注「每调用一个方法压入一帧,返回时弹出;栈帧越大,同样 -Xss 下可容纳的递归深度越浅」。右上大卡「一个栈,两种溢出路径」:两条横向流程——路径一(青色):调用链加深 → 请求深度超过栈容量 → StackOverflowError,注「栈容量固定,HotSpot 默认形态」;路径二(紫色):栈允许动态扩展 → 申请新栈空间失败 → OutOfMemoryError,注「规范允许的可变容量形态;线程建得太多耗尽内存也会触发」。底部卡片「-Xss 是深度与线程数的跷跷板」:左侧一个跷跷板图形,一端标「单线程递归深度」向上,另一端标「可创建线程数」向下;右侧文字:-Xss 调大则单线程递归更深,但每个线程占的栈内存更多,同样内存下能建的线程更少;深度递归(表达式树、嵌套 JSON)优先改迭代或显式栈。最底部总结条:判题口径——容量到顶是 StackOverflowError,扩容失败才是 OutOfMemoryError 图 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 要算线程数这笔账。你在线上遇到过栈溢出吗?当时是真写错了递归,还是合法调用链太深?欢迎聊聊你是怎么定位和收口的。