打开任务管理器,你会发现一个令人困惑的现象:某些单页应用(SPA)的标签页在后台运行几小时后,内存占用从最初的 50MB 悄然攀升到 500MB 甚至更多。页面开始卡顿,滚动变得迟滞,最终浏览器可能直接弹出"Aw, Snap!“崩溃页面。

这不是浏览器的问题,而是应用代码在持续泄漏内存。

JavaScript 有垃圾回收机制,理论上开发者不需要手动管理内存。但现实远比理论复杂:闭包、事件监听器、定时器、分离的 DOM 节点……这些日常使用的 API 背后,隐藏着一个个精心设计的内存陷阱。

V8 的内存世界:对象生存的地方

要理解内存泄漏,首先需要了解 V8 如何组织和管理内存。

V8 将堆内存划分为多个空间,每个空间承担不同的职责:

┌─────────────────────────────────────────────────────────────┐
│                      V8 Heap                                │
├─────────────────┬───────────────────────────────────────────┤
│   New Space     │  Young Generation (1-8 MB)                │
│   (Nursery +    │  ├── Semi-space 1 (From-space)            │
│    Intermediate)│  └── Semi-space 2 (To-space)              │
├─────────────────┼───────────────────────────────────────────┤
│   Old Space     │  Old Generation                           │
│                 │  ├── Old Pointer Space (含指针的对象)      │
│                 │  └── Old Data Space (纯数据对象)            │
├─────────────────┼───────────────────────────────────────────┤
│ Large Object    │  大于其他空间限制的对象                     │
│ Space           │  每个对象独占一个内存区域                    │
├─────────────────┼───────────────────────────────────────────┤
│ Code Space      │  JIT 编译后的可执行代码                     │
├─────────────────┼───────────────────────────────────────────┤
│ Map Space       │  对象的隐藏类(Hidden Classes)             │
└─────────────────┴───────────────────────────────────────────┘

New Space(新生代) 是对象出生的地方。这里空间很小(1-8MB),但分配速度极快——只需移动一个指针。大多数对象的生命周期都很短,它们在这里被创建,很快就被丢弃。

Old Space(老生代) 存放那些"活下来"的对象。如果一个对象在新生代经历了两次垃圾回收仍然存活,它就会被晋升(promote)到老生代。这里空间更大,但垃圾回收的开销也更大。

这个分代设计基于一个被称为分代假说的观察:大多数对象都会在很年轻时死亡。新生代中 80%-90% 的对象在第一次垃圾回收时就会被清理掉。

垃圾回收:从标记到清扫的旅程

V8 实现了两套垃圾回收器:Minor GC 和 Major GC。

Minor GC:新生代的快速清理

Minor GC 也称为 Scavenger,专门清理新生代。它采用 Cheney 的半空间复制算法:

V8 Scavenger Algorithm

图片来源: V8.dev - Trash talk: the Orinoco garbage collector

  1. 初始状态:新生代被分为两个相等大小的半空间——From-space 和 To-space。新对象分配在 From-space。

  2. 触发时机:当 From-space 填满时,触发 Minor GC。

  3. 复制存活对象:从 GC Roots(栈指针、全局对象)开始遍历,将所有存活对象复制到 To-space。这个过程会自动完成内存整理(compaction),消除碎片。

  4. 晋升老生代:如果一个对象已经是第二次在 Minor GC 中存活,它会被直接移动到老生代。

  5. 空间翻转:复制完成后,From-space 和 To-space 角色互换。To-space 变成新的 From-space,准备接收新对象。

整个过程虽然是"stop-the-world"的,但由于新生代空间小,存活对象少,通常只需要几毫秒。

Major GC:老生代的全面清理

老生代的垃圾回收使用 Mark-Sweep-Compact 算法,分为三个阶段:

V8 Major GC Phases

图片来源: V8.dev - Trash talk: the Orinoco garbage collector

标记阶段:从 GC Roots 开始,通过深度优先搜索遍历整个对象图,标记所有可达对象。V8 使用三色标记法:

  • 白色:未访问
  • 灰色:已访问但子节点未处理完
  • 黑色:已访问且子节点已处理完

