Published on

JavaScript 垃圾回收机制详解

Authors
  • avatar
    Name
    青雲
    Twitter

JavaScript 是一种高级编程语言,它的内存管理和垃圾回收机制大大简化了开发者的工作。现代 JavaScript 引擎会自动管理内存,通过垃圾回收机制(Garbage Collection, GC)来清除不再使用的对象,使开发者可以更多地专注于编写业务逻辑,而不是手动管理内存。

然而,理解垃圾回收机制对优化程序性能和避免内存泄漏非常重要。本文将详细介绍 JavaScript 的垃圾回收机制,包括标记-清除、引用计数、增量回收和分代回收,帮助你深入理解这项关键技术。

基本概念

想象一下,你是一所大房子的主人。随着日常生活,各种杂物和家具开始堆积,占据空间。幸运的是,有一个无形的管家(JavaScript垃圾回收器)负责定时清理无用的物品,让房子(内存)保持整洁。

在计算机科学中,垃圾回收是指自动检测程序不再使用的内存并回收它,以免造成内存泄漏。JavaScript 中主要使用两种垃圾回收算法:

  1. 标记-清除(Mark-and-Sweep):这是最常见的垃圾回收算法。
  2. 引用计数(Reference Counting):较老的垃圾回收算法,现在已经较少使用。

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

标记-清除算法分为两个阶段:

  1. 标记阶段:从根对象(如全局对象、当前执行上下文的局部变量)开始,递归遍历所有可达对象,并打上标记。
  2. 清除阶段:在清除阶段,垃圾回收器遍历所有对象,回收那些没有被标记的对象,释放内存。

以下是一个标记-清除算法的图示,帮助你理解垃圾回收的过程:

下面示例中,虽然 createCycle() 结束后 obj1 和 obj2 已不可见,但它们形成了循环引用,这在引用计数算法中会导致内存泄漏,而标记-清除算法可以正确回收这些对象。

function createCycle() {
  let obj1 = {};
  let obj2 = {};
  obj1.a = obj2;
  obj2.a = obj1;
}

createCycle();

引用计数(Reference Counting)

引用计数算法为每个对象维护一个引用计数器,每当有引用指向该对象时,计数器加一;当引用解除时,计数器减一。计数为零的对象会被立即回收。

以下是一个引用计数算法的图示:

  • [Object A]、[Object B] 和 [Object C] 是作用域中的变量,它们分别持有对 obj_A、obj_B 和 obj_C 的引用。
  • obj_A 和 obj_B 互相引用,这造成了循环引用的问题,在只有引用计数的垃圾回收机制中,这会导致内存泄漏,因为他们的引用计数永远不会达到0。
  • obj_C 被对象 B 引用,但如果 [Object C] 离开作用域或被设置为 null,那么 obj_C 的引用计数将会减少到0,并且它可以被垃圾回收器回收。

下面的示例里,按照引用计数算法,obj1 和 obj2 的引用计数分别为 1,即使它们已经不可访问,但因为引用计数永远不会为零,它们将不会被回收,造成内存泄漏。

function createCycle() {
  let obj1 = {};
  let obj2 = {};
  obj1.a = obj2;
  obj2.a = obj1;
}

createCycle();

增量回收和分代回收

增量回收(Incremental Garbage Collection)

增量回收是将垃圾回收过程分解为多个小步骤,而不是一次性完成整个回收过程。这种方式可以减少垃圾回收带来的卡顿,使得应用程序响应更流畅。

在这个示意图中,“程序”表示主程序的执行时间段,“回收”表示进行增量垃圾回收的时间段。由于垃圾回收被分割成多个小段,每段回收期间程序的暂停时间会减少,从而可以使程序的运行看起来更加平滑,避免了长时间的停顿。

分代回收(Generational Garbage Collection)

分代回收基于对象的生命周期管理内存,通过将堆内存划分为几代(如新生代和老生代)来优化垃圾回收效率。年轻对象为“新生代”,存活时间较长的对象逐渐移动到“老生代”。

  • 新生代(Young Generation):存活时间短的对象。新对象首先在新生代里分配内存。新生代垃圾回收频繁,但速度快。
  • 老生代(Old Generation):存活时间长的对象。新生代对象经过多次回收仍未被回收,就被移动到老生代。老生代垃圾回收不频繁,但速度较慢。

GC 流程如下:

  1. 对象首先在新生代的轮换区1中创建。
  2. 新生代发生垃圾回收,存活的对象从轮换区1复制到轮换区2。
  3. 多次垃圾回收后,如果对象继续存活,它可能被移到老生代区域。
  4. 老生代区域中的对象存活时间长,会不太频繁地进行垃圾回收。

结合使用

