在 JavaScript 的世界中,开发者通常无需像在 C/C++ 等语言中那样手动分配和释放内存。这一便利得益于 JavaScript 引擎内置的垃圾回收器 (Garbage Collector, GC)。垃圾回收器是一个自动化的内存管理机制,它负责识别并回收程序中不再使用的内存,从而避免内存泄漏和程序崩溃。

然而,“自动化”并不意味着我们可以对内存管理一无所知。深入理解 JavaScript 垃圾回收的工作原理,包括它如何判断“垃圾”、采用了哪些核心算法以及现代引擎是如何优化这些过程的,对于编写高性能、稳定且无内存泄漏的 JavaScript 应用程序至关重要。

本指南将带您全面探索 JavaScript 垃圾回收的奥秘。我们将从垃圾回收的必要性开始,详细阐述其核心概念——可达性 (Reachability)。随后,我们将深入剖析最主要的垃圾回收算法:标记-清除 (Mark-and-Sweep),并揭示其如何解决传统引用计数算法的局限性。更进一步,我们将介绍现代 JavaScript 引擎(如 V8)如何通过分代回收 (Generational Collection)、增量回收和并发回收等高级优化手段,显著提升垃圾回收的效率并减少对应用性能的影响。理解这些机制将赋能您编写出更“垃圾回收友好”的代码,从而有效避免内存泄漏和优化应用程序的运行效率。


前置知识

为了更好地理解本指南,建议您具备以下基础知识:

  • JavaScript 基础: 熟悉变量、数据类型(特别是原始值与对象)、函数、作用域和闭包等核心概念。
  • 内存基础: 对栈 (Stack) 和堆 (Heap) 等内存区域有基本了解(非必需,但有益)。

目录

  1. 什么是垃圾回收?为什么需要它?
    • 1.1 手动内存管理的挑战
    • 1.2 自动垃圾回收的优势
  2. “垃圾”的定义:可达性 (Reachability)
    • 2.1 什么是“根” (Roots)?
    • 2.2 可达性原则
  3. 垃圾回收算法:从引用计数到标记-清除
    • 3.1 引用计数 (Reference Counting)
      • 3.1.1 工作原理
      • 3.1.2 致命缺陷:循环引用 (Circular References)
    • 3.2 标记-清除 (Mark-and-Sweep) 算法: JavaScript 的核心机制
      • 3.2.1 标记阶段 (Marking Phase)
      • 3.2.2 清除阶段 (Sweeping Phase)
      • 3.2.3 标记-清除的优势 (解决循环引用)
      • 3.2.4 标记-清除的局限性 (内存碎片、“Stop-the-World”暂停)
  4. 现代 JavaScript 引擎的垃圾回收优化 (以 V8 为例)
    • 4.1 分代回收 (Generational Collection)
      • 4.1.1 新生代 (Young Generation / Young Space) 与 Scavenge 算法 (Minor GC)
      • 4.1.2 老生代 (Old Generation / Old Space) 与 Mark-Sweep-Compact 算法 (Major GC)
    • 4.2 增量回收 (Incremental GC)
    • 4.3 并发回收 (Concurrent GC)
    • 4.4 惰性清除 (Lazy Sweeping)
  5. 垃圾回收与内存泄漏
    • 5.1 内存泄漏的本质:可达但无用的对象
    • 5.2 垃圾回收无法处理的“假可达”场景 (简述)
  6. 垃圾回收对性能的影响
    • 6.1 “Stop-the-World”暂停
    • 6.2 优化的作用
  7. 开发者的职责:编写“垃圾回收友好”的代码
    • 7.1 避免创建不必要的对象
    • 7.2 及时解除不再需要的引用
    • 7.3 使用 WeakMapWeakSet
  8. 总结
  9. 附录:常见问题

1. 什么是垃圾回收?为什么需要它?

1.1 手动内存管理的挑战

在没有自动垃圾回收的语言(如 C/C++)中,程序员必须手动管理内存:

  • 分配内存: 使用 malloc 等函数请求操作系统分配内存。
  • 释放内存: 使用 free 等函数在不再需要时将内存归还给操作系统。

