Skip to content

死循环 (Dead Loop) vs. 无限递归 (Infinite Recursion)

一、 死循环 (Dead Loop)

1. 核心后果:浏览器无响应 (卡死)

死循环会导致 主线程被长期占用,浏览器无法进行页面渲染和响应用户交互,最终导致页面无响应。

核心原理: 浏览器的主线程(也称渲染主线程)负责执行 JavaScript、解析 HTML、渲染 CSS、生成布局树等多种任务。当一个死循环开始执行时,CPU 会陷入循环指令中无法退出,持续占用主线程。这使得主线程无法处理队列中的其他任务(如页面重绘、重排、响应用户点击等),导致用户看到的现象就是页面“卡死”或“转圈圈”。

2. 会导致内存溢出吗?→ 不会

很多人误以为死循环会不停地消耗内存,但通常情况下并不会。

  • 原因分析:
    • 内存的占用主要来源于变量对象的创建。
    • 使用 letconst:在循环体内使用 let 声明变量,会形成块级作用域。每次循环开始时创建变量,循环结束时该作用域销毁,变量随之被回收。内存占用如同“变魔术”,一进一出,不会持续增长。
      javascript
      while (true) {
        // 进入块级作用域,创建变量 a
        let a = {}; 
        // 离开作用域,变量 a 被销毁
      }
      // 内存占用并不会无限增长
    • 使用 var:由于变量提升(hoisting),var 声明的变量实际上在循环外部就已经存在。循环体内只是不断地对其进行赋值操作,自始至终只有一个变量,因此内存占用不会增加。
      javascript
      while (true) {
        var a = {}; // 等价于在循环外声明,循环内赋值
      }
      
      // 实际上的效果:
      var a;
      while (true) {
        a = {};
      }

3. 加入 async/await 的情况

a. 等待宏任务 (Macrotask) - 不会卡死

如果循环中 await 一个宏任务(如 setTimeout 的封装),浏览器不会卡死,但会非常繁忙。

javascript
async function loop() {
  while (true) {
    // delay 是一个基于 setTimeout(..., 0) 的异步函数
    await delay(0); 
  }
}

原理await 一个宏任务会交出主线程的控制权。即使等待时间为 0,这个操作也会被放入任务队列(Task Queue)的末尾。在这段极短的“空隙”中,主线程可以处理其他高优先级的任务,比如渲染帧(通常约 16.6ms 一次)。因此,浏览器有机会“见缝插针”地进行渲染,页面不会完全卡死,但可能会感觉卡顿。

b. 等待微任务 (Microtask) - 会导致卡死

如果循环中 await 一个常量或一个已 resolve 的 Promise,浏览器会卡死

javascript
async function loop() {
  while (true) {
    // await 1; 等价于 await Promise.resolve(1);
    await 1; 
  }
}

原理await 一个非 Promise 值或已完成的 Promise,会将其后续操作作为一个微任务(Microtask)放入微任务队列。微任务队列的执行优先级高于渲染。这意味着,循环会不断地向微任务队列中添加任务,并且这些任务会立即被执行,主线程始终被微任务占据,没有机会去进行渲染,从而导致页面无响应。

二、 无限递归 (Infinite Recursion)

1. 核心后果:栈溢出 (Stack Overflow)

无限递归会导致调用栈 (Call Stack) 溢出,程序直接报错崩溃。

核心原理: 每一次函数调用,都会在调用栈中创建一个新的执行上下文 (Execution Context)。在常规递归中,函数执行完毕后,其上下文会从栈中弹出。但无限递归的场景下,函数在执行完成前就调用了自身,导致执行上下文不断入栈,而没有出栈的机会。调用栈的容量是有限的(如 Chrome 中约 10-14MB),当栈被填满时,就会抛出 Maximum call stack size exceeded 错误。

javascript
function recursive() {
  // 函数尚未执行完毕,就再次调用自身
  recursive(); 
}

recursive(); 
// Uncaught RangeError: Maximum call stack size exceeded

注意:所谓的尾递归优化 (Tail Call Optimization) 在当前主流的 JavaScript 引擎(如 V8)中并未实现,因此不能依赖它来避免栈溢出。

2. 加入 async/await 的情况

a. 异步递归 - 不会栈溢出

在递归调用前加入 await不会导致栈溢出

javascript
async function asyncRecursive() {
  // await 会让出线程,等待异步操作完成
  await delay(0); // 或者 await 1;
  asyncRecursive();
}

原理async 函数的执行在遇到 await 时会暂停并返回一个 Promise。当前函数的执行上下文实际上已经执行完毕并从调用栈中弹出。当 await 后面的异步操作完成后,对 asyncRecursive 的下一次调用会作为一个新的任务(宏任务或微任务)被放入事件循环队列,而不是在当前调用栈上继续叠加。因此,调用栈的深度始终保持为 1,不会溢出。

b. 异步递归是否卡死? - 取决于 await 的内容

  • 等待宏任务 (await delay(0)):不会卡死。原因同死循环部分,它为渲染帧的插入提供了时机。
  • 等待微任务 (await 1):会导致卡死。原因同死循环部分,微任务队列会抢占所有处理时间,使渲染无法进行。

三、 总结

死循环 (Sync)无限递归 (Sync)async 死循环 (await 宏任务)async 死循环 (await 微任务)async 无限递归
主要后果无响应 (卡死)栈溢出 (崩溃)CPU 繁忙无响应 (卡死)不会崩溃或卡死 (若await宏任务) <br> 会卡死 (若await微任务)
原因主线程持续被占用,无法渲染调用栈被填满await让出线程,给渲染机会微任务优先级高于渲染await让函数上下文及时出栈
内存溢出