栈内存溢出:递归深度、线程栈

栈溢出这词,做 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,问题再没出现过。

排查栈溢出的工具和姿势

  1. 看异常栈:StackOverflowError 的堆栈里能直接看到重复的调用行号,定位递归路径。
  2. jstack:生产环境用 jstack <pid> 看线程状态,能发现大量线程处于 RUNNABLE 且栈很深。
  3. jvisualvm / JMC:图形化看线程栈深度,直观。
  4. 核心转储分析:如果应用被 kill 掉,生成 .hprof 或者 core 文件,用 MAT 或 gdb 分析。

一个避坑指南:我曾经为了“快速解决问题”,直接把 -Xss 调成 10MB,以为万事大吉。结果几周后,服务出现频繁的 Full GC,因为栈内存占用了太多系统内存,导致堆空间被挤压。更合理的做法是:先查递归逻辑,用尾递归优化或者改成迭代;如果必须保留递归,再适当调大 -Xss,但一定要算好总内存预算。

SVG:栈溢出根因与排查流程

栈溢出根因与排查流程 递归调用过深 每层压栈 ➔ 栈帧消耗 线程栈数量过大 线程数 × 栈大小 是否超过 -Xss 限制? StackOverflowError 排查:查看异常栈 + 分析递归 改为迭代 / 尾递归 / 增大 -Xss 排查:线程转储 + 控制线程数 合理线程池参数 / 调整栈大小 优先优化逻辑,再考虑调整 -Xss
注意:不要在生产环境轻易调大 -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 压力小了很多。内存管理这件事,全局平衡比局部调优更重要。