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 跑一次压力测试,确认最大递归深度在安全范围内。
4.5 总结一下
栈内存溢出这件事,说复杂也复杂,说简单也简单。我把核心要点再拎一遍:
- StackOverflowError 的根源:递归深度 > 栈容量 ÷ 单帧大小。三个变量,哪个都能爆。
- 调 -Xss 不是万能的:栈设大了,线程数就少了。高并发场景下,优先优化代码而不是加栈。
- 迭代优先,递归限深:能改迭代的别犹豫,非要递归就加深度限制,别裸奔。
- 压测是唯一真理:别猜,跑一次压力测试,所有数据都出来了。
最后送大家一句话:栈溢出不是一个 bug,它是一个信号——提醒你该审视代码的调用了。学会跟 StackOverflowError 打交道,是每个 Java 工程师的必修课。