4. 栈内存溢出(StackOverflowError)详解

栈内存溢出,也就是 StackOverflowError,算是 JVM 里最常见、也最好定位的一种 OOM 了。我刚开始做 Java 那几年,递归写嗨了没控制住,就经常跟它打照面。说白了,就是线程把栈空间撑爆了,JVM 实在没地方放新的栈帧,就只能抛异常给你看。

你想想看,每次调用一个方法,JVM 就得给它分配一个栈帧。方法调多了,栈帧叠起来就像堆盘子——盘子多了,早晚得塌。那这个“盘子堆”到底能堆多高?怎么调?这就是咱们这节课要聊清楚的事。

核心结论先行:StackOverflowError 本质是线程请求的栈深度超过了 JVM 允许的最大深度。要么是递归没控制住,要么是栈帧太大,再或者就是栈容量本身设小了。

4.1 栈内存的结构与栈帧

JVM 的栈,是每个线程私有的。线程每执行一个方法,就会创建一个栈帧。栈帧里都装了些啥?我画个简图帮大家理解:

  • 局部变量表——方法里的局部变量就存在这儿。注意,如果是对象引用,存的是引用地址,不是对象本身。
  • 操作数栈——字节码指令的“工作台”,加减乘除、入栈出栈都在这里完成。
  • 动态链接——指向运行时常量池中该方法的符号引用,说白了就是找到真正要调用的方法。
  • 方法出口——方法执行完了回到哪儿去,这里是记录返回地址的。

每个栈帧的大小,跟方法里局部变量的个数、操作数栈的深度都有关系。一个栈帧撑死几十 KB 到几百 KB,但也别小看它——递归调用几千次,累积起来很吓人。

我个人的一个习惯:在写递归之前,会先算一下最大递归深度 × 单帧大小,看会不会接近 -Xss 的限额。心里有数,线上才不会慌。

4.2 递归深度——最经典的“坑”

递归是最容易触发 StackOverflowError 的场景,没有之一。我敢说,每个 Java 程序员至少都踩过一次。

来看一段最经典的递归代码:

public class RecursionDemo {
    
    // 递归计算阶乘
    public static long factorial(int n) {
        if (n == 1) {
            return 1;
        }
        return n * factorial(n - 1);
    }

    public static void main(String[] args) {
        // 试试递归深度到多少会挂
        System.out.println(factorial(10000));
    }
}

这段代码跑起来,大概率会看到这样的输出:

Exception in thread "main" java.lang.StackOverflowError
    at RecursionDemo.factorial(RecursionDemo.java:6)
    at RecursionDemo.factorial(RecursionDemo.java:6)
    ... 重复几百行

为什么会这样?因为每次递归调用都要压入一个新栈帧。默认的 -Xss 通常在 1024 KB(1 MB)左右,一个栈帧如果按 16 KB 算,极限递归深度也就 60 多次。n=10000?那肯定越界了。

实际深度跟栈帧大小关系很大。我测过一组数据,大家可以参考:

场景 单栈帧大小(估算) 默认 -Xss 下最大深度
空方法(无局部变量) ~ 2 KB ~ 500 次
简单方法(2-3 个局部变量) ~ 8 KB ~ 120 次
复杂方法(大量局部变量 + 操作数栈深) ~ 32 KB 以上 ~ 30 次

注意:不同 JVM 版本、不同操作系统、不同编译器优化等级,都会影响实际深度。上面的数据只是给大家一个感性认识,千万别当死标准背。

4.3 -Xss 参数:线程栈大小的“开关”

既然默认栈不够大,那就调呗。-Xss 参数就是干这个用的。它的语法很简单:

# 设置栈大小为 256 KB
-Xss256k

# 设置栈大小为 2 MB
-Xss2m

# 设置栈大小为 1024 KB(默认值)
-Xss1024k

但这里有个问题:栈设大了,能支持的线程数就少了。每个线程都要在操作系统层面分配一块独立的内存做栈,32 位系统下进程地址空间总共才 4 GB,你给每个线程分 2 MB,那最多也就 2000 个线程左右。64 位系统虽然地址空间大得多,但物理内存还是有限的。

