---

title: 前端必备:JavaScript Event Loop 与V8 垃圾回收机制

date: 2026-04-08

categories: 前端开发

tags: V8, JavaScript, 性能优化

---

# 深入理解 V8 垃圾回收机制与内存泄漏排查

作为前端工程师,我们每天都在与 JavaScript 打交道,但真正理解其底层运行机制的人却不多。本文将带你深入了解 V8 引擎的垃圾回收机制、内存泄漏排查方法,以及 Event Loop 在不同环境下的差异。

<!-- more -->

## 一、Event Loop 基础概念

### 1. 什么是 Event Loop?

JavaScript 的核心特征是单线程,为了在单线程下实现非阻塞 I/O 和并发(网络请求、用户交互、定时器、文件读写),Event Loop 应运而生。

Event Loop(事件循环)是 JavaScript 执行异步代码的核心机制。它负责协调宏任务(Macrotask)和微任务(Microtask)的执行顺序,确保 JavaScript 单线程环境下的异步操作能够有序进行。

#### 核心组件

Call Stack(调用栈):同步代码执行的区域,遵循 LIFO(后进先出),当函数执行完毕,栈弹出。

Web APIs / C++ APIs:宿主环境提供的异步能力(如 DOM 操作、定时器、网络请求、文件 I/O),由浏览器或 Node.js 提供的多线程环境。它们在后台多线程运行。

Task Queue(任务队列):异步操作完成后,其回调函数会被推入队列,等待 Call Stack 清空后执行。遵循 FIFO(先进先出)。

Event Loop: 本质上是一个类似 while(true) 的无限循环,它不断监控调用栈和任务队列,扮演着“消费者”的角色,它是一个带有阻塞等待的无限事件循环。

> 从实现层面讲,Event Loop 确实是一个类似 while(true) 的无限循环,但它的“无限”建立在有任务或预期会来任务的前提下,且在没有任务时通过操作系统机制( I/O 多路复用或事件通知机制)进入睡眠(不消耗 CPU),以避免 CPU 空转。

### 2. 宏任务 vs 微任务

| 特性 | 宏任务(Macrotask) | 微任务(Microtask) |

| ---- | ------------------- | ------------------- |

| 执行时机 | 当前任务完成后、下一个宏任务开始前 | 当前宏任务结束后、下一个宏任务开始前 |

| 典型代表 | script整体代码、setTimeout、setInterval、I/O、UI rendering、UI 交互事件(click 等)、postMessage、MessageChannel | Promise.then/catch/finally、MutationObserver、queueMicrotask |

| 执行顺序 | 后进先出 | 先进先出 |

| 优先级 | 较低 | 较高 |

| 特点 | 每次执行栈清空后,Event Loop 会从宏任务队列中取出一个任务执行 | 在当前宏任务执行完毕后,清空整个微任务队列(即执行所有微任务)后,才会进行 UI 渲染或执行下一个宏任务 |

### 3. Event Loop 执行流程

> 宏任务 -> [清空微任务] -> [判断渲染] -> [rAF(渲染前执行)] -> [UI 渲染] -> [rIC(渲染后执行)] -> 下一个宏任务

```

┌───────────────────────────┐

│ 执行主线程同步代码 │

└───────────┬───────────────┘

┌───────────────────────────┐

│ 检查微任务队列 │

│ (queueMicrotask) │────► 执行所有微任务

└───────────┬───────────────┘

┌───────────────────────────┐

│ 检查是否需要渲染 │────► Browser: 检查是否需要重绘

│ (requestAnimationFrame) │

└───────────┬───────────────┘

┌───────────────────────────┐

│ 从宏任务队列取一个任务 │────► 执行宏任务

└───────────┬───────────────┘

└──► 回到步骤 2

```

## 二、Browser 与 Node 环境(基于libuv)的差异

### 1. 宏任务队列的差异

**Browser 环境:**

- 只有一个宏任务队列

- 所有宏任务(setTimeout、I/O、UI rendering)共享同一个队列

**Node.js 环境:**

> Node.js 的主要任务是服务端 I/O 处理(网络、文件系统),它没有 UI 渲染,底层依赖 libuv 库来实现事件循环。因此,它的宏任务队列被拆分成了多个不同的阶段(Phases)。

1. 核心阶段(6大 Phase)

Node.js 的 Event Loop 按照以下顺序循环执行,每个阶段都有自己专属的队列:

