栈溢出的原因与分析

存在一个问题:无限递归是否一定会溢出栈?

1. 递归栈溢出

1.1 基本原理

栈溢出通常发生在程序调用栈使用的空间超出其分配的限制时。是计算机内存中用于存储函数调用、局部变量、返回地址等信息的区域。当栈空间耗尽时,会触发“栈溢出”错误。

递归调用是在程序调用栈上依次压入新的一层函数调用,直到遇到递归终止条件结束。每一次递归调用会占用栈空间,因此,递归的深度会影响程序的栈空间使用。

1.2 无限递归的概念

无限递归是指递归函数没有合适的终止条件,或者终止条件从未被满足,导致递归会一直进行下去,直到栈空间耗尽。这样的递归会导致栈溢出。无线递归的代码通常会引发栈溢出错误,并且无法正常退出。

举个 🌰

function fun() {
  fun();
}
fun();

1.3 栈溢出与递归深度的关系

栈空间是有限的,栈的深度通常与系统的配置和编程语言的环境限制有关。即使是有限递归,如果递归层数非常深,也会导致栈溢出。因此,任何递归函数都需要保证有一个基准条件来停止递归,否则就会导致栈溢出。

🌰:递归函数计算阶乘

function factorial(n) {
  if (n === 0) {
    return 1;
  }
  return n * factorial(n - 1);
}
console.log(factorial(100));

此函数具有基准条件 n === 0,因此不会发生无线递归。

2. 无线递归是否会导致栈溢出?

分情况而言

2.1 立即执行递归调用
function A() {
  A();
}
A();

结果:栈溢出

每次调用直接占用调用栈,没有释放空间。

没有任何异步处理或中断机制,调用栈不断增长。

2.2 通过 setTimeout 异步递归
function A() {
  setTimeout(A, 0);
}
A();

结果:不会栈溢出

执行 A 函数时,遇到 setTimeout,其是异步函数,它会将回调函数 A 放到宏队列中,等待执行栈中的所有同步任务执行完毕后再执行。此时 A 函数执行完毕,从栈中移除。一段时间后发现同步任务清空后,JS 会执行宏队列任务,调用 A 函数,依次进行......这样就形成了一个异步的、不断重复的任务,但每次 A 的执行并不会造成新的栈帧堆叠,因为它并不是同步递归调用。

总结:栈溢出发生的典型情况是同步递归,只有当递归是同步进行的,即每次递归调用会立即在调用栈上推入新的栈帧时,才会有栈溢出的风险。

2.3 递归调用中直接执行函数
function A() {
  setTimeout(A(), 0);
}
A();

结果:栈溢出

A() 在 setTimeout 执行时会立即调用,不会进入宏任务队列,而是直接进入调用栈。也就是 函数 A 调用优先于 setTimeout 执行,A() 在 setTimeout 还没有机会被执行时,已经被直接调用。

当 A() 执行时,它再次调用 A(),形成了同步递归调用,调用栈无限增长。

2.4 通过 Promise 的微任务递归
function A() {
  Promise.resolve().then(A);
}
A();

结果:不会栈溢出

在 JavaScript 的事件循环机制中:当前的同步任务会先执行,直到调用栈为空;接着会执行微任务队列中的任务;再进入宏任务队列。

Promise.resolve().then(A) 是一个微任务。其中 A 被放入微任务队列,因此它的调用是异步的。每次执行 A 时,当前调用栈都会被清空,然后再从微任务队列中取出 A 执行。不会导致栈溢出,只是会无限循环。

2.5 基于 async/await 的异步递归
async function A() {
  await A();
}
A();

结果:栈溢出

尽管 await 是异步的,但每次调用 A 时,都会增加调用栈。

这是因为 await 会暂停当前函数,但不会释放调用栈。

优化方式:

async function A() {
  await Promise.resolve(); // 将递归本身放入微任务队列
  A();
}
A();

结果:不会栈溢出

每次调用通过微任务队列完成,调用栈始终保持清空状态。

2.6 自执行函数的递归
(function A() {
  (function B() {
    A();
  })();
})();

结果:栈溢出

每次调用都会嵌套一个新的函数,导致调用栈无限增长。

3. 什么情况下会发生栈溢出?

导致栈溢出的情况

- 同步递归调用,无论是否通过立即执行。

- 异步调用,但未正确中断或优化调用栈(如直接调用 A() )。

- 忽略终止条件。

不会栈溢出的情况:

- 使用异步调用(setTimeout 或 Promise)。

- 每次调用都清空调用栈。

- 函数回调和生成器的暂停调用。

3.1 如何避免栈溢出?

1、有明确的终止条件:递归函数必须在某些条件下停止调用,通常称为基准条件。

2、确保递归条件会向终止条件靠近:每次递归时,要确保传递给下一层的参数能逐步接近终止条件。

3、避免过深的递归:对于递归深度可能很大的情况,可以考虑使用迭代代替递归,或者使用尾递归优化(在某些编程语言中,编译器或运行时可以优化尾递归,避免栈溢出)。

3.2 举个 🌰

1、尾递归优化

某些编程语言(如 Python、JavaScript、Swift 等)支持尾递归优化。尾递归是一种特殊形式的递归,其中递归调用是函数的最后一步,调用栈可以被复用,从而避免了栈的增长。

function factorial(n, accumulator = 1) {
  if (n === 0) {
    return accumulator;
  }
  return factorial(n - 1, n * accumulator); // 递归调用是最后一步
}

每次递归调用不需要新的栈帧,从而节省栈空间。

2、使用迭代

如果递归的深度很大,并且无法避免,考虑将递归转化为迭代。

例如,计算阶乘时,使用循环而不是递归:

function factorial(n) {
  let result = 1;
  for (let i = 1; i <= n; i++) {
    result *= i;
  }
  return result;
}

3、限制递归的深度

如果无法避免递归调用过深,考虑通过限制递归的最大深度来防止栈溢出,或使用数据结构来模拟递归。

评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值