我去年帮一家公司做过一次性能调优:他们的网关服务要用线程池处理高并发,默认 -Xss 1 MB,结果线程数刚跑到 3000 就开始频繁 Full GC,原因是线程栈占用了太多内存,堆都快被挤没了。后来我把 -Xss 降到 256 KB,线程数直接干到 8000+,吞吐量翻了一倍。当然,前提是确认了每个线程的递归深度不会超过 200 层。

我的建议:-Xss 的值别拍脑袋定,先做一次压力测试,找到你业务场景下的最大递归深度,然后留 30%-50% 的余量,再定具体的值。

4.4 避坑指南与最佳实践

这些东西是我这些年一点点攒出来的经验,踩过的坑不少,分享给大家。

4.4.1 能用迭代就别用递归

大多数递归都能改写成迭代。比如上面的阶乘,用循环写就完全没栈溢出的风险:

public static long factorialIter(int n) {
    long result = 1;
    for (int i = 2; i <= n; i++) {
        result *= i;
    }
    return result;
}

递归写得爽,维护两行泪。除非是树遍历、回溯这类天然适合递归的场景,否则优先选迭代。

4.4.2 尾递归优化?Java 不支持!

有些同学会说:“我用尾递归不就行了?”可惜,Java 目前没有做尾递归消除优化。Scala、Kotlin 等 JVM 语言支持,但 Java 原生的编译器不会把尾递归优化成迭代。所以别指望这个。

4.4.3 限制递归深度

如果你非要用递归,那一定要加深度限制。比如:

public static long safeFactorial(int n) {
    if (n > 200) {
        throw new IllegalArgumentException("递归深度超过安全限制");
    }
    // ... 递归逻辑
}

这种做法虽然保守,但至少不会让程序在线上莫名其妙地挂掉。我曾经在一个定时任务里忘了加这个限制,结果半夜被报警吵醒——那滋味,一次就够了。

4.4.4 用 -Xss 调大栈要谨慎

  • 注意线程数:栈越大,能启动的线程越少。高并发场景下,宁可把栈设小一点,也别让线程数受限。
  • 注意 32 位 vs 64 位:32 位系统下进程地址空间有限,栈设大了很容易 OOM(是进程级别的,不是堆 OOM)。64 位系统相对宽松,但物理内存依然是硬约束。
  • 注意 Docker 容器:容器里的 -Xss 要基于容器的内存限制来算,别按宿主机内存来拍脑袋。

我曾经犯过一个错:把 -Xss 设成了 512 KB,觉得够小了,结果没考虑到业务代码里有一个深度达 800 的递归调用,上线直接 StackOverflowError。从那以后,我养成了一个习惯——每次修改 -Xss,都会用 Arthas 或者 JMH 跑一次压力测试,确认最大递归深度在安全范围内。

栈内存溢出(StackOverflowError)知识体系 StackOverflowError 递归深度过大 栈帧体积过大 -Xss 设置过小 递归转迭代 / 限深度 优化局部变量 / 减少帧大小 增大 -Xss / 压测定值 结合业务场景,压测确定安全递归深度 迭代优先 · 限深度 · 合理设 -Xss 避坑口诀 递归不加限,栈满两行泪;迭代能解决,别偷懒用递归;-Xss 调优要压测,线程数也需算清楚。 关键参数:-Xss256k / -Xss512k / -Xss1m / -Xss2m 常用排查工具:Arthas · jstack · JVisualVM · JMH 压测

4.5 总结一下

栈内存溢出这件事,说复杂也复杂,说简单也简单。我把核心要点再拎一遍:

  • StackOverflowError 的根源:递归深度 > 栈容量 ÷ 单帧大小。三个变量,哪个都能爆。
  • 调 -Xss 不是万能的:栈设大了,线程数就少了。高并发场景下,优先优化代码而不是加栈。
  • 迭代优先,递归限深:能改迭代的别犹豫,非要递归就加深度限制,别裸奔。
  • 压测是唯一真理:别猜,跑一次压力测试,所有数据都出来了。

最后送大家一句话:栈溢出不是一个 bug,它是一个信号——提醒你该审视代码的调用了。学会跟 StackOverflowError 打交道,是每个 Java 工程师的必修课。