1.1 **Timers(定时器阶段)**:执行 setTimeout 和 setInterval 的回调。

1.2 **Pending Callbacks(挂起的回调)**:执行系统级别的回调,例如 TCP 连接错误(ECONNREFUSED)。

1.3 **Idle, Prepare**:仅供 Node.js 内部使用(可忽略)。

1.4 **Poll(轮询阶段)**:最核心的阶段!

-- **获取 I/O 事件**:执行 I/O 相关回调(如文件读写完成、网络请求到达)

-- **阻塞机制**:若无 timer 到期且其他队列为空,Event Loop 会在此阶段阻塞等待新 I/O 事件

1.5 **Check(检查阶段)**:专为 setImmediate 设置的阶段。一旦 Poll 阶段空闲,且有 setImmediate 回调,就会进入此阶段。

1.6 **Close Callbacks(关闭回调)**:执行一些关闭事件的回调,如 socket.on('close', ...)。

2. Poll 阶段的阻塞策略

2.1 基本概念

Poll 阶段是 Event Loop 的核心,负责处理 I/O 事件。它的阻塞策略直接影响 Node.js 的性能和响应速度。

2.2 阻塞决策逻辑

Poll 阶段的阻塞策略由两个关键因素决定:

**决策流程:**

```

┌─────────────────────────────┐

│ Poll 阶段开始 │

└──────────────┬──────────────┘

┌────────────────────────┐

│ check 队列是否有任务? │

└───────┬───────┬────────┘

│ 是 │ 否

▼ ▼

┌──────────┐ ┌──────────────────────┐

│ 不阻塞 │ │ timers 有到期的? │

│ 执行完 │ └──────┬───────┬──────┘

│ 进入 check │ │ 是 │ 否

└──────────┘ ▼ ▼

┌──────────┐ ┌──────────────┐

│ 不阻塞 │ │ 阻塞等待 │

│ 执行完 │ │ 新 I/O 事件 │

│ 进入 timers│ │ (节省 CPU) │

└──────────┘ └──────────────┘

```

2.3 高级理解:源码层面的实现

Node.js 的 libuv 库在 Poll 阶段使用了 I/O 多路复用机制(epoll/kqueue/select):

**阻塞时间计算:**

```c

// 伪代码 - libuv 的 poll 实现

int timeout = calculate_timeout();

// 计算最近的 timer 到期时间

if (has_immediate_callbacks()) {

timeout = 0; // 有 setImmediate,不阻塞

} else if (has_pending_timers()) {

timeout = get_nearest_timer_timeout();

} else {

timeout = -1; // 无限等待新 I/O 事件

}

epoll_wait(epoll_fd, events, max_events, timeout);

```

**关键点:**

- **timeout = 0**:非阻塞,立即返回

- **timeout > 0**:阻塞等待指定毫秒

- **timeout = -1**:无限阻塞,直到有 I/O 事件

2.4 架构层面:性能优化考量

Poll 阶段的阻塞策略体现了 Node.js 的核心设计理念:

1. **高吞吐量**:通过阻塞等待 I/O,避免空轮询消耗 CPU

2. **低延迟**:对 setImmediate 和 timers 的特殊处理,保证关键任务及时执行

3. **资源协调**:在 I/O 事件、定时器、回调之间找到最佳平衡点

4. **可预测性**:确定性的执行顺序,便于性能分析和调试

这种设计使得 Node.js 能够在单线程下高效处理大量并发 I/O 操作,成为后端开发的优秀选择。

### 2. 微任务执行时机差异

```javascript

// Browser vs Node 执行顺序对比

setTimeout(() => console.log('timeout'), 0);

Promise.resolve().then(() => console.log('promise'));

console.log('sync');

```

**Browser 输出:** sync → promise → timeout

**Node 输出:** sync → promise → timeout(v10+ 与 Browser 一致)

**在 Node.js 10 及以前:** 微任务队列会在整个阶段(如 timers、poll、check)中的所有宏任务执行完毕后才被清空。

**微任务类型(优先级从高到低):**

- **process.nextTick()**:Node.js 特有,不属于 ECMAScript 规范,优先级最高。在当前宏任务结束后、任何其他微任务之前立即执行。

- **Promise 回调(then/catch/finally)**:标准微任务,遵循 ECMAScript 规范,在 nextTick 队列清空后执行。

**执行顺序(以 Node.js 11+ 为例):**