现代 JavaScript 引擎(如 V8 引擎)结合增量回收和分代回收机制,以提高效率和减少暂停时间。这意味着垃圾回收器既可以分步骤执行回收任务,又可以根据对象的生命周期优化回收策略。

优化 JavaScript 中的垃圾回收

JavaScript 的垃圾回收机制大大简化了内存管理,但为了确保应用的高性能和内存效率,我们仍然需要采取一些优化策略来减少垃圾回收器的工作量,避免内存泄漏,并尽量减少对主线程执行流程的干扰。

限制变量作用域

尽量在需要时才声明变量,避免使用全局变量。局部变量在函数执行结束后会自动离开作用域,成为垃圾回收的候选对象。

function processData() {
  let temporaryData = fetchData();
  // 使用 temporaryData 进行一些操作
  process(temporaryData);
  // 函数结束后,temporaryData 会自动离开作用域,被垃圾回收
}

手动解除引用

当确定不再需要某个对象时,手动将其引用设置为 null,尤其适用于全局变量或大型对象。

let bigObject = createBigObject();
// ...
bigObject = null; // 显式解除引用

使用局部变量代替全局变量

全局变量直到页面关闭才会被清理。尽量把全局变量替换为局部变量或包含在闭包中。

function process() {
  let localVar = 'This is a local variable';
  console.log(localVar);
}
process();
// localVar 已经超出作用域,可以被垃圾回收

优化事件处理器和监听器

在不需要的时候,移除事件监听器和定时器,避免在 DOM 元素和 JavaScript 代码之间创建不必要的引用关系。

const element = document.getElementById('myElement');
function eventHandler(event) {
  console.log('Event triggered!');
}
element.addEventListener('click', eventHandler);
// 当不再需要监听事件时
element.removeEventListener('click', eventHandler); // 移除监听器

控制 DOM 元素引用

创建对 DOM 元素的引用时要谨慎,一旦不需要 DOM 元素,确保解除对它的引用,以便垃圾回收。

let div = document.createElement('div');
document.body.appendChild(div);
// ...
document.body.removeChild(div);
div = null; // 解除对 DOM 元素的引用

节流和去抖动

对于频繁触发的事件(如窗口调整大小、滚动等),使用节流(throttle)或去抖动(debounce)来减少回调函数触发的频率,减少由这些事件触发的对象创建。

function throttle(func, limit) {
  let inThrottle;
  return function() {
    const args = arguments;
    const context = this;
    if (!inThrottle) {
      func.apply(context, args);
      inThrottle = true;
      setTimeout(() => inThrottle = false, limit);
    }
  };
}

window.addEventListener('resize', throttle(() => {
  console.log('Window resized');
}, 200));

使用 Web Workers

对于复杂或长时间的任务,可以使用 Web Workers 将这些任务移出主线程,以避免阻碍 UI 的响应性和触发垃圾回收。

const worker = new Worker('worker.js');
worker.postMessage({ task: 'heavy-computation' });
worker.onmessage = function(event) {
  console.log('Received result from worker:', event.data);
};

优化内存分配

尽量重用对象而不是创建新对象。对于大型数据结构,如使用数组,提前分配足够的空间来避免频繁的调整大小所导致的内存分配。

// 尽量重用对象
function createObjects() {
  const obj = {};
  for (let i = 0; i < 1000; i++) {
    // 重用 obj,而不是每次创建新对象
    obj[i] = i;
  }
}

// 预先分配数组空间
let array = new Array(1000);
// 在需要时使用数组
for (let i = 0; i < array.length; i++) {
  array[i] = i * 2;
}

使用高效的数据结构

例如,使用 MapSet 而不是纯对象,因为这些数据结构提供了更优化的性能和内存管理。

const map = new Map();
map.set('key1', 'value1');
map.set('key2', 'value2');
console.log(map.get('key1')); // 输出: value1

const set = new Set();
set.add(1);
set.add(2);
set.add(1); // 重复添加不会生效
console.log(set.has(1)); // 输出: true

分析和监控

使用内存分析工具如 Chrome DevTools 来监视你的应用程序的内存使用情况。寻找内存泄漏的来源,并根据其反馈进行优化。

  1. 打开 Chrome 浏览器,按 F12 打开开发者工具。
  2. 导航到 “Memory” 标签。
  3. 进行堆快照(Heap Snapshot)以分析内存使用情况。
  4. 分析快照找到内存泄漏的根源,并优化代码。

减少闭包的使用

虽然闭包是 JavaScript 中强大的功能,但不适当的使用会导致内存泄漏,因为它们会保持对外部作用域的引用。

function createCounter() {
  let count = 0;

  return function() {
    count++;
    console.log(count);
  };
}

const counter = createCounter();
counter(); // 输出: 1
counter(); // 输出: 2

面试实战

