创见博客
JavaScript的垃圾回收算法
七崽爱吃小饼干2026/01/23阅读 0专栏 JavaScript

首先要明确:JavaScript 是自动垃圾回收语言,不需要开发者手动分配/释放内存(不像 C/C++ 需要 malloc/free),垃圾回收的核心目标是找出内存中不再被使用的对象,释放其占用的内存,避免内存泄漏和内存溢出。

在讲解算法前,先铺垫一个基础概念:什么是「不再被使用的对象」? 简单说,就是无法通过任何「可达路径」访问到的对象。 「可达性」是 JS 垃圾回收的核心判断标准,常见的「根(Root)」对象(绝对不会被回收)包括:

  1. 全局对象(浏览器中的 window、Node.js 中的 global)
  2. 当前执行栈中的变量(正在执行的函数的局部变量、参数)
  3. 事件循环队列中的回调函数(待执行的宏任务、微任务)
  4. DOM 节点(仅浏览器环境,与 DOM 树关联的节点)

从这些根对象出发,能逐层访问到的对象就是「可达对象」(需要保留),反之则是「垃圾对象」(需要回收)。


一、JS 早期核心垃圾回收算法(基础)

早期 JS 引擎(如早期 Chrome V8、IE 引擎)主要使用两种基础算法,分别对应不同场景的对象。

1. 标记-清除(Mark-and-Sweep)

这是 JS 垃圾回收的基础核心算法,现代 JS 引擎的 GC 都是基于它进行优化扩展的,适用于所有对象(尤其是复杂对象、大对象)。

工作流程(分两步,对应算法名称)

  1. 标记阶段(Mark):

    • 垃圾回收器从所有「根对象」出发,遍历所有可达对象。
    • 给每个可达对象打上「存活标记」(可以理解为给对象贴一个“不回收”的标签)。
    • 遍历过程中,会深入对象的属性、数组的元素,确保所有可达的子对象都被标记。
  2. 清除阶段(Sweep):

    • 垃圾回收器遍历整个内存空间(堆内存,JS 对象主要存在于堆中)。
    • 清除所有没有被打上存活标记的对象,释放其占用的内存空间,将这些内存标记为「空闲可用」。

示意图(简化)

// 初始内存:有 A、B、C、D 四个对象,根能访问到 A、B
根 -> A -> C
根 -> B

// 标记阶段:A、B、C 被标记(存活),D 无标记(垃圾)
[标记] A、[标记] B、[标记] C、[未标记] D

// 清除阶段:D 被回收,内存释放
[存活] A、[存活] B、[存活] C、[空闲] 原 D 内存

优点 & 缺点

优点缺点
实现简单,逻辑清晰1. 清除后会产生内存碎片(空闲内存是不连续的小块,后续分配大对象时,可能明明总空闲内存足够,却无法找到连续空间,不得不提前触发下一次 GC)
适用于任意类型对象,无类型限制2. 标记和清除过程需要暂停应用程序(Stop-The-World,简称 STW),如果内存中对象数量庞大,STW 时间会较长,影响页面流畅度

2. 引用计数(Reference Counting)

这是早期用于优化「简单对象」的辅助算法,现在已基本被淘汰(仅部分老旧环境或特殊场景可能残留),核心思路是统计对象被引用的次数,次数为 0 时自动回收。

工作流程

  1. 每个对象都有一个「引用计数器」,记录当前被其他对象引用的次数。
  2. 当一个对象被新的变量/对象引用时,计数器 +1。
  3. 当一个引用被销毁时(如变量赋值为 null、变量超出作用域),计数器 -1。
  4. 当计数器的值变为 0 时,说明该对象不再被任何地方引用,立即回收其内存。

示例代码

javascript
// 1. 创建对象,引用计数器 = 1(变量 obj1 引用它)
let obj1 = { name: "测试" };

// 2. 新增引用,计数器 = 2(变量 obj2 引用同一个对象)
let obj2 = obj1;

// 3. 销毁 obj1 引用,计数器 = 1
obj1 = null;

// 4. 销毁 obj2 引用,计数器 = 0 → 触发垃圾回收,释放该对象内存
obj2 = null;

优点 & 缺点

优点缺点
无需等待标记/清除阶段,垃圾对象会被立即回收,内存利用率高1. 无法解决循环引用问题(这是致命缺陷,也是被淘汰的核心原因)
无需大面积遍历内存,STW 时间极短2. 计数器需要持续维护(每次引用增减都要更新),有额外性能开销
不会产生内存碎片3. 无法处理「间接可达但无直接引用」的复杂场景

致命缺陷:循环引用

两个对象互相引用,导致它们的引用计数器永远大于 0,即使不再被根对象可达,也无法被回收,最终造成内存泄漏。

示例代码(循环引用):

javascript
function createCycle() {
  let a = {};
  let b = {};
  
  // a 引用 b,b 引用 a → 循环引用
  a.next = b;
  b.prev = a;
}