```

某个宏任务执行完毕

→ 清空整个 process.nextTick 队列

→ 清空整个 Promise 微任务队列

→ 执行下一个宏任务(或进入 Event Loop 的下一阶段)

```

**注意:** 由于 process.nextTick 优先级极高且会递归追加任务,滥用可能导致 I/O 饥饿(事件循环无法及时进入 poll 阶段处理新请求),需谨慎使用。

### 3. nextTick 特殊机制

Node.js 独有的 `process.nextTick()`,其优先级高于微任务:

```javascript

process.nextTick(() => console.log('nextTick'));

Promise.resolve().then(() => console.log('promise'));

console.log('sync');

// 输出: sync → nextTick → promise

```

### 4. setTimeout 与 setImmediate 的对比

这是 Node.js 面试中最经典的陷阱:

```javascript

// 情况一:在 main 模块中执行

setTimeout(() => console.log('timeout'), 0);

setImmediate(() => console.log('immediate'));

// 输出不确定:取决于进入 event loop 时 timer 的延迟是否 > 1ms。

// 由于系统时钟精度,通常 immediate 先执行,但 timeout 也有可能。

// 情况二:在 I/O 回调内部执行

fs.readFile('file.txt', () => {

setTimeout(() => console.log('timeout'), 0);

setImmediate(() => console.log('immediate'));

});

// 输出确定:immediate 永远先于 timeout。

// 原因:I/O 回调在 poll 阶段执行,此时 poll 结束后会进入 check 阶段(setImmediate),

// 下一个循环才进入 timers 阶段。

```

## 三、V8 垃圾回收机制

> 垃圾回收(Garbage Collection, GC)是 V8 引擎实现高效内存管理的核心。

### 1. 为什么需要垃圾回收? —— 从内存生命周期说起

任何程序在运行时都需要内存。内存管理通常分为三步:分配 → 使用 → 释放。

- 在 C/C++ 中,开发者需要手动调用 malloc 分配内存,free 释放内存。这种做法非常灵活,但也极易出错:忘记释放会导致内存泄漏,提前释放会产生“野指针”。

- 在 JavaScript 中,V8 引擎会自动进行垃圾回收(GC)。开发者不用操心内存释放,但需要理解 GC 的工作原理,一个优秀的开发者要知其然,知其所以然。

### 1. 可达性:垃圾回收的“黄金规则”

V8 判断一个对象是否还能被引用的标准,叫做可达性。简单说:从“根”对象出发,如果能通过引用链访问到某个对象,那它就是“存活”的;否则就是“垃圾”,可以被回收。

- **根对象**:包括全局变量、活动对象、闭包引用等、Node中的global。

- **可达性分析**:从根对象出发,遍历所有引用路径,标记所有可达对象。

- **不可达对象**:未被标记的对象,就是垃圾对象。

### 2. V8 内存划分

V8 将内存分为两部分:

| 区域 | 用途 | 大小 |

| ---- | ---- | ---- |

| 新生代(New Space) | 存储生命周期短的对象 | 1~8MB(可配置) |

| 老生代(Old Space) | 存储生命周期长的对象 | 动态增长 |

### 2. 垃圾回收算法

#### (1)新生代回收:Scavenge 算法 —— “复制式”快速清理

- 结构:分为两个大小相等的 semispace,称为 From 空间(当前使用)和 To 空间(空闲)。

- 过程:

1. 标记:从根集(全局对象、栈上的局部变量等)出发,标记所有可达对象。

2. 复制:将 From 空间中所有存活对象,按顺序复制到 To 空间。复制后,这些对象在 To 空间中是连续的,天然解决了碎片问题。

3. 交换:复制完成后,清空 From 空间(现在变为空闲),并交换 From 和 To 的角色。下一次 Minor GC 就在新的 From 空间进行。

- 晋升:若一个对象在 Scavenge 中经历多次复制(通常为 1-2 次)后仍然存活,或 To 空间使用率超过一定阈值,则将其晋升到老生代。

- 优点:复制算法只需遍历存活对象,效率高;无碎片。

- 代价:空间利用率只有 50%(一半空间总是空闲)。

#### (2)Mark-Sweep(标记清除)

适用于老生代:

- 标记阶段:从根集出发,遍历所有可达对象并标记。这是Stop-The-World 阶段,主线程暂停。

