JavaScript 垃圾回收机制深度解析

JavaScript 作为一门高级语言,拥有自动内存管理机制,开发者无需手动分配和释放内存。然而,理解垃圾回收(Garbage Collection,GC)的工作原理,对于编写高性能、无内存泄漏的代码至关重要。

一、垃圾回收机制的核心概念

1.1 内存的生命周期

无论使用何种编程语言,内存的生命周期都遵循相同的模式:

┌─────────────┐     ┌─────────────┐     ┌─────────────┐
│  内存分配    │ ──▶ │  内存使用    │ ──▶ │  内存回收    │
│  Allocate   │     │    Use      │     │   Release   │
└─────────────┘     └─────────────┘     └─────────────┘
  • 内存分配:当声明变量、函数、对象时,系统会自动分配内存
  • 内存使用:即读写内存,也就是使用变量、函数
  • 内存回收:使用完毕后,由垃圾回收器自动回收不再使用的内存

1.2 什么是垃圾回收

JavaScript 中的函数、变量、对象等都需要占用一定的内存。当这些东西不再被使用时,就变成了「垃圾」。如果不进行回收,内存会被持续占用,随着程序运行,垃圾越来越多,最终导致内存耗尽。

为什么需要自动 GC?

  • JavaScript 不允许开发者直接操作内存(不像 C/C++ 的 malloc/free
  • 自动 GC 避免了手动内存管理导致的内存泄漏野指针等问题
  • 降低开发成本,让开发者专注于业务逻辑

1.3 垃圾的判定标准

核心原则:该内存空间是否还能被程序「访问到」

可访问场景(非垃圾):

  1. 全局作用域中声明的变量(如 window/global 上的属性)
  2. 函数执行栈中正在执行的函数内的局部变量、参数
  3. 通过「可达链」能关联到上述两类变量的对象
// 示例:可达链
const globalObj = {
  // globalObj 是全局变量,可达
  child: {
    // child 通过 globalObj 可达
    grandchild: {}, // grandchild 通过 child 可达
  },
};

不可访问场景(垃圾):

  • 函数执行结束后,局部变量未被外部引用,且无法通过任何可达链关联
  • 手动将变量赋值为 null/undefined 后,若没有其他引用,原对象成为垃圾

二、JavaScript 中常见的垃圾回收算法

2.1 引用计数法(Reference Counting)

核心原理

引用计数法的核心逻辑是:为每个内存对象维护一个「引用计数器」,记录当前有多少个活跃引用指向该对象

  • 当对象被引用时(如被变量赋值、作为参数传递等),计数器 +1
  • 当引用失效时(如变量被重新赋值、离开作用域等),计数器 -1
  • 当计数器变为 0 时,引擎判定该对象为垃圾,立即回收
let obj = { name: 'Alice' }; // { name: 'Alice' } 引用计数 = 1
let ref = obj; // 引用计数 = 2
obj = null; // 引用计数 = 1
ref = null; // 引用计数 = 0 → 被回收

优点

优点说明
即时回收引用计数为 0 时立即回收,避免内存堆积
实现简单逻辑直观,只需维护计数器增减操作
减少卡顿回收过程分散在程序运行中,不会长时间停顿

致命缺陷:循环引用

当两个或多个对象相互引用时,即使它们已无外部引用,计数器也始终大于 0,导致内存泄漏

function createCycle() {
  let objA = {};
  let objB = {};

  // 形成循环引用
  objA.ref = objB;
  objB.ref = objA;
}

createCycle();
// 函数执行结束后,objA、objB 局部变量销毁
// 但两者的属性互相引用,计数器均为 1
// 引用计数法无法识别,这部分内存永远无法释放!

由于这个致命缺陷,现代 JavaScript 引擎已不再使用纯引用计数法。

2.2 标记清除法(Mark-and-Sweep)

核心原理

标记清除法基于「可达性」判定垃圾,分为标记清除两个阶段,定期执行(非实时)。

┌─────────────────────────────────────────────────────────────┐
│                       标记阶段 (Mark)                        │
│  从根对象出发,遍历所有可达对象,标记为「活跃」                  │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                       清除阶段 (Sweep)                       │
│  遍历整个堆内存,回收所有未被标记的对象                         │
└─────────────────────────────────────────────────────────────┘
                              │
                              ▼
┌─────────────────────────────────────────────────────────────┐
│                     整理阶段 (Compact,可选)                  │
│  将存活对象内存地址连续排列,减少内存碎片                       │
└─────────────────────────────────────────────────────────────┘

执行流程详解:

  1. 标记阶段:从「根对象」(全局对象、执行栈中的变量)出发,递归遍历所有可达对象,标记为「活跃对象」
  2. 清除阶段:遍历整个内存空间,将未被标记的对象判定为垃圾,释放其内存
  3. 整理阶段(可选):将活跃对象内存地址连续排列,减少内存碎片

优点

  1. 解决循环引用问题:只要对象从根不可达,无论是否存在循环引用,都会被回收
  2. 无额外空间开销:不需要为每个对象维护计数器

缺点

  1. 非实时性:GC 执行时会中断主线程(「Stop-The-World」),若内存量大,可能造成明显卡顿
  2. 内存碎片:释放的内存地址不连续,可能导致后续大对象无法分配

三、V8 引擎的现代 GC 优化

现代 JavaScript 引擎(如 Chrome 的 V8)在标记清除法基础上,引入了多项优化技术。

3.1 分代收集(Generational Collection)

V8 将堆内存分为新生代老生代两个区域,基于一个重要的观察:大多数对象的生命周期都非常短

┌─────────────────────────────────────────────────────────────┐
│                         堆内存 (Heap)                        │
├───────────────────────┬─────────────────────────────────────┤
│     新生代 (Young)     │           老生代 (Old)               │
│   ┌───────┬───────┐   │                                     │
│   │ From  │  To   │   │     存活时间长的对象                  │
│   │ Space │ Space │   │     GC 频率低                        │
│   └───────┴───────┘   │     标记-清除/标记-整理               │
│   新创建的对象          │                                     │
│   GC 频率高            │                                     │
│   Scavenge 算法        │                                     │
└───────────────────────┴─────────────────────────────────────┘
区域特点GC 算法
新生代空间小,存放新对象,GC 频繁Scavenge(复制算法)
老生代空间大,存放长期存活对象Mark-Sweep + Mark-Compact

对象晋升:在新生代中存活过一定次数 GC 的对象,会被「晋升」到老生代。

3.2 并行、增量、并发回收

为了减少 GC 带来的程序暂停(Stop-The-World),现代 GC 使用了多种技术:

技术说明
并行(Parallel)主线程和辅助线程同时执行 GC 工作
增量(Incremental)将 GC 工作拆分成多个小任务,穿插在 JS 任务之间执行
并发(Concurrent)辅助线程在后台执行 GC,主线程继续运行 JS
传统 GC:
JS 执行 ──────┤ GC 停顿(长)├────── JS 执行

增量 GC:
JS 执行 ──┤GC├── JS ──┤GC├── JS ──┤GC├── JS 执行
          (短)       (短)       (短)

四、如何避免内存泄漏

理解 GC 原理后,我们可以采取措施避免内存泄漏。

4.1 常见内存泄漏场景

1. 意外的全局变量

// ❌ 错误:未声明的变量会成为全局变量
function leak() {
  leakedVar = 'I am global!'; // 没有 let/const/var
}

// ✅ 正确:使用严格模式
('use strict');
function safe() {
  let localVar = 'I am local';
}

2. 被遗忘的定时器和回调

// ❌ 错误:定时器未清除
const timer = setInterval(() => {
  // 持有外部引用的闭包
  console.log(someData);
}, 1000);

// ✅ 正确:不需要时清除定时器
clearInterval(timer);

3. 脱离 DOM 的引用

// ❌ 错误:DOM 元素被移除,但 JS 仍持有引用
const button = document.getElementById('myButton');
document.body.removeChild(button);
// button 变量仍然引用该 DOM 节点,无法被 GC

// ✅ 正确:手动置空引用
button = null;

4. 闭包使用不当

// ❌ 潜在问题:闭包持有大量数据
function createClosure() {
  const largeData = new Array(1000000).fill('x');

  return function () {
    // 即使只用了 largeData 的一小部分
    // 整个 largeData 都无法被回收
    console.log(largeData[0]);
  };
}

// ✅ 优化:只保留需要的数据
function createOptimizedClosure() {
  const largeData = new Array(1000000).fill('x');
  const neededData = largeData[0]; // 只取需要的

  return function () {
    console.log(neededData);
  };
}

4.2 检测内存泄漏的工具

工具说明
Chrome DevTools - Memory可以拍摄堆快照,对比内存变化
Chrome DevTools - Performance可以观察内存使用趋势
Node.js --inspectNode.js 应用的内存分析

五、面试要点总结

  1. GC 的核心判定标准:可达性(是否能从根对象访问到)

  2. 两种主要算法对比

    • 引用计数:即时回收,但无法处理循环引用
    • 标记清除:可处理循环引用,但会造成停顿
  3. V8 的优化策略

    • 分代收集:新生代频繁 GC,老生代低频 GC
    • 增量/并行/并发:减少 GC 停顿时间
  4. 常见内存泄漏场景

    • 意外全局变量
    • 未清除的定时器/事件监听器
    • 脱离 DOM 的引用
    • 闭包使用不当
  5. 防范措施

    • 使用 'use strict'
    • 及时清除定时器和事件监听器
    • 移除 DOM 后置空相关引用
    • 合理使用闭包,避免持有不必要的大对象

总结

JavaScript 的垃圾回收机制是语言运行时的核心组成部分。理解其工作原理,不仅有助于编写高性能代码,也是前端面试的必考知识点。关键要记住:

  • 可达性是判定垃圾的核心标准
  • 现代引擎使用标记清除 + 分代收集组合策略
  • 开发者需要主动避免常见的内存泄漏场景

掌握这些知识,你就能写出更加健壮、高效的 JavaScript 代码。

← 返回列表