Javascript进阶之栈溢出
什么是栈帧?什么是栈溢出?
**栈帧(Stack Frame)**是内存中用于存储函数调用信息的区域。每当一个函数被调用时,系统都会为其分配一块内存来存储函数的局部变量、参数、返回地址等信息,并将当前运行的函数的栈帧压入栈中,以便于后续函数调用时能够恢复之前的运行状态。当函数调用结束后,栈帧也会被弹出栈,释放内存。
举个例子:
function C() {
// 函数C的逻辑代码
//...
};
function B() {
// ...
C(); // 调用函数C
};
function A() {
// ...
B(); // 调用函数B
};
A(); // 调用函数A
上面的例子中A函数调用了B函数,B函数又调用了C函数,那么首先给A函数分配栈帧,当A调用B时,A并没有结束,所以A的栈帧必须保留,系统会将A的栈帧压入(push)栈中,以便于后续返回时能够恢复之前的运行状态。接下来运行B函数,系统会为B函数分配栈帧,当B调用C时,系统会将当前函数B的栈帧压入栈中。最后运行C函数,系统会为C函数分配栈帧,并压入栈中,当C函数调用结束后,系统会弹出(pop)栈,释放C的栈帧,流程返回到B函数,B函数结束后,系统会弹出栈,释放B的栈帧,流程回到A函数,A函数结束后,系统会最后弹出栈,释放A的栈帧。
了解这个过程后我们自然想到,系统的栈空间是受限于资源的,如果函数调用层次过深,栈空间就会被占满,会怎么样呢?这就是栈溢出(Stack Overflow)的问题,我们经常听到的运维小伙伴口中的爆栈,就是栈溢出的别名。知名的程序员debug问答社区stackoverflow.com,它的名字就来源于此,可见爆栈给程序员们留下的心理阴影面积有多大。
Javascript中的栈溢出
很自然的,我们会想到,递归这种策略是很容易形成很深的调用栈的,我们可以做个实验看看JS中的栈溢出情况:
function recursive(n) {
if (n <= 0) {
return 0;
}
else return 1 + recursive(n-1);
}
recursive(50000);
上面是一个很无聊的函数,递归调用自己,直到n为0,但是这样的递归调用会导致栈溢出,因为调用50000次函数,大大超出了主流引擎的最大调用栈,系统会抛出栈溢出的异常,报错通常是这样的:
Uncaught RangeError: Maximum call stack size exceeded
JS中如何避免栈溢出?以迭代替代递归为例
这个话题太大了,我们这里只谈谈最简单的,上文提到的由于深度递归导致的栈溢出,解决方案也很简单,把递归改成迭代(循环)即可。
用例子说话:
//递归版本,相加函数
const addRecursive = x => y => y<=0? x : addRecursive(x+1)(y-1);
//循环版本,相加函数
const addLoop = x => y => {
while(y>0){
x++;
y--;
}
return x;
};
try{
console.log(addRecursive(1)(50000)); // 栈溢出
}catch(e){
console.log(e.message); // 报错:Maximum call stack size exceeded
}
console.log(addLoop(1)(50000)); // 正常运行,输出50001
以上展示了两个功能等价的函数addRecursive
和addLoop
,它们的作用都是计算两个正整数的和,但是addRecursive
使用递归算法,而addLoop
使用循环。
addRecursive
函数的实现很简单,就是递归调用自己,直到y
为0,然后返回x
。但是由于递归调用层次过深,会导致栈溢出。
addLoop
函数的实现也很简单,就是用一个循环,对x
执行后继操作(++)和对y
执行前继操作(–),直到y
为0,然后返回x
。这个实现不会导致栈溢出,因为它不会无限调用自己,循环的开销远远小于递归调用。
这个简单的例子展示了迭代代替递归的策略,可以有效避免栈溢出。在设计函数的时候,可以参考此策略。
其它避免栈溢出的方法还有很多,比如:尾递归优化,记忆化,分治策略,惰性求值,使用迭代器和生成器等等,这些方法都有其适用的场景,我们以后再聊。