JavaScript 中的垃圾回收:V8 引擎的 Minor GC(Scavenger) 与 Major GC(Mark-Compact)协同工作机制
字数 2309 2025-12-09 01:48:19

JavaScript 中的垃圾回收:V8 引擎的 Minor GC(Scavenger) 与 Major GC(Mark-Compact)协同工作机制

在 V8 引擎的垃圾回收中,Minor GC(副垃圾回收)Major GC(主垃圾回收) 是基于“代际假说”的两个核心回收过程,它们协同工作以高效管理内存。我会从基础概念开始,逐步深入它们的工作细节、协同机制和触发时机。


1. 核心背景:代际假说与分代内存布局

代际假说指出:

  • 大多数对象生命周期很短(很快变得不可访问)。
  • 少数对象会存活较长时间。

基于此,V8 将堆内存分为两代

  • 新生代:存放新创建的对象。特点是对象存活时间短、区域小、垃圾回收频繁。
  • 老生代:存放存活时间较长的对象(从新生代晋升而来)。特点是对象存活时间长、区域大、垃圾回收不频繁。

2. 新生代回收:Minor GC(Scavenger)

目标:快速回收新生代中的短暂存活对象。
算法Cheney 算法(一种复制算法,采用“半空间”设计)。

步骤详解:

步骤 1:内存划分
新生代被划分为两个相等大小的半空间From-Space(使用中)和 To-Space(闲置)。对象始终只在 From-Space 中分配。

步骤 2:对象分配
新对象在 From-Space 中按顺序分配。当 From-Space 被填满时,触发 Minor GC。

步骤 3:标记活跃对象
根对象(全局对象、当前函数作用域链等)出发,遍历引用链,标记 From-Space 中所有可访问的对象为“活跃对象”。

步骤 4:复制活跃对象

  • From-Space 中的活跃对象复制到 To-Space 中,并紧密排列(消除内存碎片)。
  • 复制过程中,会更新对象的指针,使其指向 To-Space 中的新地址。

步骤 5:更新引用指针

  • 由于对象被移动,所有指向这些对象的指针(包括老生代中的指针)都必须更新。V8 通过写屏障机制来记录跨代引用,确保引用正确更新。

步骤 6:角色交换
复制完成后,清空 From-Space 并交换 From-SpaceTo-Space 的角色。现在,To-Space 变为新的使用空间,From-Space 变为闲置空间。

步骤 7:对象晋升
在复制过程中,如果一个对象满足以下任一条件,会被晋升到老生代:

  • 对象已经经历过一次 Minor GC 且仍然存活。
  • 复制时,To-Space 空间不足(避免无限制复制)。

3. 老生代回收:Major GC(Mark-Compact)

目标:回收老生代中长期存活但已死亡的对象。
算法:结合标记-清除标记-压缩,通常使用“增量标记”和“惰性清理”优化。

步骤详解:

步骤 1:标记阶段

  • 从根对象出发,遍历所有可达对象,将它们标记为“活跃”。
  • 由于老生代对象多,V8 使用增量标记:将标记过程分解为多个小步骤,穿插在 JavaScript 执行中,避免长时间停顿。

步骤 2:清除/压缩阶段

  • 清除未被标记的对象(死亡对象),释放内存。
  • 但频繁清除会产生内存碎片,因此 V8 会周期性执行标记-压缩
    • 将所有活跃对象向内存一端移动。
    • 更新所有引用指针。
    • 释放另一端连续内存,避免碎片。

步骤 3:写屏障的作用

  • 在 Minor GC 和 Major GC 之间,写屏障 记录从老生代到新生代的引用,确保跨代引用的正确性。
  • 在 Major GC 标记时,写屏障记录的引用会被作为“额外根”来扫描。

4. Minor GC 与 Major GC 的协同工作机制

触发时机:

  • Minor GC:当新生代 From-Space 满时触发,频繁且快速。
  • Major GC:当老生代内存达到阈值时触发(或由调度算法决定),较慢但全面。

协同流程:

  1. 新对象在新生代分配 → 反复经历 Minor GC。
  2. 存活对象晋升到老生代。
  3. 老生代逐渐积累,触发 Major GC。
  4. Major GC 会同时处理新生代和老生代,但以老生代为主:
    • 它从根对象开始标记,包括新生代对象。
    • 由于跨代引用的存在,写屏障确保不会遗漏。

避免全堆回收:

  • 通常 Major GC 只标记和清理老生代,但如果内存压力大,V8 可能执行全堆垃圾回收(包含两代)。
  • 优化:V8 的 Orinoco 项目 实现了并发标记和并行清理,减少主线程停顿。

5. 举例说明协同过程

假设我们有以下代码:

function createObjects() {
  let shortLived = { name: "temp" }; // 新生代分配
  let longLived = { data: new Array(1000) }; // 新生代分配,但较大
  return longLived; // longLived 被返回,可能被外部引用
}
let globalObj = createObjects();
  • 初始:shortLivedlongLived 在新生代。
  • Minor GC 触发
    • shortLived 在函数结束后不可达 → 被回收。
    • longLivedglobalObj 引用 → 活跃,晋升到老生代。
  • 后续:longLived 在老生代长期存在,直到不可达时由 Major GC 回收。

6. 总结与关键点

  • Minor GC:快速、频繁,使用复制算法处理新生代,存活对象晋升。
  • Major GC:较慢、全面,使用标记-清除/压缩处理老生代,避免碎片。
  • 协同核心
    • 代际分离:适应对象生命周期差异。
    • 写屏障:维护跨代引用正确性。
    • 晋升机制:连接两代回收。
  • 优化目标:减少主线程停顿,提高应用响应速度。