清扫阶段:扫描堆内存,将未标记(白色)的对象占用的内存加入空闲列表(free-list)。这些空间可以被重新分配。

整理阶段:可选。将存活对象移动到连续的内存区域,减少内存碎片。整理会带来复制开销,但能提高后续分配效率。

Orinoco:打破停顿的边界

传统的垃圾回收会完全暂停 JavaScript 执行,导致明显的卡顿。V8 的 Orinoco 项目引入了三种技术来解决这个问题:

并行(Parallel):主线程和多个辅助线程同时工作。仍然是 stop-the-world,但总停顿时间被多个线程分摊。

增量(Incremental):将 GC 任务拆分成多个小片段,穿插在 JavaScript 执行间隙。虽然不减少总工作时间,但避免了长时间阻塞。

并发(Concurrent):辅助线程在后台完全独立执行 GC 工作,主线程持续运行 JavaScript。这是最理想的方案,但实现最复杂——需要处理并发读写竞争。

Orinoco Concurrent GC

图片来源: V8.dev - Trash talk: the Orinoco garbage collector

现代 V8 中,Major GC 的标记阶段完全并发执行,只有在最后的标记完成和指针更新阶段才需要短暂暂停主线程。

写屏障:追踪跨代引用的秘密

一个关键问题:如果老生代对象持有新生代对象的引用,Minor GC 如何知道这些新生代对象仍然存活?

V8 使用**写屏障(Write Barrier)**机制。每当一个指针被写入对象时,V8 会检查这个操作是否创建了一个从老生代到新生代的引用。如果是,就将这个指针位置记录到 Store Buffer 中。

// 写屏障的简化逻辑
function writeBarrier(holder, field, value) {
  if (isInOldSpace(holder) && isInNewSpace(value)) {
    storeBuffer.record(holder, field);
  }
  holder[field] = value;
}

Minor GC 时,Store Buffer 中的记录会被当作额外的 GC Roots,确保这些被老生代引用的新生代对象不会被误清理。

内存泄漏:被遗忘的引用

垃圾回收器通过可达性判断对象是否存活。如果一个对象不再被任何变量引用,它就是垃圾。但泄漏的本质是:你认为对象已经是垃圾了,但系统不这么认为

在组件化的 SPA 中,最危险的泄漏模式只需一行代码:

window.addEventListener('message', this.onMessage.bind(this));

这个事件监听器绑定在全局对象 window 上。当组件卸载时,如果没有调用 removeEventListener,整个组件——包括它的所有子组件、DOM 节点、闭包中的变量——都会被这个监听器引用,永远无法被回收。

五大经典泄漏模式

1. 未清理的事件监听器

// ❌ 泄漏:组件卸载后监听器仍然存在
class MyComponent {
  componentDidMount() {
    window.addEventListener('resize', this.handleResize);
  }
}

// ✅ 正确:清理监听器
class MyComponent {
  componentDidMount() {
    this.handleResize = this.handleResize.bind(this);
    window.addEventListener('resize', this.handleResize);
  }
  componentWillUnmount() {
    window.removeEventListener('resize', this.handleResize);
  }
}

2. 未清理的定时器

// ❌ 泄漏:setInterval 永远不会停止
componentDidMount() {
  this.timer = setInterval(() => {
    this.setState({ time: Date.now() });
  }, 1000);
}

// ✅ 正确:清理定时器
componentWillUnmount() {
  clearInterval(this.timer);
}

3. 分离的 DOM 节点

// ❌ 泄漏:DOM 节点从文档中移除,但被 JS 引用
const detached = document.createElement('div');
document.body.appendChild(detached);
document.body.removeChild(detached);
// detached 变量仍然持有引用

// ✅ 正确:解除引用
detached = null;

分离的 DOM 节点是 SPA 中最常见的泄漏来源之一。它们不在文档树中,但 JavaScript 代码仍持有引用。

4. 闭包陷阱

// ❌ 泄漏:闭包持有大对象的引用
function createHandler() {
  const largeData = new Array(1000000).fill('x');
  return function() {
    console.log(largeData.length); // 闭包捕获了整个 largeData
  };
}

const handler = createHandler();
window.addEventListener('click', handler);

5. 无界缓存

