死循环 (Dead Loop) vs. 无限递归 (Infinite Recursion)
一、 死循环 (Dead Loop)
1. 核心后果:浏览器无响应 (卡死)
死循环会导致 主线程被长期占用,浏览器无法进行页面渲染和响应用户交互,最终导致页面无响应。
核心原理: 浏览器的主线程(也称渲染主线程)负责执行 JavaScript、解析 HTML、渲染 CSS、生成布局树等多种任务。当一个死循环开始执行时,CPU 会陷入循环指令中无法退出,持续占用主线程。这使得主线程无法处理队列中的其他任务(如页面重绘、重排、响应用户点击等),导致用户看到的现象就是页面“卡死”或“转圈圈”。
2. 会导致内存溢出吗?→ 不会
很多人误以为死循环会不停地消耗内存,但通常情况下并不会。
- 原因分析:
- 内存的占用主要来源于变量和对象的创建。
- 使用
let或const:在循环体内使用let声明变量,会形成块级作用域。每次循环开始时创建变量,循环结束时该作用域销毁,变量随之被回收。内存占用如同“变魔术”,一进一出,不会持续增长。javascriptwhile (true) { // 进入块级作用域,创建变量 a let a = {}; // 离开作用域,变量 a 被销毁 } // 内存占用并不会无限增长 - 使用
var:由于变量提升(hoisting),var声明的变量实际上在循环外部就已经存在。循环体内只是不断地对其进行赋值操作,自始至终只有一个变量,因此内存占用不会增加。javascriptwhile (true) { var a = {}; // 等价于在循环外声明,循环内赋值 } // 实际上的效果: var a; while (true) { a = {}; }
3. 加入 async/await 的情况
a. 等待宏任务 (Macrotask) - 不会卡死
如果循环中 await 一个宏任务(如 setTimeout 的封装),浏览器不会卡死,但会非常繁忙。
async function loop() {
while (true) {
// delay 是一个基于 setTimeout(..., 0) 的异步函数
await delay(0);
}
}原理:
await一个宏任务会交出主线程的控制权。即使等待时间为 0,这个操作也会被放入任务队列(Task Queue)的末尾。在这段极短的“空隙”中,主线程可以处理其他高优先级的任务,比如渲染帧(通常约 16.6ms 一次)。因此,浏览器有机会“见缝插针”地进行渲染,页面不会完全卡死,但可能会感觉卡顿。
b. 等待微任务 (Microtask) - 会导致卡死
如果循环中 await 一个常量或一个已 resolve 的 Promise,浏览器会卡死。
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错误。
function recursive() {
// 函数尚未执行完毕,就再次调用自身
recursive();
}
recursive();
// Uncaught RangeError: Maximum call stack size exceeded注意:所谓的尾递归优化 (Tail Call Optimization) 在当前主流的 JavaScript 引擎(如 V8)中并未实现,因此不能依赖它来避免栈溢出。
2. 加入 async/await 的情况
a. 异步递归 - 不会栈溢出
在递归调用前加入 await,不会导致栈溢出。
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让函数上下文及时出栈 |
| 内存溢出 | 否 | 否 | 否 | 否 | 否 |