通过这种分代与协同设计,V8 在内存回收效率和应用性能之间取得了平衡。理解这一机制有助于编写内存友好的 JavaScript 代码,并能在性能调优时预判垃圾回收行为。

JavaScript 中的垃圾回收:V8 引擎的 Minor GC(Scavenger) 与 Major GC(Mark-Compact)协同工作机制 在 V8 引擎的垃圾回收中, Minor GC(副垃圾回收) 和 Major GC(主垃圾回收) 是基于“代际假说”的两个核心回收过程,它们协同工作以高效管理内存。我会从基础概念开始,逐步深入它们的工作细节、协同机制和触发时机。 1. 核心背景:代际假说与分代内存布局 代际假说 指出: 大多数对象生命周期很短(很快变得不可访问)。 少数对象会存活较长时间。 基于此,V8 将堆内存分为 两代 : 新生代 :存放新创建的对象。特点是对象存活时间短、区域小、垃圾回收频繁。 老生代 :存放存活时间较长的对象(从新生代晋升而来)。特点是对象存活时间长、区域大、垃圾回收不频繁。 2. 新生代回收:Minor GC(Scavenger) 目标 :快速回收新生代中的短暂存活对象。 算法 : Cheney 算法 (一种复制算法,采用“半空间”设计)。 步骤详解: 步骤 1:内存划分 新生代被划分为两个相等大小的 半空间 : From-Space (使用中)和 To-Space (闲置)。对象始终只在 From-Space 中分配。 步骤 2:对象分配 新对象在 From-Space 中按顺序分配。当 From-Space 被填满时,触发 Minor GC。 步骤 3:标记活跃对象 从 根对象 (全局对象、当前函数作用域链等)出发,遍历引用链,标记 From-Space 中所有可访问的对象为“活跃对象”。 步骤 4:复制活跃对象 将 From-Space 中的 活跃对象 复制到 To-Space 中,并紧密排列(消除内存碎片)。 复制过程中,会更新对象的指针,使其指向 To-Space 中的新地址。 步骤 5:更新引用指针 由于对象被移动,所有指向这些对象的指针(包括老生代中的指针)都必须更新。V8 通过 写屏障 机制来记录跨代引用,确保引用正确更新。 步骤 6:角色交换 复制完成后,清空 From-Space 并交换 From-Space 和 To-Space 的角色。现在, To-Space 变为新的使用空间, From-Space 变为闲置空间。 步骤 7:对象晋升 在复制过程中,如果一个对象满足以下任一条件,会被 晋升 到老生代: 对象已经经历过一次 Minor GC 且仍然存活。 复制时, To-Space 空间不足(避免无限制复制)。 3. 老生代回收:Major GC(Mark-Compact) 目标 :回收老生代中长期存活但已死亡的对象。 算法 :结合 标记-清除 和 标记-压缩 ,通常使用“增量标记”和“惰性清理”优化。 步骤详解: 步骤 1:标记阶段 从根对象出发,遍历所有可达对象,将它们标记为“活跃”。 由于老生代对象多,V8 使用 增量标记 :将标记过程分解为多个小步骤,穿插在 JavaScript 执行中,避免长时间停顿。 步骤 2:清除/压缩阶段 清除未被标记的对象(死亡对象),释放内存。 但频繁清除会产生 内存碎片 ,因此 V8 会周期性执行 标记-压缩 : 将所有活跃对象向内存一端移动。 更新所有引用指针。 释放另一端连续内存,避免碎片。 步骤 3:写屏障的作用 在 Minor GC 和 Major GC 之间, 写屏障 记录从老生代到新生代的引用,确保跨代引用的正确性。 在 Major GC 标记时,写屏障记录的引用会被作为“额外根”来扫描。 4. Minor GC 与 Major GC 的协同工作机制 触发时机: Minor GC :当新生代 From-Space 满时触发,频繁且快速。 Major GC :当老生代内存达到阈值时触发(或由调度算法决定),较慢但全面。 协同流程: 新对象在新生代分配 → 反复经历 Minor GC。 存活对象晋升到老生代。 老生代逐渐积累,触发 Major GC。 Major GC 会 同时处理新生代和老生代 ,但以老生代为主: 它从根对象开始标记,包括新生代对象。 由于跨代引用的存在,写屏障确保不会遗漏。 避免全堆回收: 通常 Major GC 只标记和清理老生代,但如果内存压力大,V8 可能执行 全堆垃圾回收 (包含两代)。 优化:V8 的 Orinoco 项目 实现了并发标记和并行清理,减少主线程停顿。 5. 举例说明协同过程 假设我们有以下代码: 初始: shortLived 和 longLived 在新生代。 Minor GC 触发 : shortLived 在函数结束后不可达 → 被回收。 longLived 被 globalObj 引用 → 活跃,晋升到老生代。 后续: longLived 在老生代长期存在,直到不可达时由 Major GC 回收。 6. 总结与关键点 Minor GC :快速、频繁,使用复制算法处理新生代,存活对象晋升。 Major GC :较慢、全面,使用标记-清除/压缩处理老生代,避免碎片。 协同核心 : 代际分离:适应对象生命周期差异。 写屏障:维护跨代引用正确性。 晋升机制:连接两代回收。 优化目标 :减少主线程停顿,提高应用响应速度。 通过这种分代与协同设计,V8 在内存回收效率和应用性能之间取得了平衡。理解这一机制有助于编写内存友好的 JavaScript 代码,并能在性能调优时预判垃圾回收行为。