什么是垃圾回收?为什么 JavaScript 需要垃圾回收机制?

答案:垃圾回收(GC)指的是自动监测内存使用情况并释放那些不再需要的内存分配的过程。JavaScript 需要垃圾回收机制来管理内存,因为它是一门内存管理自动化的语言,开发者无法直接控制内存分配和释放。GC 有助于预防内存泄漏,并确保不使用的内存可以被再次利用。

JavaScript 中的垃圾回收算法有哪些?

答案:JavaScript 中常见的垃圾回收算法主要包括:

  • 标记—清除算法:这是最常见的垃圾回收算法。垃圾回收器会在内存中找出不再使用的对象,并进行垃圾回收。它会分两步进行:首先标记所有的根对象,然后递归地标记所有可达对象。最后清除那些未被标记的对象。
  • 引用计数算法:每个对象维护一个引用计数器,当对象被引用时计数器增加,当引用被释放时计数器减少。计数器为 0 的对象即为垃圾对象。
  • 分代回收机制:将内存分为新生代和老生代两个区域。新生代对象存活时间短,老生代对象存活时间长。新生代使用 “标记—清除” 算法,老生代则使用更复杂的“标记—整理”算法。

什么是标记—清除算法(Mark-and-Sweep)?

答案:标记—清除(Mark-and-Sweep)算法是最常见的垃圾回收算法。它会扫描整个内存,先标记所有可达对象,然后清除没有被标记的对象。具体流程如下:

  1. 标记阶段:遍历所有的根对象,将所有可达对象标记为活跃状态。
  2. 清除阶段:遍历内存区域,删除所有未被标记为活跃状态的对象。

这个过程可以重复执行,确保内存中的非活跃对象被定期清理掉。

什么是内存泄漏?JavaScript 中常见的内存泄漏类型有哪些?

答案:内存泄漏(Memory Leak)是指不再访问的对象未被垃圾回收机制回收,从而导致内存无法被释放的情况。常见的内存泄漏类型包括:

  • 全局变量:过多使用全局变量或意外创建全局变量。
  • 闭包:错误使用闭包,导致无法释放的内存。
  • 计时器和回调:计时器或事件回调未被清除,导致变量无法被释放。
  • 未被移除的 DOM 引用:DOM 元素被移除,但仍被引用,导致内存无法释放。

如何在 JavaScript 中避免内存泄漏?

答案:避免内存泄漏的方法包括:

  1. 限制使用全局变量:尽量少使用全局变量,避免意外创建全局变量。
  2. 正确使用闭包:在使用闭包时要小心,确保闭包引用的变量在不需要时能被释放。
  3. 清除不再使用的定时器和事件监听器:确保在适当的时候清除已不再需要的定时器和事件监听器。
  4. 减少对 DOM 元素的直接引用:避免保持对已删除的 DOM 元素的引用。
  5. 使用 WeakMap 和 WeakSet:可以在不再需要时使得对象很快被垃圾回收。

解释分代回收(Generational Collection)机制,它是如何工作的?

答案:分代回收机制将内存分成几代,通常分为新生代和老生代:

  • 新生代:新生对象存储在新生代内存中,存活时间短,垃圾回收非常频繁但代价较低。
  • 老生代:存活时间较长的对象被移动到老生代内存区域,垃圾回收频率较低,但每次回收的代价较高。

工作流程包括:

  1. Minor GC:主要针对新生代内存,采用复制算法(Copying Collector),通过将活跃对象从 From 空间复制到 To 空间。
  2. Major GC:主要处理老生代内存区域,使用“标记—整理”算法来处理,效率较低但确保老生代内存中的长时间存活的对象能够被整理和回收。

V8 引擎使用的增量标记算法与标记-清除算法有什么区别?

答案:增量标记算法是对标记-清除算法的改进,其目的是减少垃圾回收对程序执行的影响。传统的标记-清除算法会在回收过程中停止程序执行,这可能导致应用程序的延迟显著增加。增量标记算法将标记过程分割成多个小步骤,与应用程序的执行交替进行,这样可以避免长时间的停顿,使得延迟更为可控,从而提升应用程序的响应速度和性能。

如何用 Chrome DevTools 进行内存泄漏检测?

答案:可以通过以下步骤使用 Chrome DevTools 进行内存泄漏检测:

  1. 打开 Chrome DevTools:使用 F12 或右键选择“检查”。
  2. 切换到 Memory 面板:在 DevTools 中选择 Memory 面板。
  3. 捕获堆快照:点击“Take Snapshot”按钮,捕获当前内存使用情况。
  4. 分析快照:对比不同快照间的内存使用情况。检查 Detached HTML 节点。
  5. 查看 Allocation Instrumentation:使用 Allocation instrumentation on timeline 查看具体的内存分配和回收情况。