掘金 后端 ( ) • 2024-04-26 10:35

起因

今天被一位大佬问了个问题,如何写个代码测试函数栈的最大深度,第一次遇到这个问题我有点懵,大佬给我解释了一下,然后把代码贴出来给我看了一下:

function stack(depth) {
    try {
        return stack(depth + 1);
    } catch (error) {
        return depth
    }
}

仔细看了一下没有问题,这样就可以拿到最深的函数栈了;本来打算高高兴兴去写其他代码了,但是突然起了歹念:

如果在catch到error之后,return stack(depth + 1)会发生什么。

测试

我首先写了段Kotlin的代码准备测试一下:

class Test {
    fun stack (depth: Int): Int {
        try {
            return stack (depth + 1)
        } catch (e:Throwable){
            println(depth)
            return stack(depth + 1)
        }
    }
}

fun main() {
    val test = Test()
    test.stack(0)
}

结果在这里:

image.png 最后结果是程序根本没有退出也没有卡死,而是一直在反复输出这些莫名其妙的数字。

按理来说,栈满了之后是不会再push函数进去的,递归函数没有出口又不可能return出来,应当卡死才对,这是什么奇怪的东西?

抱着怀疑的态度,我去JS里面试了一下:

// 测试代码
function stack(depth) {
    try {
        return stack(depth + 1);
    } catch (error) {
        console.log(depth);
        return stack(depth + 1)
    }
}

结果是这样的:

image.png 没错,果然,他最后没报错,然后卡死在7761这里了。

发现问题

到这里其实我就已经发现了一些问题了,仔细看一下这个图: image.png 如果我们假设println函数在这种情况下出了问题,每次只看五位数,也就是最开始的26639,然后是26661、26602、26659;你会发现数字很神奇的卡在了这个26600附近反复横跳,这是为什么呢?

并且两个测试都没有直接卡死在最大栈的数量的地方,而是都一直趋近于一个数,这又是为什么呢?

那么真相只有一个,我猜函数栈一定被什么东西清理了一下,所以depth数字会衰减回去,抱着这样的想法我回头改了一下kotlin的代码。

class Test {
    fun stack (depth: Int): Int {
        try {
            return stack (depth + 1)
        } catch (e:Throwable){
            // 注意这里⚠️
            print("${depth}\n")
            return stack(depth + 1)
        }
    }
}

fun main() {
    val test = Test()
    test.stack(0)
}

果然,print函数没出问题:

image.png 仔细思考一下,如果我们认为在这种情况下,编译器会自动把栈顶的部分函数出栈,阻止程序卡死,那这样是不是就很合理了呢?

进阶探索

以上面最后一张图为例,我们可以看到发生stack overflow错误的时候依次是:21816、21815、21814;

在这个过程中,JVM可能尝试每次将栈顶的函数弹出,然后继续执行代码,但是在这里面有一个严重的问题会干扰我们的观测:

print函数也会因为可能栈满导致栈溢出,所以我用了一个奇怪的方式进行了一次断点调试:

image.png

首先我们给代码卡在123行,这时一定会是溢出时的状态。

image.png

然后在119行加入断点,此时就可以快乐调试了,我们发现他在13704时发生了第一次栈溢出,然后继续 不断执行代码,发现他可以超过13704这个所谓的最大函数栈,最后在13727不断循环,上限值卡在了13727!

image.png

之后我们继续调试发现,在depth达到13728时,再次调用会出现问题,程序会强制把栈顶清空一部分,亲测为:

清空栈顶

一层然后重新push函数->

两层然后重新push函数->

三层然后重新push函数->

...

n层然后重新push函数->

一层然后重新push函数...

如此循环(n应该会有一个极限值)

这样的结论完全符合node测试结果和Kotlin的测试结果,我让朋友用C++运行一下,结果也是一样!

结论

经过这样的实验我们得出一个暂定的结论(希望各路研究语言运行环境的大佬指出错误):

当函数栈溢出时强制压入一个函数,在首次这样时,运行环境会扩大一次函数栈的最大容量

例如node测试用例中的:7373->7762; Kotlin的具体样例中的:13704->13727;

然后再次强制推入函数时,运行环境会将栈顶的部分函数按照特定规则强制出栈,继续执行代码。

最后,按照不同语言的编译器规范,可能会卡在某个时间不再强制出栈,程序卡死(例如node环境下的JS);或是持续不断的强制出栈,持续执行。(C++/Kotlin)

后记

偶然间因为兴趣发现了这样一个问题,希望这篇文章可以抛砖引玉,各路研究运行环境的大佬可以指正或讨论一下我文章中推断的不足之处,感谢各位!