JS堆栈溢出

发布时间 2023-07-27 23:46:46作者: 楚小九

前言

平时写代码过程中,或多或少会遇到栈溢出的问题,如下:
image

究竟什么是是什么问题导致的呢?想弄清楚原因,我们先来看函数调用的执行过程。

函数调用

	var a = 2;
	function add() {
		var b = 10;
		return a + b;
	}
	add();

现在来分析下上面代码的执行过程

编译阶段

根据js的执行流程,经过编译后,会生成两部分内容:执行上下文(Execution context)和可执行代码。

image

执行上下文是指JS执行一段代码时的运行环境,比如调用一个函数,就会进入这个函数的执行上下文,确定该函数在执行期间的用到变量如this、变量、对象、函数等,在执行上下文中存在一个变量环境的对象(Variable Environment),该对象中保存了变量提升的内容。

执行阶段

生成可执行代码之后,JS引擎开始顺序执行代码,执行到add这里时,JS引擎判断出这里是函数调用,然后执行下面操作:

1.从全局上下文中,取出add函数代码
2.对add函数的这段代码进行编译(创建该函数的执行上下文环境和可执行代码)
3.执行add函数,输出结果

image
函数调用完毕,在执行add函数时,会存在两个执行上下文,一个是全局执行上下文,一个是add函数的执行上下文。
那么JS引擎是怎么管理多个执行上下文的呢,JS引擎是通过栈来管理这些执行上下文的。

栈是一种数据结构,它遵循后进先出(LIFO)的原则。栈是一种有限容量的线性数据结构,在栈的一端称为栈顶,另一端称为栈底。
栈主要有两种基本操作:

  • 入栈(push):将元素添加到栈的顶部。
  • 出栈(pop):将栈顶的元素移除。

简单来说,栈就是一个拥有固定尺寸的容器,并且只有一个口子,如果要往里面放东西就要遵循后进先出的原则。
image

调用栈

调用栈就是管理这些执行上下文的栈,就叫调用栈。每次创建好一个执行上下文之后,就会放入调用栈中。
看下边这个例子

	var a = 2;
	function add(b, c) {
		return b + c;
	}

	function addAll(b, c) {
		var d = 10;
		var result = add(b, c);
		return a + result + d;
	}

	addAll(3, 6);

在上面这段代码中,在addAll函数中调用了add函数,现在我们来逐步分析调用栈是如何变化的

  • 第一步,创建全局执行上下文,并将其压入栈底,如下图:
    image
    从图中可以看出,变量a、函数add、函数addAll都保存到全局执行上下文的变量环境对象中。

全局执行上下文环境压入调用栈后,JS引擎开始执行全局代码。

	a = 2;

该语句会将全局执行上下文变量环境中a的值设置为2。全局执行上下文环境状态如下图:
image

	addAll(3, 6);

当调用addAll函数时,JS引擎会编译addAll函数,并为addAll创建一个执行上下文,最后将addAll函数的执行上下文环境压入栈中,如下图:
image
addAll函数的执行上下文创建成功之后,接着执行addAll函数的可执行代码。

	d = 10;
	result = add(b, c);
	return a + result + d;

执行到add函数调用语句时,同样会为add函数创建一个执行上下文环境,并将其压入调用栈,如下图所示:
image
创建好add函数的执行上下文环境之后,接着执行add函数的可执行代码

	return b + c;

add函数返回时,add函数的执行上下文环境就会从调用栈顶部弹出,并将result的值设置为add函数的返回值,也就是9,如下图:
image
然后执行addAll函数中的接下来可执行代码

	return a + result + d;

这个语句执行完成之后,把结果返回,addAll函数的执行上下文环境也会从调用栈顶部弹出,此时调用栈中就只剩下全局执行上下文了。如下图所示:
image
至此,整个JS流程执行结束。
调用栈是JS引擎追踪函数执行的一个机制,当一次有多个函数被调用时,通过调用栈就能够追踪到哪个函数正在被执行以及各个函数之间的调用关系。

如何利用调用栈

  • 利用浏览器查看调用栈的信息
    打开开发者工具(f12) -> source -> 打断点 -> 刷新
    就可以通过右边的“call stack”来查看当前的调用栈的情况。如下图:
    image
    从图中可以看出,右边的"call stack"下面显示出来了函数的调用关系:
    栈的底部是anonymous,也就是全局的函数入口;中间是addAll函数;顶部是add函数。非常清晰的反应了函数的调用关系。所以在分析复杂的代码时,调用栈是非常有用的。
  • console.trace()
    也可以在代码中添加console.trace()来输出函数的调用关系,如在add函数中增加console.trace(),如下图:
    image

栈溢出

调用栈是用来管理执行上下文的数据结构,先进后出。需要注意的是,它是有大小的,当入栈的执行上下文超过了一定数目,JS引擎就会报错,然后罢工了,这种错误就叫做栈溢出。
递归函数,很容易出现栈溢出,如:

	function isEven(n) {
		if (n === 0) {
			return true;
		}
		if (n === 1) {
			return false;
		}
		return isEven(Math.abs(n) - 2);
	}

这个JavaScript方法判断一个数字是否为偶数。它接受一个参数n,如果n等于0,则返回true;如果n等于1,则返回false;否则,递归调用isEven函数,并将参数n的绝对值减去2作为新的参数传入。该方法会一直递归调用直到n等于0或1,然后返回对应的布尔值。
当我们打印console.log(factorial(10))答案是true,结果运行也比较快,再看当我们输入console.log(factorial(10000000)),结果是抛出了错误:Uncaught RangeError: Maximum call stack size exceeded
我们该如何解决呢?
直接上代码

	function isEven(n) {
		function isEvenInner(n) {
			if (n === 0) {
				return true;
			}
			if (n === 1) {
				return false;
			}
			return function () {
				return isEvenInner(Math.abs(n) - 2);
			}
		}
		function trampoline(func, arg) {
			var value = func(arg);
			while (typeof value === "function") {
				value = value();
			}
			return value;
		}
		return trampoline.bind(null, isEvenInner)(n);
	}

上述方法在递归时使用了trampoline函数的尾调用优化技巧,通过将递归函数返回的函数保存在变量value中,然后在while循环中不断执行这个函数,每次递归调用都是在同一个上下文中执行,不会创建额外的堆栈帧,避免了递归调用的堆栈堆积导致溢出的问题。
image