深度解析浏览器垃圾回收与内存泄露

3 阅读5分钟

要理解浏览器的内存管理,我们可以把浏览器的内存想象成一个自动化的仓库。垃圾回收机制是仓库的自动清洁工,它的职责是找出并清理不再使用的旧物(内存)。而内存泄露,则是这个清洁工因为某些原因,没能识别出一些本该丢弃的旧物,导致它们一直堆在那里,挤占了宝贵的空间,最终让整个仓库(页面)变得臃肿不堪、运行卡顿。


浏览器的垃圾回收机制

1. 核心原理:可达性

简单来说,如果一个对象可以通过某种方式被访问到,它就是“可达”的,清洁工会认为它“还在使用中”,从而保留它。反之,如果一个对象变得“不可达”,它就会被当作垃圾回收掉。

  • 根(Roots):这是所有可达性追踪的起点,通常包括全局变量(如 window)、当前正在执行的函数及其局部变量等。
  • 引用链:从根出发,通过一系列的引用连接起来的所有对象,都被认为是可达的。

2. 主要算法:标记-清除(Mark-and-Sweep)

这是现代浏览器普遍采用的核心算法,它的工作流程非常清晰:

  1. 标记(Mark):清洁工从“根”出发,遍历所有能被访问到的对象,并给它们打上一个“仍在使用的标记”。
  2. 清除(Sweep):遍历整个内存,清除那些没有被打上标记的对象。这些就是“不可达”的垃圾。
  3. 优化:为了解决每次清理都暂停程序运行(造成卡顿)的问题,引擎还引入了分代回收增量回收等优化策略,让清理工作更高效、更平滑。

3. 过时的算法:引用计数(Reference Counting)

这是一种早期的算法,它的逻辑是“跟踪每个对象被引用的次数”。引用次数为0 的对象就会被回收。但它有一个致命的缺陷:无法处理循环引用。当两个对象互相引用时,即使它们对外都不可达,各自的引用次数仍为1,导致永远不会被回收,从而造成内存泄露。因此,现代引擎早已不再单纯使用此算法。

内存泄露

了解了垃圾回收的原理,我们就能明白,内存泄露的根本原因就是:本应被回收的对象,因为某些意外操作,被保留了引用,从而变得“可达”,让浏览器无法识别

以下是四种最常见的内存泄露场景及示例:

1. 意外的全局变量

在函数内部,如果不使用关键字声明变量,它会自动挂载到全局对象(如 window)上,成为永久性的根,无法被回收。

function leak() {
  // 漏掉了 let/const/var
  accidentalGlobal = '我是一个意外的全局变量,永远不会被回收!';
}

2. “遗忘”的定时器或事件监听器

当页面中的组件或元素被销毁后,与之关联的 setIntervaladdEventListener 如果没有被清除,它们的回调函数中引用的对象就会一直留在内存中。

const someResource = { data: '重要数据' };
const button = document.getElementById('myButton');

// 添加了一个事件监听
button.addEventListener('click', () => {
  console.log(someResource);
});

// ... 后来,button 从DOM中被移除了,但我们忘了移除事件监听
// 导致 button、someResource 以及这个箭头函数都无法被回收

3. 闭包的不当使用

闭包可以记住并访问其外部函数的变量,这是它的强大特性。但如果闭包的生命周期过长(例如被赋值给了全局变量),它引用的外部变量也会随之一直存活。

function createClosure() {
  const largeData = new Array(1000000).fill('数据'); // 一个很大的对象

  return function innerFunction() {
    // 这个内部函数(闭包)引用了 largeData
    if (largeData.length > 0) {
      console.log('数据存在');
    }
  };
}

// 全局变量引用了闭包,导致 largeData 永远无法被回收
const myClosure = createClosure();

4. 分离的DOM节点引用

DOM元素被移除后,如果JavaScript变量中还保留着对它的引用,那么整个DOM节点连同它的属性、事件监听器等都会被保留在内存中。

let detachedDiv = document.createElement('div');
document.body.appendChild(detachedDiv);

// ... 后来,从DOM中移除了这个div
document.body.removeChild(detachedDiv);

// 但是!变量 detachedDiv 仍然持有对该DOM节点的引用
// 所以这个div并未被真正回收,它变成了一个“分离”的节点

如何检测并预防内存泄露?

1. 如何检测内存泄露?

最好的办法是利用浏览器自带的开发者工具,其中Chrome的DevTools最为强大。

  • Performance (性能) 面板:录制一段用户操作(如反复进入退出页面),观察内存曲线的变化。一个健康的应用内存曲线应该是“锯齿状”的(上升代表分配,下降代表GC回收)。如果曲线只升不降,或者整体趋势持续向上,就很可能存在内存泄露。
  • Memory (内存) 面板
    • 堆快照 (Heap Snapshot):在某个操作前后分别拍摄快照,然后进行对比。可以筛选出“新增”的对象,并查看它们的引用路径(Retainers),从而定位到是哪个变量在阻止其回收。
    • 分配时间线 (Allocation Timeline):可以实时记录对象的内存分配情况,让你直观地看到哪些函数在持续申请内存而没有释放。

2. 如何预防内存泄漏?

  • 手动解除引用:在不需要对象时,将其设置为 null,切断引用链。

    let data = { /* ... */ };
    // 使用data...
    data = null; // 明确告知GC可以回收
    
  • 用好 WeakMapWeakSet:这是处理缓存或存储DOM引用的利器。它们对键名是弱引用,意味着如果没有其他变量再引用这个键名对象,它就会被GC自动回收,无需手动删除。

    const cache = new WeakMap();
    const element = document.getElementById('tempElement');
    cache.set(element, { some: 'info' });
    
    // 当 element 被从DOM移除,并且所有对它的强引用(如这里的 element 变量)都被释放后
    // cache 中的这条记录会自动消失,不会造成泄露。
    
  • 清理工作要成对出现

    • 绑定了 addEventListener,就在组件销毁时 removeEventListener
    • 设置了 setInterval,就在不用时 clearInterval
  • 避免意外的全局变量:在JavaScript文件头部使用 "use strict" 严格模式,可以有效防止意外的全局变量。