手动管理内存存在以下常见挑战:

  • 内存泄漏 (Memory Leak): 忘记释放不再使用的内存,导致内存占用持续增长,最终耗尽系统资源。
  • 悬空指针 (Dangling Pointer): 释放内存后,指针仍指向该区域,如果后续访问该指针,可能导致不可预测的行为或程序崩溃。
  • 二次释放 (Double Free): 尝试释放已被释放的内存,同样可能导致程序不稳定。
1.2 自动垃圾回收的优势

为了解决手动内存管理的复杂性和错误倾向,许多现代语言(包括 JavaScript、Java、Python、Go 等)引入了自动垃圾回收机制。

  • 简化开发: 开发者无需关心内存的分配和释放细节,可以更专注于业务逻辑。
  • 提高可靠性: 减少了因内存管理不当导致的错误,如内存泄漏、悬空指针等。
  • 安全性: 有助于防止一些安全漏洞,这些漏洞可能利用内存错误进行攻击。

垃圾回收器的核心任务是:自动识别程序中不再使用的内存(即“垃圾”),并将其回收以供后续使用。


2. “垃圾”的定义:可达性 (Reachability)

在 JavaScript 垃圾回收机制中,“垃圾”并不是指那些没有被任何变量引用的对象。更准确地说,一个对象是否是“垃圾”取决于它是否可达 (reachable)

2.1 什么是“根” (Roots)?

垃圾回收器不依赖简单的引用计数,而是从一组被称为**“根” (Roots)** 的特殊对象开始追溯。根是那些总是可达的、不能被垃圾回收器回收的对象。主要包括:

  • 全局对象: 例如,在浏览器环境中是 window 对象,在 Node.js 环境中是 global 对象。所有直接定义在全局作用域的变量都挂在这些对象上。
  • 当前执行栈中的变量: 当前正在执行的函数中声明的局部变量和参数。
  • 一些特殊对象: 例如,DOM 元素如果被页面持有,即使 JavaScript 中没有直接变量引用它,它也是可达的。
2.2 可达性原则

一个对象被认为是“可达的”,如果它满足以下任何一个条件:

  1. 它是本身。
  2. 它被任何一个可达的对象引用(直接引用或通过引用链间接引用)。

换句话说,如果从任何一个根开始,通过遍历所有对象的引用链,能够最终访问到某个对象,那么这个对象就是可达的。所有不可达的对象,都被认为是“垃圾”,可以被回收。

let user = {
  name: "John"
}; // user 是根可达的 (通过全局变量 user)

let admin = user; // admin 引用 user,user 仍然可达

user = null; // user 变量不再引用对象,但 admin 变量仍然引用着,所以对象仍然可达

admin = null; // 现在没有任何根引用这个对象了,它变得不可达,成为垃圾。

3. 垃圾回收算法:从引用计数到标记-清除

历史上,以及在不同的编程语言中,有多种垃圾回收算法。JavaScript 引擎主要依赖标记-清除算法及其优化版本。

3.1 引用计数 (Reference Counting)
  • 工作原理: 最简单的垃圾回收算法。它跟踪每个对象被引用的次数。

    • 当一个对象被引用时,其引用计数加 1。
    • 当一个引用被移除时(例如变量被重新赋值或离开作用域),其引用计数减 1。
    • 当对象的引用计数变为零时,认为该对象不再被使用,可以被回收。
  • 示例:

    let obj1 = { data: 'A' }; // obj1 引用计数: 1
    let obj2 = obj1;          // obj1 引用计数: 2
    let obj3 = obj2;          // obj1 引用计数: 3
    
    obj1 = null;              // obj1 引用计数: 2
    obj2 = null;              // obj1 引用计数: 1
    obj3 = null;              // obj1 引用计数: 0,此时可以被回收
    
  • 致命缺陷:循环引用 (Circular References)
    引用计数的主要问题是它无法处理循环引用。如果两个或多个对象相互引用,即使它们都已不再被任何根对象引用,它们的引用计数也永远不会变为零,从而导致它们永远不会被回收,造成内存泄漏。

    let objA = {};
    let objB = {};
    
    objA.propB = objB; // objB 的引用计数为 1
    objB.propA = objA; // objA 的引用计数为 1
    
    // 此时,即使我们将 objA 和 objB 的外部引用设为 null
    objA = null; // objA 的引用计数仍为 1 (因为 objB.propA 还在引用它)
    objB = null; // objB 的引用计数仍为 1 (因为 objA.propB 还在引用它)
    
    // 根据引用计数算法,objA 和 objB 无法被回收,因为它们的引用计数都是 1。
    // 这就导致了内存泄漏。
    

    由于这个缺陷,现代 JavaScript 引擎不再使用引用计数作为主要的垃圾回收算法。