createCycle();
// 函数执行完毕后,a、b 均超出作用域,根无法访问到它们
// 但由于互相引用,引用计数器均为 1,无法被引用计数算法回收
// (标记-清除算法可以解决这个问题,因为它只关心可达性,不关心引用次数)

二、现代 JS 引擎的优化方案(基于基础算法扩展)

现代 JS 引擎(如 Chrome V8、Firefox SpiderMonkey)不再单纯使用基础算法,而是结合了多种优化策略,核心目标是减少 STW 时间、提高回收效率、解决内存碎片问题。

其中,V8 引擎的方案最具代表性,我们以 V8 为例讲解核心优化点。

1. 分代回收(Generational Garbage Collection)

核心依据:JS 中的对象大多是「短命的」(创建后很快就不再被使用,如函数内部的局部变量),只有少数对象是「长命的」(如全局对象、持久化缓存)。

V8 将堆内存分为两个区域,针对不同区域使用不同的 GC 策略,提高回收效率:

  • 新生代(Young Generation):存储「短命对象」,内存空间较小(通常几 MB 到几十 MB)。
  • 老生代(Old Generation):存储「长命对象」(从新生代存活下来的对象),内存空间较大(几百 MB 到 GB 级别)。

(1)新生代:Scavenge 算法(复制-清除)

新生代采用 Scavenge 算法(基于「复制」思想),核心是将新生代内存再划分为两个大小相等的子区域:「From 空间」(当前使用区)和「To 空间」(空闲区)。

工作流程:

  1. 新创建的对象优先分配到「From 空间」。
  2. 当「From 空间」快被占满时,触发新生代 GC(Minor GC):
    • 标记:遍历「From 空间」中的可达对象。
    • 复制:将所有标记为存活的对象,按顺序复制到「To 空间」的连续内存地址(自动解决内存碎片问题)。
    • 交换:清除「From 空间」的所有内容,然后交换「From 空间」和「To 空间」的角色(原来的 To 变成新的 From,原来的 From 变成新的 To,等待下一次 Minor GC)。
  3. 额外规则:如果一个对象在新生代中存活过多次 Minor GC(通常是 2 次),说明它可能是「长命对象」,会被转移到「老生代」(这个过程称为「晋升」)。

优点:

  • 复制过程简单,效率极高(新生代内存小,对象数量少)。
  • 复制后内存是连续的,无内存碎片。
  • STW 时间极短,几乎不影响页面流畅度。

缺点:

  • 内存利用率低,只能使用新生代内存的 50%(因为总有一个 To 空间是空闲的)—— 但由于新生代内存本身很小,这个代价是可接受的。

(2)老生代:标记-清除 + 标记-整理(Mark-Compact)

老生代存储长命对象,内存空间大,对象数量多,不适合使用 Scavenge 算法(内存利用率太低),因此采用「标记-清除」为主,「标记-整理」为辅的策略。

  • 首先使用 标记-清除 进行常规回收:解决大部分垃圾对象,优点是速度较快。
  • 当内存碎片过多,无法分配大对象时,触发 标记-整理(Mark-Compact) 算法:
    1. 标记阶段:和标记-清除一致,标记所有可达对象。
    2. 整理阶段:不是直接清除垃圾对象,而是将所有存活对象向内存的一端移动,挤在一起形成连续的存活内存块。
    3. 清除阶段:释放存活对象另一端的所有空闲内存。

标记-整理的优点是解决了内存碎片问题,缺点是整理过程需要移动对象,性能开销比标记-清除更高,因此只在必要时触发。

2. 增量标记(Incremental Marking)

核心目标:解决标记-清除/标记-整理的长时 STW 问题。

传统的标记阶段是「一次性完成」的,当老生代对象数量庞大时,STW 可能达到几百毫秒,页面会出现明显卡顿。

增量标记的思路是:将整个标记过程拆分成多个小步骤,穿插在 JS 应用程序的执行间隙中进行,每完成一个小步骤,就把执行权还给应用程序,直到所有标记步骤完成。

流程示意图:

[JS 执行] → [标记步骤 1] → [JS 执行] → [标记步骤 2] → [JS 执行] → ... → [标记步骤 N 完成] → [清除/整理阶段]

这样一来,单次 STW 时间被缩短到几毫秒以内,用户几乎感知不到页面卡顿,大幅提升了流畅度。

3. 并发标记 & 并行回收

这是增量标记的进一步优化,是现代 V8 引擎的核心特性(V8 后续引入的 Concurrent Marking 和 Parallel GC):

  • 并行回收(Parallel):在 STW 阶段,启用多个 GC 线程同时执行标记/清除/整理操作,缩短单次 STW 的总时间(相当于“多人一起干活,比单人干活快”)。
  • 并发标记(Concurrent):GC 线程在标记对象时,不需要暂停 JS 执行线程,两者可以同时运行(仅在极少数关键节点短暂暂停 JS 线程)。这是目前最优的 GC 策略,进一步降低了 STW 对应用程序的影响。