// ❌ 泄漏:缓存无限增长
const cache = {};
function getData(key) {
  if (!cache[key]) {
    cache[key] = fetchExpensiveData(key);
  }
  return cache[key];
}

// ✅ 正确:使用 LRU 缓存限制大小
class LRUCache {
  constructor(maxSize = 100) {
    this.maxSize = maxSize;
    this.cache = new Map();
  }
  get(key) { /* ... */ }
  set(key, value) {
    if (this.cache.size >= this.maxSize) {
      const firstKey = this.cache.keys().next().value;
      this.cache.delete(firstKey);
    }
    this.cache.set(key, value);
  }
}

堆快照:泄漏侦探的工具箱

Chrome DevTools 的 Memory 面板提供了多个内存分析工具,其中最强大的是 Heap Snapshot(堆快照)

三快照技术

Nolan Lawson(PouchDB 核心开发者)推荐了一种系统化的泄漏检测方法——三快照技术

  1. 快照 1:页面加载后,执行一次垃圾回收,拍下第一个快照。
  2. 操作:执行你认为可能泄漏的操作(如打开/关闭模态框)。
  3. 快照 2:执行垃圾回收,拍下第二个快照。
  4. 重复操作:再次执行相同操作多次(建议 7 次,这是一个醒目的质数)。
  5. 快照 3:执行垃圾回收,拍下第三个快照。
  6. 比较:将快照 3 与快照 1 进行对比。

为什么重复 7 次?如果对象泄漏了,你会看到正好 7 个相同类型的对象在快照对比中出现。这个数字太独特,不可能是巧合。

分离 DOM 树检测

Chrome DevTools 提供了专门的过滤器来查找分离的 DOM 节点:

  1. 在 Memory 面板选择 “Heap snapshot”。
  2. 拍摄快照。
  3. 在过滤框中输入 Detached
  4. 展开分离的 DOM 树,查看哪些对象持有引用。

Detached DOM Trees in Chrome DevTools

图片来源: Chrome DevTools - Record heap snapshots

保留路径追踪

找到泄漏对象后,关键问题是:谁持有它的引用?

Chrome DevTools 的 Retainers 面板展示了从 GC Roots 到目标对象的完整引用链。沿着这条链追溯,你就能找到泄漏的根源。

GC Roots
  └── Window (全局对象)
      └── EventListener (事件监听器)
          └── Closure (闭包)
              └── SomeComponent (组件实例)
                  └── leakedObject (泄漏的对象)

自动化检测:MemLab 与 fuite

手动使用 DevTools 分析内存泄漏既耗时又容易出错。Meta(Facebook)开发并开源了 MemLab,一个自动化内存泄漏检测框架。

MemLab 的工作流程:

MemLab Workflow

图片来源: Meta Engineering - MemLab

  1. 导航到页面 A,拍摄堆快照 $S_A$。
  2. 导航到目标页面 B,拍摄堆快照 $S_B$。
  3. 返回页面 A,拍摄堆快照 $S_A'$。
  4. 计算泄漏对象:$(S_B - S_A) \cap S_A'$ —— 在页面 B 分配、但返回后仍未释放的对象。

MemLab 还能自动生成保留路径追踪(Retainer Traces),告诉你每个泄漏对象是如何被保持存活的。

另一个轻量级选择是 fuite,由 Nolan Lawson 开发:

npx fuite https://your-spa-url.com

fuite 会自动打开 Chrome,遍历应用的路由,检测每次导航后是否有内存泄漏。

现代 JavaScript 的内存管理工具

ES2021 引入了两个新的 API,帮助开发者更好地管理内存。

WeakRef:弱引用的艺术

常规引用会阻止对象被垃圾回收。但有时我们需要一个"不阻止回收"的引用:

// 创建弱引用
const weakRef = new WeakRef(largeObject);

// 获取对象(可能已被回收)
const obj = weakRef.deref();
if (obj) {
  // 对象仍然存活
} else {
  // 对象已被回收
}

WeakRef 的典型应用场景:

  • 缓存:持有缓存条目的弱引用,内存紧张时自动释放。
  • DOM 元素引用:避免持有已移除 DOM 元素的强引用。