3.2 标记-清除 (Mark-and-Sweep) 算法: JavaScript 的核心机制

标记-清除是 JavaScript 引擎中最主要的垃圾回收算法。它有效地解决了引用计数的循环引用问题。

  • 工作原理: 垃圾回收器执行时分为两个主要阶段。

    3.2.1 标记阶段 (Marking Phase)
    1. 垃圾回收器从所有的**“根”对象**开始。
    2. 它遍历这些根对象,并标记所有直接或间接从根可达的对象为“活跃”或“可达”。
    3. 这个过程会遍历整个对象图,所有被访问到的对象都会被标记。
    3.2.2 清除阶段 (Sweeping Phase)
    1. 垃圾回收器再次遍历整个堆内存。
    2. 所有在标记阶段没有被标记为“活跃”的对象,都被认为是“垃圾”。
    3. 垃圾回收器会清除这些未被标记的对象,将它们占用的内存空间回收,并归还给操作系统。
  • 标记-清除的优势 (解决循环引用)
    标记-清除算法的核心优势在于它能够正确处理循环引用。因为它只关心对象是否从根可达,而不是简单的引用计数。在上面的循环引用例子中,一旦 objA = null;objB = null;,那么从任何根对象都无法再访问到 objAobjB 形成的循环。因此,在标记阶段,它们都不会被标记,最终在清除阶段被回收。

  • 标记-清除的局限性 (原始形式)
    虽然解决了循环引用,但原始的标记-清除算法也有其自身的局限性:

    1. “Stop-the-World”暂停: 在标记和清除这两个阶段,应用程序的执行通常会暂停,等待垃圾回收完成。这可能导致用户界面卡顿或响应延迟,影响用户体验(特别是对于大型应用程序和老生代回收)。
    2. 内存碎片 (Memory Fragmentation): 清除不活跃对象后,内存空间中可能会留下大量不连续的、小的空闲块。如果后续需要分配一个较大的对象,即使总空闲内存足够,也可能找不到足够大的连续空间,导致分配失败或效率降低。

4. 现代 JavaScript 引擎的垃圾回收优化 (以 V8 为例)

为了克服标记-清除的局限性并提高垃圾回收的效率,现代 JavaScript 引擎(如 Chrome 的 V8 引擎、Firefox 的 SpiderMonkey 等)采用了多种高级优化策略。其中最主要的是分代回收

4.1 分代回收 (Generational Collection)

基于一个重要的经验法则:“大多数对象生命周期都很短,而少数对象生命周期很长。” 分代回收将堆内存分为不同的“代”,并对不同代的内存区域采用不同的回收策略。

4.1.1 新生代 (Young Generation / Young Space) 与 Scavenge 算法 (Minor GC)
  • 特点: 存放新创建的对象。这些对象通常生命周期很短(例如,函数执行过程中创建的临时变量)。
  • 回收策略:
    • 采用Scavenge 算法,它是一种复制算法,速度快但空间效率相对较低。
    • 新生代内存被分为两个大小相等的半区:From 空间和 To 空间。
    • 新对象通常分配在 From 空间。
    • 每次垃圾回收(称为 Minor GC)时:
      1. Scavenge 算法从 From 空间中标记出所有存活的对象。
      2. 将这些存活的对象复制到 To 空间,并按序排列,从而避免内存碎片。
      3. 清除整个 From 空间。
      4. 交换 FromTo 空间的角色。
    • 晋升 (Promotion): 经过多次(通常是 1-2 次)Scavenge 回收仍然存活的对象,被认为是“老”对象,会被晋升到老生代。
  • 频率与暂停: 新生代的垃圾回收频率非常高,但每次执行的耗时很短,因此造成的“Stop-the-World”暂停时间很短。
