DevFix
Java已验证

Java 栈溢出排查:StackOverflowError 的定位与解决

@debug_master更新于 3 天前阅读 6 min0

一句话先说结论:StackOverflowError 不是内存不够,是方法调用一层套一层,栈帧把线程栈顶穿了。最常见的根因是递归没有真出口,或者出口条件写错。先闷头看递归出口,能改循环就改循环,-Xss 调大只是应急,治不了无限递归。

背景

每个线程启动时,JVM 给它划一块固定大小的栈内存。方法每被调用一次,就在栈上压一个栈帧,帧里放局部变量、返回地址、操作数。方法返回了,帧才弹掉。

普通程序调用链就三四层,这点栈绰绰有余。递归不一样,每深入一层就多一个帧,深度上去了,栈迟早见底。

栈的默认大小各平台不一样:Linux 和 macOS 主线程约 8 MB,Windows 约 1 MB。这个上限写死,不会像堆一样自动扩容。调用深度一超,JVM 直接抛 StackOverflowError

现象

报错原文最显眼的特征是:同一行反复出现。

Exception in thread "main" java.lang.StackOverflowError
    at com.example.Factorial.factorial(Factorial.java:7)
    at com.example.Factorial.factorial(Factorial.java:7)
    at com.example.Factorial.factorial(Factorial.java:7)
    ...

堆栈里同一个方法压了一长串,把「罪魁祸首」直接写在脸上。看最后几行就知道是哪个方法在疯狂调用自己。

触发条件很简单,一个没有出口的递归就够:

public static int factorial(int n) {
    return n * factorial(n - 1); // 永远到不了出口
}

factorial(5),帧一直压,压到栈底就崩。这是最典型的一类,另一类更隐蔽,下面讲根因时细说。

根因分析

StackOverflowError 的根因都能归结成一句话:递归没有真正往下走,或者压根不终止。拆开看有四种常见形态。

第一,递归出口写错或者没写。前面 factorial 就是,没有 if (n <= 1) return 1 这一层兜底。

第二,数据结构里藏了循环引用。两个对象的 toString() 互相调用,或者 A 的某字段指向 BB 又指向 A,序列化或打印时死循环套娃:

class A {
    B b;
    public String toString() { return String.valueOf(b); }
}
class B {
    A a;
    public String toString() { return String.valueOf(a); }
}

第三,JSON 序列化撞上循环引用,jacksonfastjson 这类库递归遍历对象图时爆栈。

第四,代理链写错。Spring AOP 里一个方法切到了自己,或者在 equals / hashCode 里又间接调了自己,形成自调用环。

一句话记住:栈溢出的本质是「调用深度失控」,不是「数据量大」。

解决方案

先修递归出口,这是根

看到 StackOverflowError,第一件事不是调参数,是按住报错堆栈最后几行,找到那个反复出现的方法,检查它到底能不能停。

递归出口必须满足两条:能命中,且每次调用都朝出口靠近。

public static int factorial(int n) {
    if (n <= 1) {
        return 1; // 出口,朝它收敛
    }
    return n * factorial(n - 1);
}

出口条件写错是最常见的坑。比如传进去的参数是对象,每次调用又新建一个没衰减的对象,深度永远不减。

改循环,最稳

递归能改循环就改循环。用显式的栈结构替代调用栈,深度不再受线程栈限制:

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

树的遍历这类递归,改成 Dequewhile 循环,一样能做到不依赖调用栈。

处理循环引用:让序列化「看得见」环

序列化爆栈,先排查对象图里有没有环。Jackson 加 @JsonIdentityInfo@JsonBackReference 打破环,fastjson 也有对应的 serializerFeature 处理循环引用。根治办法是设计上别让 model 互相持有。

-Xss 调大:只应急,不治本

线上确实需要深递归、又来不及改代码时,可以把线程栈调大:

-Xss2m

注意两点:一,它只对深度有限、确实合法的递归有效;二,无限递归调多大都会崩,反而因为每线程栈变大,多线程场景更容易把物理内存吃光。

思考过程

最有迷惑性的一点是:StackOverflowErrorError,不是 Exception。用 try-catch (Exception e) 是接不住它的,别指望靠捕获来「兜住」爆栈。它的设计语义就是「这个错不该由业务代码处理」。

还有一个常见误判:把栈溢出当成堆溢出。看 detail message 就行——StackOverflowError 不带 OutOfMemoryError 那串堆空间描述。两者名字里都有「溢出」,但一个说的是调用栈,一个说的是堆内存。

定位方法论一句话:让崩溃现场说话,报错堆栈里重复出现的那个方法,就是递归失控的位置。别去猜,去看。

小结

  • StackOverflowError 是调用深度超限,不是堆内存不够。
  • 根因几乎都是递归不终止或出口写错,少数是循环引用和代理链写坏。
  • 先查递归出口,能改循环就改循环,-Xss 只当应急。
  • 它是 Errorcatch (Exception) 接不住,别试图靠捕获兜底。

来源

最后更新于 2026-08-23

这篇帮到你了吗?

刚解决了一个棘手的报错?花两分钟记录下来,帮助下一个遇到同样问题的开发者。

贡献一条解法