本文会详细介绍垃圾回收的各个知识点,祝你面试成功。
为什么需要垃圾回收?
核心问题 :
JavaScript 在运行时需要动态分配内存(如创建对象、数组等),但开发者无法手动释放内存,因此必须依赖自动内存管理机制。
手动 vs 自动:
- 手动管理
- (如 C/C++):通过
malloc/free
直接控制内存,高效但容易出错(内存泄漏、野指针)
- (如 C/C++):通过
- 自动管理
- (如 JavaScript):由引擎自动追踪"不再使用的内存"并回收,安全但有一定性能开销
V8 的内存分代假说
关键洞察 :
V8 将堆内存分为 新生代(New Space) 和 老生代(Old Space) ,基于两个观察:
- 大多数对象"朝生夕死" (如函数内的临时变量)
- 存活时间长的对象会持续存活(如全局变量)
内存分区:
- 新生代 :存放新创建的小对象(默认 1~8MB),使用 Scavenge 算法快速回收
- 老生代 :存放存活时间长的对象(默认约 700MB ~ 1.4GB),使用 标记-清除(Mark-Sweep) 和 标记-整理(Mark-Compact) 算法
新生代垃圾回收(Scavenge)
算法核心 :Cheney 算法(复制算法)
步骤:
-
将新生代分为两个等大的 From-Space 和 To-Space
-
分配对象:新对象始终写入 From-Space
-
垃圾回收:
- 遍历 From-Space,将存活对象 复制到 To-Space
- 交换 From/To 空间角色(清空旧的 From-Space)
优点:
- 时间与 存活对象数量 成正比(适合回收大量"短命"对象)
- 无内存碎片
对象晋升:
- 若对象在新生代经历多次回收仍存活(默认 2 次)
- 或 To-Space 使用超过 25%
- ➔ 晋升到老生代
老生代垃圾回收(Mark-Sweep & Mark-Compact)
标记-清除(Mark-Sweep)
步骤:
- 标记阶段:从根(全局变量、活动函数栈)出发,递归标记所有可达对象
- 清除阶段:遍历堆内存,释放未被标记的对象
缺点:
- 产生内存碎片
- 全停顿(Stop-The-World)可能较久(影响性能)
标记-整理(Mark-Compact)
改进点 :在清除阶段,将存活对象向一端移动,解决碎片问题
代价:移动对象需要更多时间(通常只在内存碎片较多时触发)
增量标记与并发回收
增量标记(Incremental Marking)
目标 :将标记阶段分解为多个小任务,穿插在主线程的空闲时间执行
实现:
- 使用三色标记法(白→灰→黑)追踪对象状态
- 通过写屏障(Write Barrier)维护标记一致性
并发标记/清除(Concurrent Marking/Sweeping)
目标 :利用后台线程并行执行标记/清除,减少主线程阻塞
挑战:需保证 JavaScript 执行与回收线程的数据一致性
三色标记法(Tri-color Marking)和写屏障(Write Barrier)是 增量标记(Incremental Marking) 和 并发标记(Concurrent Marking) 的核心技术,用于解决垃圾回收过程中主线程长时间阻塞的问题。下面通过 原理、流程、代码示例 深入解析:
三色标记法
1. 核心思想
将堆内存中的对象标记为三种颜色,表示它们在垃圾回收过程中的状态:
- 白色:未被访问的对象(默认状态,可能待回收)
- 灰色:已被访问,但其子引用未被遍历(中间状态)
- 黑色:已被访问,且所有子引用已被遍历(完成状态)
标记过程从根对象(如全局变量、活动调用栈)出发,最终所有不可达的对象保持白色,被回收。
2. 标记流程
-
初始状态 :
所有对象标记为白色,根对象变为灰色。
text[Root] → A(灰色) B(白色) ← A → C(白色)
-
标记阶段 :
逐步将灰色对象变为黑色,并遍历其子对象:
- 取出一个灰色对象(如 A)
- 遍历 A 的所有子引用(B、C),将子对象标记为灰色
- A 标记为黑色
textA(黑色) → B(灰色) A → C(灰色)
-
持续处理 :
重复上述步骤,直到没有灰色对象存在:
- 处理 B → B 的子对象(如有)标记为灰色,B 变黑
- 处理 C → C 的子对象(如有)标记为灰色,C 变黑
textA(黑色) → B(黑色) A → C(黑色)
-
回收阶段 :
所有白色对象视为不可达,被释放。
3. 增量标记的优势
传统标记是 全停顿(Stop-The-World) 的,而三色标记允许将标记过程拆分为多个小步骤:
- 每次仅处理部分灰色对象
- 主线程可以在标记间隙执行 JavaScript 代码
- 显著减少单次阻塞时间(从几百毫秒 → 几毫秒)
写屏障(Write Barrier)
1. 问题背景
在增量或并发标记过程中,若主线程修改了对象引用关系,可能导致两种错误:
- 漏标:本应存活的对象被错误回收(致命错误)
- 多标:本应回收的对象未被回收(内存泄漏,可容忍)
示例场景:
javascript
// 初始状态:A 已标记为黑色(完成状态)
A.black = true;
// 主线程执行代码,将 A 的子引用从 B 改为 C
A.child = C; // 原引用是 B
// 若 C 未被标记,且标记阶段已过 ➔ C 会被错误回收!
2. 写屏障的作用
写屏障是一种 拦截机制 ,当 JavaScript 代码修改对象引用时,触发额外的逻辑,确保标记的正确性。
V8 的实现方式是:在写入对象属性时,检查是否符合特定条件,并将目标对象标记为灰色。
关键规则:
若一个黑色对象(已标记完成)新增了一个白色对象的引用,则将该白色对象标记为灰色。
3. 写屏障的伪代码实现
javascript
// 当执行 obj.field = value 时
function writeBarrier(obj, field, value) {
// 记录旧值(某些算法需要处理旧引用)
const oldValue = obj[field];
// 写入新值
obj[field] = value;
// 触发屏障逻辑
if (isMarkingInProgress() && // 当前处于标记阶段
isBlack(obj) && // 写入者(obj)是黑色
isWhite(value)) { // 被写入的值(value)是白色
markGray(value); // 将 value 标记为灰色
}
}
总结
阶段 | 技术手段 | 目标 | 适用场景 |
---|---|---|---|
分代内存 | 新生代 + 老生代 | 针对性优化回收效率 | 所有对象 |
新生代回收 | Scavenge 算法 | 快速回收短期对象 | 小对象、高分配率 |
老生代回收 | 标记-清除/整理 | 处理长期存活对象 | 大对象、低死亡率 |
增量标记 | 三色标记 + 写屏障 | 减少主线程阻塞 | 大型应用、低延迟需求 |
并发回收 | 多线程并行 | 最大化利用 CPU 资源 | 多核环境 |