4.1.2 老生代 (Old Generation / Old Space) 与 Mark-Sweep-Compact 算法 (Major GC)
  • 特点: 存放从新生代晋升过来的对象,以及一些直接分配在老生代(例如大型对象)的生命周期较长的对象。
  • 回收策略:
    • 采用更复杂的 Mark-Sweep-Compact 算法(在一些资料中也简称为 Mark-Sweep,但内部包含 Compact 优化),用于 Major GC
    • Mark (标记): 从根开始遍历,标记所有可达的活跃对象。
    • Sweep (清除): 清除所有未被标记的非活跃对象。
    • Compact (整理): 为了解决内存碎片问题,在清除之后,将所有存活的对象向一端移动,形成更大的连续空闲空间。
  • 频率与暂停: 老生代的垃圾回收频率较低,但由于涉及的内存空间更大、对象更多,且包含整理阶段,因此每次执行的耗时相对较长,可能会导致较明显的“Stop-the-World”暂停。
4.2 增量回收 (Incremental GC)

为了进一步减少“Stop-the-World”暂停,现代 GC 引入了增量回收

  • 原理: 不再一次性完成整个标记阶段,而是将其分解为许多小块(增量步骤)。
  • 执行: 在每个小块之间,JavaScript 应用程序可以继续执行。
  • 优势: 应用程序的响应性得到改善,因为长时间的暂停被分解成多次短暂停。
4.3 并发回收 (Concurrent GC)
  • 原理: 将某些垃圾回收工作(主要是标记阶段的一部分)放在单独的后台线程中执行。
  • 执行: 这些后台线程与主 JavaScript 线程并发运行,不会阻塞主线程。
  • 优势: 进一步减少了主线程的“Stop-the-World”暂停时间,提高了应用程序的流畅性。
4.4 惰性清除 (Lazy Sweeping)
  • 原理: 清除阶段也可以被优化。在标记阶段完成后,清除操作可以不立即完成,而是分步进行,或者在后续的内存分配请求时进行。
  • 优势: 将清除阶段的开销分散,减少单次暂停时间。

通过这些复杂的优化组合,现代 JavaScript 引擎能够实现高效且低延迟的垃圾回收,使得开发者可以专注于业务逻辑而无需过多担心内存问题。


5. 垃圾回收与内存泄漏

即使有先进的垃圾回收器,内存泄漏在 JavaScript 中仍然是一个常见的问题。

5.1 内存泄漏的本质:可达但无用的对象

垃圾回收器只能回收不可达的对象。如果一个对象在程序逻辑上已经不再需要,但由于某些原因仍然被某个可达的对象引用着,那么它就会一直存在于内存中,无法被回收,这就是内存泄漏

简而言之,内存泄漏发生当:
对象 = 可达 (从根出发可以访问到) + 无用 (在应用程序的生命周期中不再需要)

5.2 垃圾回收无法处理的“假可达”场景 (简述)

常见的内存泄漏场景,虽然在之前的内存管理文档中详细描述过,这里简要回顾它们与垃圾回收的关系:

  • 全局变量: 全局变量始终是根可达的,如果它们存储了不再需要的引用,就会导致泄漏。
  • 被遗忘的定时器 (setTimeout, setInterval): 未清除的定时器会保持对其回调函数及其闭包变量的引用,阻止它们被回收。
  • 脱离 DOM 的引用 (Detached DOM Elements): DOM 元素从文档中移除后,如果 JavaScript 代码中仍有变量引用它,该元素将无法被回收。
  • 未移除的事件监听器: 类似于定时器,未移除的监听器会保持对回调函数及其闭包变量的引用。
  • 闭包的过度引用: 闭包会记住其创建时的外部作用域。如果闭包长时间存活且其外部作用域包含大型或不再需要的变量,这些变量也会被保留。
  • 大型缓存 (Map/Set): 普通的 MapSet 对其键和值保持强引用。如果不断向缓存中添加数据而不进行清理,这些数据将永远不会被回收。

6. 垃圾回收对性能的影响

  • “Stop-the-World”暂停: 尽管有优化,但在某些时刻(特别是老生代的 Major GC),垃圾回收器仍然需要暂停 JavaScript 代码的执行来完成其任务。这些暂停被称为“Stop-the-World”暂停。
  • 优化作用: 增量回收和并发回收的主要目标就是将这些长时间的暂停分解为更短、更不明显的暂停,或者在后台执行,从而最大限度地减少对应用程序响应性和用户体验的影响。
  • 内存分配速度: 频繁的对象创建会增加垃圾回收器的压力,因为这意味着更多的新对象需要被跟踪和潜在地回收。