FinalizationRegistry:清理的回调

当对象被垃圾回收时,我们可能需要执行一些清理工作:

const registry = new FinalizationRegistry((heldValue) => {
  console.log(`对象 ${heldValue} 已被回收`);
  // 执行清理逻辑
});

const obj = { data: 'important' };
registry.register(obj, 'my-object');

注意:回调的执行时机不可预测,不要依赖它做关键逻辑。它最适合用于调试、日志记录或释放外部资源。

WeakMap 和 WeakSet

WeakMap 的键是弱引用,不会阻止键对象被回收:

// ✅ 当 element 被移除后,关联数据自动释放
const metadata = new WeakMap();
const element = document.getElementById('myElement');
metadata.set(element, { clickCount: 0 });

相比之下,普通的 Map 会永远持有键的引用,导致 DOM 元素无法释放。

最佳实践:编写内存安全的 SPA

1. 组件生命周期清理

现代框架提供了生命周期钩子,确保资源被正确释放:

// React
useEffect(() => {
  const handler = (e) => console.log(e);
  window.addEventListener('resize', handler);
  
  // 返回清理函数
  return () => window.removeEventListener('resize', handler);
}, []);

// Vue
onMounted(() => {
  const timer = setInterval(/* ... */, 1000);
  onUnmounted(() => clearInterval(timer));
});

// Angular
ngOnInit() {
  this.subscription = this.service.data$.subscribe(/* ... */);
}
ngOnDestroy() {
  this.subscription.unsubscribe();
}

2. 使用 AbortController 批量清理

AbortController 是一个优雅的批量清理机制:

const controller = new AbortController();

// 多个事件监听器共享同一个 signal
window.addEventListener('resize', handler, { signal: controller.signal });
document.addEventListener('click', handler, { signal: controller.signal });

// 一次性清理所有监听器
controller.abort();

3. 避免在闭包中捕获大对象

// ❌ 闭包捕获了整个 config
function setupHandler(config) {
  element.addEventListener('click', () => {
    processConfig(config); // config 可能很大
  });
}

// ✅ 只捕获需要的字段
function setupHandler(config) {
  const { id, name } = config; // 只提取需要的字段
  element.addEventListener('click', () => {
    processConfig({ id, name });
  });
}

4. 虚拟化长列表

无限滚动的列表如果不虚拟化,DOM 节点会无限增长:

// 使用虚拟滚动库,只渲染可见区域的元素
import { FixedSizeList } from 'react-window';

<FixedSizeList
  height={600}
  itemCount={10000}
  itemSize={35}
  width="100%"
>
  {({ index, style }) => (
    <div style={style}>Item {index}</div>
  )}
</FixedSizeList>

5. 定期内存审计

将内存检测集成到 CI/CD 流程中:

// 在 E2E 测试中添加内存断言
describe('Memory Leak Detection', () => {
  it('should not leak memory on route navigation', async () => {
    const initialMemory = await page.metrics().then(m => m.JSHeapUsedSize);
    
    for (let i = 0; i < 10; i++) {
      await page.click('[data-testid="navigate-away"]');
      await page.click('[data-testid="navigate-back"]');
    }
    
    const finalMemory = await page.metrics().then(m => m.JSHeapUsedSize);
    
    // 允许 20% 的增长,但不能超过
    expect(finalMemory).toBeLessThan(initialMemory * 1.2);
  });
});

结语

内存泄漏是 SPA 的隐形杀手。它不会立即暴露,而是在用户长时间使用后悄然降低体验。理解 V8 的内存模型和垃圾回收机制,掌握 DevTools 的分析技巧,遵循组件清理的最佳实践——这些是构建内存健壮应用的基础。

JavaScript 的垃圾回收机制解放了开发者,但并未免除我们的责任。当我们享受自动内存管理的便利时,也需要对代码中的每一个引用保持警惕。

记住 Joe Armstrong 的那句话:你持有了一根香蕉,但你最终得到了拿着香蕉的大猩猩,以及整个丛林。

每一个未清理的事件监听器、每一个被闭包捕获的对象、每一个分离的 DOM 节点,都可能成为那根引出整个丛林的香蕉。


参考资料