- 清除阶段:遍历堆内存,将未被标记的对象(垃圾)所占据的空间标记为可重用(加入空闲链表)。

- 缺点:会产生大量不连续的内存碎片。

#### (3)Mark-Compact(标记整理)

在 Mark-Sweep 基础上增加整理步骤:

- 标记完成后,将所有存活对象向内存一端移动,使其连续排列,然后清理掉边界外的所有内存。

- 优点:消除碎片,后续分配大对象更高效。

- 缺点:移动对象需要更新所有指向该对象的指针,成本高。

### 3. 增量标记与并发标记

为了减少 GC 造成的停顿,V8 引入了增量标记和并发标记:

- **增量标记(Incremental Marking)**:将 GC 分成多个小步骤,交替执行

- **并发标记(Concurrent Marking)**:利用后台线程并行标记

## 四、内存泄漏排查

### 1. 常见内存泄漏场景

#### (1)全局变量

```javascript

function leak() {

this.variable = '泄漏'; // 指向 window

}

leak();

console.log(window.variable); // '泄漏'

```

#### (2)闭包引用

```javascript

function createLeak() {

let largeArray = new Array(1000000);

return function() {

return largeArray[0];

};

}

const leak = createLeak();

// largeArray 无法被回收

```

#### (3)DOM 引用

```javascript

const elements = [];

function addElement() {

const div = document.createElement('div');

elements.push(div); // 即使 DOM 已移除,引用仍存在

}

```

#### (4)定时器未清理

```javascript

function startTimer() {

setInterval(() => {

// 持续执行

}, 1000);

}

startTimer(); // 页面切换时未清理

```

### 2. 排查工具

#### (1)Chrome DevTools Memory 面板

1. **Snapshot 快照**:记录当前内存状态

2. **Comparison 对比**:比较两个快照的差异

3. **Allocation Timeline**:跟踪内存分配时间线

#### (2)performance 面板

监控页面性能,发现异常 GC 行为:

- 频繁的 GC 会导致页面卡顿

- 红色三角形表示 GC 暂停时间过长

#### (3)Node.js 排查工具

```javascript

// 使用 process.memoryUsage()

console.log(process.memoryUsage());

// { rss: 12345678, heapTotal: 1234567, heapUsed: 654321, external: 12345 }

// 使用 --inspect 启动调试

node --inspect app.js

// 打开 chrome://inspect 进行分析

```

### 3. 内存泄漏检测代码示例

```javascript

function detectMemoryLeak() {

if (global.gc) {

global.gc();

}

const startMemory = process.memoryUsage();

const startTime = Date.now();

// 执行可疑操作

for (let i = 0; i < 1000; i++) {

// ... 被测试的代码

}

if (global.gc) {

global.gc();

}

const endMemory = process.memoryUsage();

const leak = (endMemory.heapUsed - startMemory.heapUsed) / 1024 / 1024;

console.log(`Memory leak: ${leak.toFixed(2)} MB`);

return leak;

}

```

## 五、性能优化实践

### 1. 减少内存占用

- 对象池复用对象

- 避免创建不必要的对象

- 使用 typedArray 处理大量数据

### 2. 及时清理资源

```javascript

// 清理定时器

const timer = setInterval(() => {}, 1000);

clearInterval(timer);

// 清理事件监听

element.addEventListener('click', handler);

element.removeEventListener('click', handler);

// 清理 Web Worker

worker.terminate();

```

### 3. 优化数据结构

```javascript

// 低效

const arr = [];

for (let i = 0; i < 100000; i++) {

arr.push({ index: i });

}

// 高效 - 使用 Map

const map = new Map();

for (let i = 0; i < 100000; i++) {

map.set(i, { index: i });

}

```

## 总结

理解 V8 垃圾回收机制和 Event Loop 对于前端性能优化至关重要。Browser 和 Node 环境在任务调度上存在差异,理解这些差异有助于我们写出更高效的代码。内存泄漏问题需要从代码层面预防,配合 Chrome DevTools 和 Node 提供的排查工具,我们可以快速定位和解决内存问题。

希望本文能帮助你在日常开发中更好地理解和优化 JavaScript 内存使用。

Logo

openEuler 是由开放原子开源基金会孵化的全场景开源操作系统项目,面向数字基础设施四大核心场景(服务器、云计算、边缘计算、嵌入式),全面支持 ARM、x86、RISC-V、loongArch、PowerPC、SW-64 等多样性计算架构

更多推荐