内存管理与垃圾回收 — 为什么你的页面越用越卡
周五下午,运营同事发来一张截图:“后台管理系统开着不动,过两个小时 Chrome 标签页就崩溃了。“你打开 DevTools 的 Memory 面板,录了一段堆快照,发现内存从 50MB 一路涨到 1.2GB——典型的内存泄漏。要定位根因,你需要理解 V8 引擎如何分配、组织和回收内存。这正是本章要讲的内容。
📋 开篇自测:你已经知道多少?
- V8 的堆内存分为哪几个区域?新生代和老生代各用什么回收算法?
- 为什么闭包、脱离 DOM 树的节点、被遗忘的定时器会导致内存泄漏?
- 在 Chrome DevTools 中,如何用”三次快照法”定位泄漏对象?
一、JavaScript 中的内存生命周期
1.1 三个阶段
无论哪种编程语言,内存使用都遵循相同的生命周期:
内存生命周期
分配 (Allocate) 使用 (Use) 释放 (Release)
┌──────────────┐ ┌──────────────┐ ┌──────────────┐
│ let user = │ │ user.name │ │ user = null │
│ { name: '张' }│ ──→ │ user.age │ ──→ │ 等待 GC 回收 │
│ │ │ ... │ │ │
└──────────────┘ └──────────────┘ └──────────────┘
↑ ↑
开发者显式完成 GC 自动完成
(C/C++ 也需要手动释放) (JavaScript 由引擎负责)
JavaScript 是自动管理内存的语言:分配和释放都由引擎完成。但”自动”不意味着”完美”——引擎只能回收确定不再被引用的对象,而开发者常常无意中保留了不必要的引用。
1.2 栈内存与堆内存
V8 内存布局概览
┌─────────────────────────────────────────────────────────┐
│ V8 进程内存 │
│ │
│ ┌─────────────┐ ┌──────────────────────────────────┐│
│ │ 栈 (Stack) │ │ 堆 (Heap) ││
│ │ │ │ ││
│ │ 基本类型值 │ │ 对象、数组、函数、闭包 ││
│ │ 函数调用帧 │ │ 字符串(较长的) ││
│ │ 引用地址 │ │ 正则表达式 ││
│ │ │ │ ││
│ │ 特点: │ │ 特点: ││
│ │ · 空间小 │ │ · 空间大 (早期默认约1.4GB/64位, ││
│ │ │ │ 现代版本根据可用内存动态调整) ││
│ │ · 自动回收 │ │ · 需要 GC 算法回收 ││
│ │ · 速度极快 │ │ · 存取速度相对慢 ││
│ └─────────────┘ └──────────────────────────────────┘│
└─────────────────────────────────────────────────────────┘
// 栈内存 vs 堆内存
let orderId = 20240315; // orderId 和值都在栈上
let orderInfo = { // orderInfo(引用)在栈上
product: '年度会员', // 对象本身在堆上
price: 299,
buyer: { name: '林晓' } // 嵌套对象也在堆上
};
let backup = orderInfo; // 复制引用,指向同一个堆对象
backup.price = 199;
console.log(orderInfo.price); // 199 —— 因为是同一个对象
二、V8 堆的分区结构
2.1 堆空间的五个区域
V8 将堆内存划分为多个区域,不同区域采用不同的管理策略:
V8 堆内存分区
┌──────────────────────────────────────────────────────┐
│ V8 Heap │
│ │
│ ┌───────────────────┐ ┌──────────────────────────┐ │
│ │ 新生代 (Young) │ │ 老生代 (Old) │ │
│ │ (1~16MB 可配) │ │ (默认约 1.4GB) │ │
│ │ │ │ │ │
│ │ ┌─────┐ ┌─────┐ │ │ ┌──────────────────┐ │ │
│ │ │From │ │ To │ │ │ │ Old Pointer Space│ │ │
│ │ │Space│ │Space│ │ │ │ (有指针的对象) │ │ │
│ │ └─────┘ └─────┘ │ │ ├──────────────────┤ │ │
│ │ │ │ │ Old Data Space │ │ │
│ │ 存放新创建的 │ │ │ (纯数据对象) │ │ │
│ │ 短命对象 │ │ └──────────────────┘ │ │
│ └───────────────────┘ └──────────────────────────┘ │
│ │
│ ┌──────────────┐ ┌──────────┐ ┌──────────────────┐ │
│ │ Large Object │ │ Code │ │ Map Space │ │
│ │ Space │ │ Space │ │ (隐藏类/形状) │ │
│ │ (大对象直接 │ │ (JIT编译 │ │ │ │
│ │ 分配在此) │ │ 的机器码)│ │ │ │
│ └──────────────┘ └──────────┘ └──────────────────┘ │
└──────────────────────────────────────────────────────┘
2.2 为什么要分代
V8 的分代策略基于一个统计学事实——代际假说(Generational Hypothesis):
| 特征 | 新生代对象 | 老生代对象 |
|---|---|---|
| 生命周期 | 短,大多很快不再使用 | 长,被长期持有 |
| 典型场景 | 函数内的局部变量、临时计算结果 | 全局缓存、长驻单例、DOM 绑定 |
| 空间大小 | 较小(1~16MB) | 较大(约 1.4GB) |
| 回收频率 | 频繁 | 较少 |
| 回收算法 | Scavenge(复制算法) | Mark-Sweep + Mark-Compact |
统计显示,大约 70%~90% 的对象在创建后很快就不再被引用。把这些”朝生暮死”的对象单独管理,可以用更高效的算法快速回收,不必每次都扫描整个堆。
三、新生代回收:Scavenge 算法
3.1 核心思想:空间换时间
新生代使用 Scavenge 算法(基于 Cheney 算法),将空间一分为二:
Scavenge 算法执行过程
初始状态: From 空间正在使用, To 空间空闲
From Space To Space
┌──┬──┬──┬──┬──┐ ┌──┬──┬──┬──┬──┐
│A │B │C │D │E │ │ │ │ │ │ │
└──┴──┴──┴──┴──┘ └──┴──┴──┴──┴──┘
↑存活 ↑死亡 ↑存活 (空闲)
↑死亡 ↑存活
Step 1: 标记存活对象 (A, C, E 仍被引用)
Step 2: 将存活对象复制到 To 空间 (紧凑排列)
Step 3: 清空 From 空间
Step 4: From 和 To 角色互换
To → 新的 From From → 新的 To
┌──┬──┬──┬──┬──┐ ┌──┬──┬──┬──┬──┐
│A │C │E │ │ │ │ │ │ │ │ │
└──┴──┴──┴──┴──┘ └──┴──┴──┴──┴──┘
存活对象紧凑排列 清空, 等待下次使用
无内存碎片!
3.2 晋升到老生代
如果一个对象在新生代中”活过”了一次 Scavenge,说明它可能是长寿对象。V8 会将它**晋升(Promote)**到老生代。晋升的条件有两个(满足任一即触发):
- 已经历过一次 Scavenge——对象从 From 复制到 To 后被标记,下次再触发 Scavenge 时直接晋升
- To 空间使用率超过 25%——为了保证后续分配有足够空间,提前晋升
晋升流程
新生代 (From Space) 老生代
┌────────────────────┐ ┌────────────────────┐
│ obj_X (第2次存活) │ ────晋升──→│ obj_X │
│ obj_Y (第1次存活) │ ──复制到To │ │
│ obj_Z (已死亡) │ ──回收 │ (长期存在的对象) │
└────────────────────┘ └────────────────────┘
四、老生代回收:Mark-Sweep 与 Mark-Compact
4.1 标记-清除(Mark-Sweep)
老生代空间大、对象多,不适合用复制算法(浪费一半空间)。V8 采用 Mark-Sweep:
Mark-Sweep 过程
Step 1: 标记阶段 — 从根对象出发,遍历所有可达对象
根 (GC Roots)
│
├──→ [A] ──→ [B]
│
├──→ [C] ──→ [D] ──→ [E]
│
└──→ [F]
堆内存: [A][G][B][H][C][D][I][E][F]
↑ ↑ ↑
不可达 不可达 不可达
Step 2: 清除阶段 — 回收未标记的对象
堆内存: [A][空][B][空][C][D][空][E][F]
↑ ↑ ↑
空洞 空洞 空洞 ← 产生内存碎片!
4.2 标记-整理(Mark-Compact)
Mark-Sweep 会产生内存碎片——零散的空洞可能无法容纳新的大对象。Mark-Compact 在标记完成后增加一步:将存活对象向一端移动,然后清理边界外的内存。
Mark-Compact 过程
标记后: [A][空][B][空][C][D][空][E][F]
整理: [A][B][C][D][E][F][空][空][空]
↑ ↑
存活对象紧凑排列 边界之后全部释放
无碎片! 连续可用空间!
| 算法 | 优点 | 缺点 | 适用场景 |
|---|---|---|---|
| Mark-Sweep | 速度快,不移动对象 | 产生内存碎片 | 碎片不严重时 |
| Mark-Compact | 无碎片,分配高效 | 需要移动对象,耗时更长 | 碎片严重时 |
V8 在实际运行中交替使用两种算法:多数时候用 Mark-Sweep(速度快),当碎片率过高时切换到 Mark-Compact。
五、减少卡顿:增量标记与并发回收
5.1 全停顿的问题
早期 V8 执行 GC 时会触发 Stop-The-World(全停顿)——暂停 JavaScript 执行,等 GC 完成后再继续。老生代可能有数百 MB 数据,一次完整标记需要几百毫秒,用户会明显感到页面卡顿。
全停顿 (Stop-The-World)
JS执行: ═══════╗ ╔═══════════
║ GC 标记 + 清除 ║
║ (200~500ms 停顿) ║
╚═════════════════════╝
↑ ↑
页面冻结 恢复响应
5.2 增量标记(Incremental Marking)
V8 从 2012 年起引入增量标记:将标记工作拆分成许多小段(每段约 5ms),穿插在 JavaScript 执行之间:
增量标记
传统全停顿:
JS ══════╗ GC ╔══════
╚═══════════════════════╝
200ms 卡顿
增量标记:
JS ══╦══╦══╦══╦══╦══╦══╦══╦══╦══
║ ║ ║ ║ ║ ║ ║ ║ ║
GC片段 GC GC GC GC GC GC GC GC
5ms 5ms ... 每段很短, 用户无感知
V8 使用**三色标记法(Tri-color Marking)**来支持增量标记:
三色标记法
白色: 未被访问的对象 (GC结束后仍为白色的将被回收)
灰色: 已被访问,但其引用的对象尚未全部处理
黑色: 已被访问,且其引用的对象全部已处理
标记过程:
初始: 所有对象为白色
Step 1: 根对象标灰
[灰A]──→[白B]──→[白C]
│
└──→[白D]
Step 2: 处理灰色对象A → 标黑, 子对象标灰
[黑A]──→[灰B]──→[白C]
│
└──→[灰D]
(此时可暂停, 下次从灰色对象继续)
Step 3: 继续处理灰色对象
[黑A]──→[黑B]──→[灰C]
│
└──→[黑D]
Step 4: 完成
[黑A]──→[黑B]──→[黑C] ← 存活
│
└──→[黑D] ← 存活
[白E] [白F] ← 回收!
5.3 并发回收与并行回收
现代 V8 还引入了**并发(Concurrent)和并行(Parallel)**策略:
三种优化策略对比
并行回收 (Parallel):
主线程: [────── GC ──────]
辅助线程: [────── GC ──────] 多个线程同时做GC, JS仍暂停
辅助线程: [────── GC ──────] 但GC本身更快
并发回收 (Concurrent):
主线程: [──── JS执行 ────][短暂暂停][── JS执行 ──]
GC线程: [──────── GC标记 ────────]
GC在后台线程执行, 不阻塞JS
增量+并发+并行 (V8实际策略):
主线程: JS [增量GC] JS [增量GC] JS [短暂暂停:最终标记+清除]
GC线程: [并发标记......] [并行清除......]
六、常见内存泄漏与排查
6.1 四种典型内存泄漏
// 泄漏1: 意外的全局变量
function handleUpload(fileData) {
// 忘记写 let/const,uploadCache 成了 window.uploadCache
uploadCache = processLargeFile(fileData); // 永远不会被回收!
}
// 泄漏2: 被遗忘的定时器
function startPolling(endpoint) {
const hugePayload = new ArrayBuffer(10 * 1024 * 1024); // 10MB
setInterval(() => {
fetch(endpoint, { body: hugePayload });
}, 5000);
// 即使组件销毁了,定时器仍在运行,hugePayload 无法回收
}
// 泄漏3: 闭包持有外部大对象
function createProcessor() {
const rawDataset = loadGiantDataset(); // 100MB 的原始数据
return function summarize() {
// summarize 只需要 rawDataset.length
// 但闭包持有整个 rawDataset 引用,100MB 无法回收
return rawDataset.length;
};
}
// 泄漏4: 脱离DOM树但仍被引用的节点
let detachedNodes = [];
function replaceWidget() {
const oldWidget = document.getElementById('dashboard-widget');
detachedNodes.push(oldWidget); // JS 仍引用旧节点
const newWidget = document.createElement('div');
oldWidget.parentNode.replaceChild(newWidget, oldWidget);
// oldWidget 已从 DOM 树移除,但 detachedNodes 数组持有引用
// 旧节点及其所有子节点都无法被 GC 回收
}
6.2 修复策略
// 修复1: 使用严格模式或 lint 规则
'use strict';
function handleUpload(fileData) {
const uploadCache = processLargeFile(fileData); // 局部变量,函数结束即可回收
}
// 修复2: 保存定时器ID,在恰当时机清除
function startPolling(endpoint) {
const hugePayload = new ArrayBuffer(10 * 1024 * 1024);
const timerId = setInterval(() => {
fetch(endpoint, { body: hugePayload });
}, 5000);
// 返回清理函数 (适用于 React useEffect / Vue onUnmounted)
return () => clearInterval(timerId);
}
// 修复3: 只保留需要的数据
function createProcessor() {
const rawDataset = loadGiantDataset();
const dataLength = rawDataset.length; // 只提取需要的值
// rawDataset 不再被闭包引用,可被 GC 回收
return function summarize() {
return dataLength;
};
}
// 修复4: 使用 WeakRef 或及时清除引用
let widgetRegistry = new WeakSet();
function replaceWidget() {
const oldWidget = document.getElementById('dashboard-widget');
widgetRegistry.add(oldWidget); // WeakSet 不阻止 GC
const newWidget = document.createElement('div');
oldWidget.parentNode.replaceChild(newWidget, oldWidget);
// oldWidget 从 DOM 移除后,WeakSet 中的引用不阻止回收
}
6.3 用 DevTools 定位泄漏:三次快照法
三次快照法
Step 1: 打开 DevTools → Memory → Take heap snapshot
(记录初始状态)
Step 2: 执行可能泄漏的操作 (如打开/关闭弹窗)
Step 3: 手动触发一次 GC (点击 Memory 面板的垃圾桶图标)
Step 4: 再次 Take heap snapshot
Step 5: 重复 Step 2~4
Step 6: 对比三次快照
快照1 快照2 快照3
50MB 65MB 80MB
↑ ↑
增长15MB 又增长15MB
← 稳定增长意味着泄漏! →
在快照3中选择 "Comparison" 视图, 对比快照2:
- 找到 Delta 列为正数的 Constructor
- 展开查看哪些对象在增长
- 点击对象查看 Retainers (是谁持有了这个引用)
七、WeakRef 与 FinalizationRegistry
7.1 弱引用
ES2021 引入了 WeakRef,允许持有对象的弱引用——不阻止 GC 回收目标对象:
// 场景: 图片预览缓存, 内存不足时自动释放
class ThumbnailCache {
#cache = new Map();
set(photoId, imageData) {
this.#cache.set(photoId, new WeakRef(imageData));
}
get(photoId) {
const ref = this.#cache.get(photoId);
if (!ref) return null;
const imageData = ref.deref(); // 获取原始对象, 若已被GC则返回undefined
if (!imageData) {
this.#cache.delete(photoId); // 清理失效条目
return null;
}
return imageData;
}
}
7.2 FinalizationRegistry
FinalizationRegistry 可以在对象被 GC 回收时收到通知:
const registry = new FinalizationRegistry((metadata) => {
console.log(`对象已被回收, 元数据: ${metadata}`);
// 在这里做资源清理,如关闭文件句柄、释放 WebGL 纹理
});
function loadTexture(textureId) {
const gpuBuffer = allocateGPUBuffer(textureId);
registry.register(gpuBuffer, textureId); // 注册回调
return gpuBuffer;
}
// 当 gpuBuffer 被 GC 回收时,registry 的回调会自动触发
WeakRef / WeakMap / WeakSet 对比
┌───────────────┬──────────────────────────────────────────────┐
│ API │ 特点 │
├───────────────┼──────────────────────────────────────────────┤
│ WeakRef │ 持有单个对象的弱引用, deref()获取 │
│ WeakMap │ 键必须是对象且为弱引用, 值为强引用 │
│ WeakSet │ 只存对象, 全部为弱引用 │
│ Finalization │ 对象被GC时触发回调, 用于清理外部资源 │
│ Registry │ │
└───────────────┴──────────────────────────────────────────────┘
注意: WeakRef 和 FinalizationRegistry 的回调时机不可预测,
不应在业务逻辑中依赖它们的执行顺序。
八、本章知识脉络总结
内存管理与垃圾回收知识地图
内存管理
├── 内存模型
│ ├── 栈: 基本类型、调用帧、引用地址
│ └── 堆: 对象、数组、闭包、字符串
│
├── V8 堆分区
│ ├── 新生代 (Young Generation)
│ │ ├── From Space / To Space
│ │ └── Scavenge 算法 (复制 + 角色互换)
│ │
│ ├── 老生代 (Old Generation)
│ │ ├── Mark-Sweep (标记-清除)
│ │ └── Mark-Compact (标记-整理)
│ │
│ └── 大对象区 / 代码区 / Map区
│
├── GC 优化
│ ├── 增量标记: 三色标记法, 分段执行
│ ├── 并发回收: GC线程与JS线程同时工作
│ ├── 并行回收: 多个GC线程协同
│ └── 代际假说: 大多数对象朝生暮死
│
├── 内存泄漏
│ ├── 意外全局变量
│ ├── 被遗忘的定时器/回调
│ ├── 闭包持有大对象
│ ├── 脱离DOM的节点引用
│ └── 排查: 三次快照法 + Retainers
│
└── 弱引用
├── WeakRef: 不阻止GC的引用
├── WeakMap / WeakSet: 弱引用容器
└── FinalizationRegistry: GC回收通知
思考题
-
在 React 的单页应用中,路由切换后旧页面的组件已经卸载,但如果旧组件中注册了
window.addEventListener('resize', handler)却没有在卸载时移除,会发生什么?从 GC 的角度解释为什么handler函数及其闭包引用的数据无法被回收。 -
WeakMap的键是弱引用,值是强引用。假设你用WeakMap缓存 DOM 节点对应的计算结果:cache.set(domNode, expensiveResult)。当domNode从 DOM 树移除并且没有其他引用时,expensiveResult会被回收吗?为什么?
📝 结尾自测:检验你的学习成果
- V8 的新生代使用什么算法回收垃圾?为什么这种算法适合新生代?
- Mark-Sweep 和 Mark-Compact 的区别是什么?V8 在什么情况下使用 Mark-Compact?
- 增量标记使用的三色标记法中,灰色对象代表什么?为什么需要灰色?
- 说出至少三种常见的内存泄漏场景,并给出对应的修复方法。
- 在 Chrome DevTools 中如何用三次快照法定位内存泄漏?
下一章预告:理解了浏览器如何管理内存之后,我们进入实战篇。下一章将从一个真实的性能监控报警出发,系统地拆解 CLS、LCP、FID 等核心 Web 指标,从加载优化到渲染优化,带你建立一套完整的页面性能治理方案。
购买课程解锁全部内容
前端进阶第一课:11 章掌握浏览器核心
¥29.90