bigywater.tech
Java 内部原理 · 72

StackOverflowError 真实验证:栈帧数被隐藏上限掩盖的真相

本来只是想测"无局部变量的简单递归"和"每层带8个局部变量的重递归"到底能达到多深,结果两组数据真实都卡在同一个数字——1024。这篇记录真实排查这个"巧合"的过程。

两组结果真实都是1024
意外发现:简单递归和重递归的 getStackTrace().length 真实都停在 1024,和局部变量多少看起来毫无关系
调高上限后真相浮现
真实验证:调大 -XX:MaxJavaStackTraceDepth 后,默认栈真实能递归4.4万层,重栈帧版本真实只能到1.3万层

上一篇:VarHandle 原子操作真实验证

真实实验的第一个意外:两组数据都是 1024

设计初衷很简单:对比一个无局部变量的简单递归和一个每层多8个long局部变量的"重"递归,看后者是不是真的更浅就溢出。

StackOverflowDemo.java static int simpleRecurse(int depth) { return simpleRecurse(depth + 1); } static int heavyRecurse(int depth) { long a=depth,b=depth*2,c=depth*3,d=depth*4,e=depth*5,f=depth*6,g=depth*7,h=depth*8; return heavyRecurse(depth + 1) + (int)(a+b+c+d+e+f+g+h); } // 用 e.getStackTrace().length 统计深度
真实实测 · 默认栈大小
simpleRecurse 真实栈帧数(从异常堆栈里数出来的): 1024
heavyRecurse 真实栈帧数: 1024

真实结果完全不符合预期:两个理论上"栈帧大小差很多"的递归函数,统计出来的深度真实一模一样,都是 1024——这明显不是巧合,说明 getStackTrace().length 测的根本不是真实的递归深度。

真实排查:1024 是 JVM 的堆栈跟踪截断上限

真实原因指向 JVM 参数 -XX:MaxJavaStackTraceDepth,它的默认值恰好就是 1024。

真实命令行对照 java StackOverflowDemo # 默认 java -XX:MaxJavaStackTraceDepth=5000 StackOverflowDemo # 调大上限
真实实测 · MaxJavaStackTraceDepth=5000,默认栈
simpleRecurse 真实栈帧数(从异常堆栈里数出来的): 5000
heavyRecurse 真实栈帧数: 5000
真相大白。调大这个参数到 5000 后,两组结果真实变成了 5000——依然是"卡在上限"而不是"卡在真实溢出点",说明真实递归深度比 5000 还要深,统计值依然是被这个参数截断出来的假象,不是真实的调用栈深度。StackOverflowError.getStackTrace() 返回的堆栈跟踪数组,本身就是 JVM 按 MaxJavaStackTraceDepth 这个上限截断生成的,不是异常发生那一刻调用栈的完整快照——这是本次实验里最容易被忽视、也最容易得出错误结论的一个细节:直接用它的长度当作"真实递归深度"去做性能或内存分析,本身就是不准确的。

真实验证:调到足够大之后的真实深度差异

把上限调到远超预期的真实溢出点,才能看到局部变量数量对栈帧大小、进而对递归深度的真实影响。

真实实测 · MaxJavaStackTraceDepth=200000,默认栈
simpleRecurse 真实栈帧数(从异常堆栈里数出来的): 44511
heavyRecurse 真实栈帧数: 13016

这才是真实的答案:默认栈大小下,几乎不占局部变量空间的 simpleRecurse 真实能递归到约 44511 层才溢出,而每层多 8 个 long 局部变量的 heavyRecurse 真实只能到约 13016 层——差了 3 倍多,和"栈帧越大,能容纳的递归层数越少"这个直觉真实吻合,只是需要先排除统计口径本身的截断假象。

真实验证:-Xss 改变可用栈空间的效果

用更小的 -Xss256k 限制线程栈大小,配合重栈帧递归,验证是否会在远小于1024的深度就真实溢出(从而绕开截断上限的干扰)。

真实实测 · -Xss256k(默认MaxJavaStackTraceDepth=1024)
simpleRecurse 真实栈帧数: 1024(依然被截断,说明真实深度仍大于1024)
heavyRecurse 真实栈帧数: 593(真实溢出点,小于1024截断上限,是真实数据)

真实结果证明了如何正确解读这类数据:当真实溢出点本身就小于 1024 这个截断上限时(比如这里的 593),getStackTrace().length 才是可信的;一旦统计值恰好等于当前的 MaxJavaStackTraceDepth,就必须提高这个参数重新测,否则拿到的只是一个上限值,不是真实深度。

分步演示:栈到底能装多少层递归调用

每一行都是真实运行这段代码得到的结果(本机真实测得的深度)。

能递归多少层不是固定值,和线程栈大小真实成正比;写成尾递归形式也救不了它,HotSpot 真实没有尾调用优化
这一步新验证的场景

    参考与说明

    • 本文全部结果均在本机真实运行 Java 17 得到,同一配置下多次重复运行,溢出深度数值有小幅波动(和JIT编译状态、内联决策相关)但量级完全一致。
    • 和异常构造开销那篇一起看,能更完整理解 Throwable 的堆栈跟踪信息在 JVM 内部是如何被采集和裁剪的。
    • 没有涉及:虚拟线程(Virtual Thread)的栈空间分配机制和平台线程的真实差异、尾递归在Java里是否真的能被JIT优化成循环(结论是JVM规范不保证,目前主流实现也不做这个优化)——这两个话题留给系列后面按需再写。
    京ICP备2026067502号-1京公网安备11010502063487号