打开任务管理器,你会发现一个令人困惑的现象:某些单页应用(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 的半空间复制算法:
-
初始状态:新生代被分为两个相等大小的半空间——From-space 和 To-space。新对象分配在 From-space。
-
触发时机:当 From-space 填满时,触发 Minor GC。
-
复制存活对象:从 GC Roots(栈指针、全局对象)开始遍历,将所有存活对象复制到 To-space。这个过程会自动完成内存整理(compaction),消除碎片。
-
晋升老生代:如果一个对象已经是第二次在 Minor GC 中存活,它会被直接移动到老生代。
-
空间翻转:复制完成后,From-space 和 To-space 角色互换。To-space 变成新的 From-space,准备接收新对象。
整个过程虽然是"stop-the-world"的,但由于新生代空间小,存活对象少,通常只需要几毫秒。
Major GC:老生代的全面清理
老生代的垃圾回收使用 Mark-Sweep-Compact 算法,分为三个阶段:
标记阶段:从 GC Roots 开始,通过深度优先搜索遍历整个对象图,标记所有可达对象。V8 使用三色标记法:
- 白色:未访问
- 灰色:已访问但子节点未处理完
- 黑色:已访问且子节点已处理完
清扫阶段:扫描堆内存,将未标记(白色)的对象占用的内存加入空闲列表(free-list)。这些空间可以被重新分配。
整理阶段:可选。将存活对象移动到连续的内存区域,减少内存碎片。整理会带来复制开销,但能提高后续分配效率。
Orinoco:打破停顿的边界
传统的垃圾回收会完全暂停 JavaScript 执行,导致明显的卡顿。V8 的 Orinoco 项目引入了三种技术来解决这个问题:
并行(Parallel):主线程和多个辅助线程同时工作。仍然是 stop-the-world,但总停顿时间被多个线程分摊。
增量(Incremental):将 GC 任务拆分成多个小片段,穿插在 JavaScript 执行间隙。虽然不减少总工作时间,但避免了长时间阻塞。
并发(Concurrent):辅助线程在后台完全独立执行 GC 工作,主线程持续运行 JavaScript。这是最理想的方案,但实现最复杂——需要处理并发读写竞争。
现代 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:页面加载后,执行一次垃圾回收,拍下第一个快照。
- 操作:执行你认为可能泄漏的操作(如打开/关闭模态框)。
- 快照 2:执行垃圾回收,拍下第二个快照。
- 重复操作:再次执行相同操作多次(建议 7 次,这是一个醒目的质数)。
- 快照 3:执行垃圾回收,拍下第三个快照。
- 比较:将快照 3 与快照 1 进行对比。
为什么重复 7 次?如果对象泄漏了,你会看到正好 7 个相同类型的对象在快照对比中出现。这个数字太独特,不可能是巧合。
分离 DOM 树检测
Chrome DevTools 提供了专门的过滤器来查找分离的 DOM 节点:
- 在 Memory 面板选择 “Heap snapshot”。
- 拍摄快照。
- 在过滤框中输入
Detached。 - 展开分离的 DOM 树,查看哪些对象持有引用。

保留路径追踪
找到泄漏对象后,关键问题是:谁持有它的引用?
Chrome DevTools 的 Retainers 面板展示了从 GC Roots 到目标对象的完整引用链。沿着这条链追溯,你就能找到泄漏的根源。
GC Roots
└── Window (全局对象)
└── EventListener (事件监听器)
└── Closure (闭包)
└── SomeComponent (组件实例)
└── leakedObject (泄漏的对象)
自动化检测:MemLab 与 fuite
手动使用 DevTools 分析内存泄漏既耗时又容易出错。Meta(Facebook)开发并开源了 MemLab,一个自动化内存泄漏检测框架。
MemLab 的工作流程:

- 导航到页面 A,拍摄堆快照 $S_A$。
- 导航到目标页面 B,拍摄堆快照 $S_B$。
- 返回页面 A,拍摄堆快照 $S_A'$。
- 计算泄漏对象:$(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 节点,都可能成为那根引出整个丛林的香蕉。
参考资料
- V8 Blog - Trash talk: the Orinoco garbage collector
- V8 Blog - Orinoco: young generation garbage collection
- Chrome DevTools - Fix memory problems
- Nolan Lawson - Fixing memory leaks in web applications
- Deepu K Sasidharan - Visualizing memory management in V8 Engine
- Meta Engineering - MemLab: An open source framework for finding JavaScript memory leaks
- Jay Conrod - A tour of V8: Garbage Collection
- MDN - Memory management
- BLeak: Automatically Debugging Memory Leaks in Web Applications (PLDI 2018)
- LeakPair: Proactive Repairing of Memory Leaks in Single Page Web Applications (ASE 2023)