7. 开发者的职责:编写“垃圾回收友好”的代码

尽管垃圾回收是自动的,但开发者的编码习惯仍然会显著影响垃圾回收器的效率和应用程序的内存行为。

7.1 避免创建不必要的对象
  • 重复利用对象,而不是在循环中不断创建新对象。
  • 优化算法,减少临时对象的产生。
7.2 及时解除不再需要的引用
  • 将不再使用的变量(特别是引用大型对象的变量)显式地设置为 nullundefined,可以帮助垃圾回收器更快地识别这些对象为不可达。
  • 确保及时清除定时器 (clearInterval, clearTimeout) 和事件监听器 (removeEventListener)。
  • 移除从 DOM 中脱离的元素的 JavaScript 引用。
7.3 使用 WeakMapWeakSet
  • 当您需要将数据与对象关联,但又不希望这种关联阻止对象被垃圾回收时,WeakMapWeakSet 是理想选择。
  • 它们对键(WeakMap)或值(WeakSet)持有弱引用,这意味着如果对象只被 WeakMap/WeakSet 引用,它仍然可以被垃圾回收。一旦对象被回收,WeakMap/WeakSet 中对应的条目也会自动消失。

8. 总结

JavaScript 的垃圾回收机制是其强大和便捷的关键特性。

  • 核心: 自动管理内存,通过判断对象的可达性来识别“垃圾”。
  • 算法演进: 从存在循环引用缺陷的引用计数,发展到能够解决循环引用的标记-清除算法
  • 现代优化: 现代 JavaScript 引擎(如 V8)通过分代回收(新生代的 Scavenge 算法和老生代的 Mark-Sweep-Compact 算法)、增量回收并发回收等手段,极大地优化了垃圾回收的性能,减少了对应用程序的阻塞。
  • 开发者角色: 尽管垃圾回收是自动的,但理解其工作原理对于避免内存泄漏、优化应用程序性能至关重要。编写“垃圾回收友好”的代码,如避免不必要的全局变量、及时解除引用、利用 WeakMap/WeakSet,是每个 JavaScript 开发者的重要职责。

通过掌握垃圾回收的核心概念和优化策略,您将能够更深入地理解 JavaScript 程序的运行机制,并编写出更健壮、更高效的应用程序。


9. 附录:常见问题

Q: 垃圾回收是何时运行的?我能控制它吗?
A: 垃圾回收的运行时间由 JavaScript 引擎内部的启发式算法决定。它通常在内存分配达到某个阈值、系统空闲时或在其他特定条件下触发。作为开发者,您无法直接控制垃圾回收的精确时机,也没有 API 可以强制执行垃圾回收(除了在调试环境下,某些浏览器可能提供手动触发 GC 的选项,但这不是生产代码应该依赖的)。

Q: 为什么会有“Stop-the-World”暂停?为什么不能完全避免?
A: 在某些垃圾回收阶段,为了确保内存状态的一致性,防止在回收过程中应用程序修改了正在被 GC 检查的对象,需要暂停应用程序的执行。虽然现代引擎通过增量、并发等技术大大减少了暂停时间,但某些关键阶段(如老生代 Compact)仍然需要短暂暂停来保证内存的正确整理。完全避免暂停在现有技术下几乎是不可能的。

Q: WeakMapMap 有什么根本区别?
A:

  • 引用强度: Map 对其键和值都持有强引用。这意味着只要 Map 实例存在,其内部的键值对就永远不会被垃圾回收。WeakMap 只对键持有弱引用。如果一个键对象没有其他强引用,它就可以被垃圾回收,WeakMap 中对应的条目也会自动消失。
  • 键类型: Map 的键可以是任何 JavaScript 值(原始值或对象)。WeakMap 的键只能是对象,不能是原始值。
  • 可迭代性: Map 可以迭代(通过 forEach, keys, values, entries),因为其键是强引用,可以被枚举。WeakMap 不可迭代,因为弱引用的键可能随时被回收,导致迭代结果不确定。
Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