栈内存溢出:递归深度、线程栈
栈溢出这词,做 Java 的应该都不陌生。报错长这样:java.lang.StackOverflowError。每次出现,十有八九是递归没写好,或者线程开太多。今天咱们就把这俩根源掰开揉碎了讲清楚。
递归调用:深度不是你想加就能加
我先给个最朴素的代码——无限递归。
public class RecursiveOverflow {
public static void recurse() {
recurse();
}
public static void main(String[] args) {
recurse();
}
}
运行一下,瞬间就报栈溢出了。为什么会这样?
每个方法调用,JVM 都会在栈上分配一块内存叫栈帧。局部变量、操作数栈、方法返回地址都存在里面。递归一次,就压入一个栈帧。栈的总容量是有限的,压满了,自然就溢出了。
我个人习惯,写递归时必问一个问题:最深的调用层级是多少?我在项目中遇到过一段 N 叉树的深度遍历,业务数据嵌套十几层,本地测试好好的,一上生产环境就崩。后来才发现,那棵树的深度能跑到三千多层——默认的线程栈根本扛不住。
影响递归深度的核心因素
| 因素 | 说明 |
|---|---|
| JVM 栈总容量 | 由 -Xss 控制,Linux 默认一般是 1MB,Windows 可能是 512KB |
| 单个栈帧大小 | 方法参数、局部变量、返回地址越多,帧越大 |
| 方法调用嵌套层数 | 递归或深层方法调用导致栈帧连续入栈 |
| JVM 实现差异 | HotSpot 与 GraalVM 的栈布局略有不同 |
-Xss256k 起了一个 JVM,统计单纯空方法递归的最大深度——结果只有 2000 多次。换成 -Xss2m,深度直接跳到 15000 以上。所以调大栈空间确实立竿见影,但这只是治标。
线程栈:一个线程一个栈
Java 是多线程语言。每个线程都有自己的虚拟机栈。栈的总大小是 线程数 × 每个线程栈大小。如果你启动 1000 个线程,每个栈 1MB,光栈就吃掉 1GB 内存。要是物理机内存只有 2GB,那剩下的堆、元空间、JVM 自身开销……你想想看,不溢出才怪。
我印象最深的一次线上事故:凌晨三点告警,服务挂掉了。dump 看线程数 4000+,每个线程栈都是 1MB。实际上那个业务场景根本不需要这么多线程,是因为调用链路里错误使用了线程池,没有控制最大线程数,被突发流量冲爆了。后来把线程池核心线程数压到 200,最大 500,问题再没出现过。
排查栈溢出的工具和姿势
- 看异常栈:
StackOverflowError的堆栈里能直接看到重复的调用行号,定位递归路径。 - jstack:生产环境用
jstack <pid>看线程状态,能发现大量线程处于 RUNNABLE 且栈很深。 - jvisualvm / JMC:图形化看线程栈深度,直观。
- 核心转储分析:如果应用被 kill 掉,生成 .hprof 或者 core 文件,用 MAT 或 gdb 分析。
一个避坑指南:我曾经为了“快速解决问题”,直接把 -Xss 调成 10MB,以为万事大吉。结果几周后,服务出现频繁的 Full GC,因为栈内存占用了太多系统内存,导致堆空间被挤压。更合理的做法是:先查递归逻辑,用尾递归优化或者改成迭代;如果必须保留递归,再适当调大 -Xss,但一定要算好总内存预算。
SVG:栈溢出根因与排查流程
-Xss。你调大一个线程的栈,实际上是给每个线程都分配了更多内存。假设你有 500 个线程,从 1MB 调到 2MB,直接就多占用 500MB。内存压力大了,堆空间被挤压,更容易出现 OutOfMemoryError。我亲眼见过有人把栈调到 8MB,然后堆只有 512MB……结果呢?堆内存先爆了。
常见问题与对照表
| 症状 | 可能原因 | 推荐操作 |
|---|---|---|
| 递归报 StackOverflowError | 递归深度过大,超出 -Xss | 改迭代或用尾递归;若数据深度固定,可适度调大 -Xss |
| 大量线程同时抛出 StackOverflowError | 每个线程栈分配过大,或线程数太多 | 减少线程数,或调小每个栈的大小(比如 256KB) |
| 异常栈里没有重复调用,但栈很深 | 可能是 A→B→C→……长链调用,不是递归但层数很多 | 静态分析调用链,看是否有不必要的深度对象组合 |
| 进程突然消失,无任何错误日志 | 内存不足,OS 强制杀掉进程(OOM Killer) | 结合 dmesg 或 /var/log/messages 看,通常伴随栈溢出导致的系统内存不足 |
个人总结几条经验
- 能用迭代就别写递归。递归代码虽然漂亮,但深度不可控就是炸弹。
- 如果非用递归不可,给递归方法加一个 深度计数器,超过阈值就抛自定义异常,比等到 JVM 崩溃好诊断。
- 修改
-Xss之前,先估算线程数峰值,算一下总内存,留足余量。 - 监控线程数:用
jstack或 JMX 的ThreadMXBean实时看活跃线程数量,超过阈值就告警。 - 我曾经在一次性能调优里,把线程池的核心线程数从 50 改成 10,又把
-Xss从 1MB 改成 512KB,单机 QPS 反而涨了 30%,因为 GC 压力小了很多。内存管理这件事,全局平衡比局部调优更重要。