三、为什么一定要区分新生代和老生代?

我们先反过来想:如果不区分两代,所有对象都放在同一个内存区域,使用同一种 GC 算法(比如标记-清除),会出现什么问题?

  1. 效率极低,浪费大量资源 JS 中存在一个普遍规律:超过 90% 的对象都是「短命鬼」(比如函数内部的临时变量,函数执行完毕后就不再被使用),只有不到 10% 的对象能长期存活。 如果不分代,每次 GC 都要遍历「所有对象」(包括那些长命的、几乎不需要回收的对象),哪怕只是为了回收那 90% 的短命对象,也要花费大量时间遍历 10% 的长命对象,这是极大的性能浪费。

  2. 无法兼顾「高效回收」和「内存利用率」 不同的 GC 算法各有优劣,没有一种算法能同时满足「快」、「无碎片」、「高内存利用率」:

    • Scavenge 算法(复制思想)快、无碎片,但内存利用率只有 50%;
    • 标记-清除算法内存利用率高,但慢、会产生碎片;
    • 标记-整理算法无碎片、利用率高,但最慢。 如果不分代,只能选一种算法妥协;分代后,就能给不同生命周期的对象「量身定制」最优算法,实现整体性能最优。

简单说:分代回收是「对症下药」,比「一刀切」的回收方式效率高得多。


新生代 vs 老生代

对比维度新生代(Young Generation)老生代(Old Generation)
核心特征(根本)生命周期短,创建后很快被废弃(90% 以上对象)生命周期长,多次 GC 后仍存活(不足 10% 对象)
外在表现(你的理解)更新频繁、临时创建(局部变量、临时对象)更新较少、长期存在(全局对象、缓存、DOM 关联对象)
内存空间大小较小(通常几 MB ~ 几十 MB)较大(几百 MB ~ GB 级别)
采用的 GC 算法Scavenge 算法(复制-清除)标记-清除 + 标记-整理(必要时)
GC 触发频率较高(因为对象创建快、废弃快,From 空间易满)较低(对象存活久,垃圾积累慢)
GC 特点(优势)速度极快,STW 时间极短,无内存碎片内存利用率高,适合存储大对象,解决碎片问题
额外机制对象存活 2 次左右 GC 会「晋升」到老生代增量标记、并发/并行回收优化,减少 STW 影响

为什么新生代不用老生代的算法,反之亦然?

这能帮你更理解「分代」的必要性,避免混淆:

  1. 新生代为什么不用标记-清除? 新生代对象多、生命周期短、内存空间小,标记-清除需要遍历所有对象再清除,效率不如 Scavenge 算法的「复制存活对象」(毕竟存活对象只占少数,复制少量对象比清除大量垃圾更快);而且 Scavenge 复制后无碎片,对于频繁创建/销毁的临时对象,后续分配内存也更高效。 另外,新生代内存小,即使 Scavenge 算法只有 50% 的内存利用率,整体浪费的内存也很少(几 MB),完全可以接受。

  2. 老生代为什么不用 Scavenge 算法? 老生代内存空间大(几百 MB),如果用 Scavenge 算法,会浪费一半的内存(几百 MB),内存利用率过低,这是无法接受的;而且老生代存活对象占绝大多数,复制大量存活对象的开销会远大于标记-清除,效率极低。 因此老生代选择「标记-清除」为主,只有内存碎片过多时才用「标记-整理」,兼顾效率和内存利用率。

四、补充:常见的内存泄漏场景(GC 无法解决的问题)

垃圾回收不是万能的,如果你写出了「让垃圾对象保持可达性」的代码,GC 无法识别它是“无用的”,会导致内存泄漏(内存占用持续升高),常见场景:

  1. 全局变量意外创建(如未声明的变量、挂载在 window 上的无用属性)。
  2. 未清除的定时器/事件监听(如 setInterval 未调用 clearInterval,DOM 节点删除后未移除对应的 addEventListener)。
  3. 闭包滥用(闭包持有外部函数的变量,导致变量无法被回收)。
  4. 循环引用(虽然标记-清除算法能解决,但如果循环引用的对象始终可达,依然会泄漏)。

总结

  1. JS 垃圾回收的核心判断标准是对象的可达性,根对象可达的对象会被保留,反之则被回收。
  2. 基础算法有「标记-清除」(核心,解决循环引用)和「引用计数」(已淘汰,存在循环引用致命缺陷)。
  3. 现代引擎(如 V8)的核心优化是「分代回收」,新生代用 Scavenge 算法(高效、无碎片),老生代用标记-清除+标记-整理(解决大对象和内存碎片),再配合增量标记、并发/并行回收减少 STW 影响。
  4. GC 自动执行但非万能,不合理的代码会导致内存泄漏,需要开发者规避。
评论
0/100