全文约 550 行,覆盖以下核心内容:
| 章节 | 核心内容 |
|---|---|
| 二、V8 内存结构 | Stack vs Heap 的区别、New Space / Old Space / Large Object Space / Code Space 分区结构图 |
| 三、新生代 GC | Semi-space 双空间切换机制、Cheney 复制算法的 4 步流程、对象晋升到老生代的 3 个条件 |
| 四、老生代 GC | Mark-Sweep(标记-清除)→ 内存碎片问题 → Mark-Compact(标记-整理)→ 三色标记法实现增量 GC |
| 五、Orinoco 优化 | Parallel Scavenge(并行清除)、Incremental Marking(增量标记,含写屏障原理)、Concurrent Marking(完全并发标记),各阶段耗时对比 |
| 六、GC 触发时机 | Scavenger / Major GC / Full GC 各自的触发条件和代价 |
| 七、内存问题诊断 | DevTools Memory 面板三种模式、标准 7 步定位泄漏流程 、Shallow Size vs Retained Size 解读、Performance Monitor 实时监控、performance.memory API 编程监控 |
| 八、7 种泄漏模式 | 全局变量、遗忘定时器、遗忘事件监听器(含 AbortController 方案)、闭包意外保留、Detached DOM、Map vs WeakMap 选择、console.log 泄漏,每种都附修复代码 |
| 九、Vue/React 框架特有 | Vue 3 的 reactive/watch 清理、React Hooks 的 useEffect 清理 + useRef、SPA 路由切换检查清单(11 项) |
| 十、Node.js 内存排查 | --inspect + Chrome DevTools、process.memoryUsage() 监控、v8.writeHeapSnapshot()、Clinic.js/0x 等 CLI 工具 |
| 十一、实战总结 | 编码 check list + 发布前 check list + 可集成到项目的 MemoryMonitor 类(含持续增长检测) |
前端 V8 引擎垃圾回收机制与内存问题排查
一、为什么前端需要关注内存
1.1 前端的真实内存瓶颈
前端不是"内存无限"的环境,常见内存问题场景:
| 场景 | 表现 | 后果 |
|---|---|---|
| SPA 长时间不刷新 | 内存持续增长 | 页面越来越卡,最终崩溃 |
| 大列表未虚拟化 | 创建上万 DOM 节点 | 白屏 / 浏览器无响应 |
| 事件监听器未清理 | 组件销毁后监听仍存在 | 内存泄漏,闭包残留 |
| 定时器/WebSocket 未关闭 | 后台持续运行 | 不必要的 CPU + 内存消耗 |
| 大量数据缓存在内存中 | 全局变量持有大对象引用 | GC 频繁,主线程卡顿 |
| 闭包引用意外保留 | 本该释放的对象被引用 | 内存泄漏 |
核心认知:JS 是自动垃圾回收语言,但不是"写了就不用管"。理解 GC 原理才能写出不卡顿、不崩溃的代码。
二、V8 内存结构全景
2.1 内存分区架构
css
┌──────────────────────────────────────────────────────────────────┐
│ V8 进程内存 │
│ │
│ ┌─────────────────┐ ┌─────────────────────────────────────────┐ │
│ │ Stack(栈) │ │ Heap(堆) │ │
│ │ │ │ │ │
│ │ 函数调用帧 │ │ ┌───────────────────────────────────┐ │ │
│ │ 局部变量 │ │ │ New Space(新生代 / 年轻代) │ │ │
│ │ 参数 │ │ │ 1~8 MB │ │ │
│ │ 返回值地址 │ │ │ │──── Semi-space 0 ────│ │ │ │
│ │ │ │ │ │──── Semi-space 1 ────│ │ │ │
│ │ 自动回收(LIFO) │ │ │ Scavenger 算法 │ │ │
│ │ │ │ └───────────────────────────────────┘ │ │
│ │ │ │ │ │
│ │ │ │ ┌───────────────────────────────────┐ │ │
│ │ │ │ │ Old Space(老生代) │ │ │
│ │ │ │ │ 几十 MB ~ 几 GB │ │ │
│ │ │ │ │ Mark-Sweep + Mark-Compact 算法 │ │ │
│ │ │ │ └───────────────────────────────────┘ │ │
│ │ │ │ │ │
│ │ │ │ ┌───────────────────────────────────┐ │ │
│ │ │ │ │ Large Object Space(大对象空间) │ │ │
│ │ │ │ │ ≥ 1MB 的对象直接分配在此 │ │ │
│ │ │ │ └───────────────────────────────────┘ │ │
│ │ │ │ │ │
│ │ │ │ ┌───────────────────────────────────┐ │ │
│ │ │ │ │ Code Space(代码空间) │ │ │
│ │ │ │ │ JIT 编译后的机器码 │ │ │
│ │ │ │ └───────────────────────────────────┘ │ │
│ │ │ │ │ │
│ │ │ │ ┌───────────────────────────────────┐ │ │
│ │ │ │ │ Cell / PropertyCell / Map Space │ │ │
│ │ │ │ └───────────────────────────────────┘ │ │
│ └─────────────────┘ └─────────────────────────────────────────┘ │
└──────────────────────────────────────────────────────────────────┘
2.2 栈(Stack)vs 堆(Heap)
javascript
// ─── 栈:保存原始类型和引用地址 ───
function foo() {
const a = 42; // 栈:a = 42(原始值直接存栈)
const b = true; // 栈:b = true
const c = { x: 1 }; // 栈:c = 0x001(存的是堆地址); 堆:{ x: 1 }
const d = [1, 2, 3]; // 栈:d = 0x002; 堆:[1, 2, 3]
bar(); // bar 的调用帧压入栈
}
function bar() {
let e = 'hello'; // 栈:e = 0x003(字符串在堆,栈只存指针)
// bar 返回后,e 和 bar 的栈帧出栈,但堆中字符串不立即回收(等 GC)
}
关键区别:
| 栈 | 堆 | |
|---|---|---|
| 存储内容 | 基本类型值、引用地址 | 对象、数组、函数、闭包 |
| 回收方式 | 函数返回时自动弹出 | 由 GC 判断是否可达 |
| 大小 | 很小(1~8 MB) | 大(浏览器 Tab 可达几 GB) |
| 速度 | 极快(LIFO) | 需要 GC 扫描,有开销 |
2.3 内存分配限制
javascript
// 64 位系统 V8 默认堆大小
// Node.js: 老生代 ≈ 1.4 GB(64位)/ 0.7 GB(32位)
// Chrome Tab: 根据系统内存动态调整,通常 2~4 GB
// 查看当前内存限制
console.log(performance.memory); // Chrome 专用
// { jsHeapSizeLimit: 2172649472, totalJSHeapSize: 12345678, usedJSHeapSize: 8765432 }
三、新生代 GC:Scavenger 算法
3.1 为什么分代
绝大多数对象都是"朝生夕死"------创建后很快就不再需要:
javascript
function processData() {
const temp = heavyComputation(); // temp 只在这一帧有用
use(temp);
// processData 返回后 temp 就可以回收了
// 这类短命对象比例 > 90%
}
分代 GC 的核心思想:先快速回收短命对象,长命对象晋升后减少扫描频率。
3.2 New Space 结构(Semi-space)
vbnet
New Space 被平分为两个半空间(semi-space):
Before GC:
┌─────────────────────┐ ┌─────────────────────┐
│ From Space(活跃) │ │ To Space(空闲) │
│ │ │ │
│ obj1 obj2 obj3 obj4 │ │ 空空如也... │
│ (有些已死) │ │ │
└─────────────────────┘ └─────────────────────┘
After GC:
┌─────────────────────┐ ┌─────────────────────┐
│ To Space(空闲) │ │ From Space(活跃) │
│ │ │ │
│ 空空如也... │ │ obj1 obj2 (存活对象) │ [obj3 obj4 已被回收]
│ │ │ 紧凑排列,无碎片 │
└─────────────────────┘ └─────────────────────┘
3.3 Cheney 算法执行步骤
vbnet
Step 1: 从根对象(Roots)出发,标记 From Space 中的存活对象
Roots: 全局对象 window
当前执行栈上的变量
闭包引用
...
Step 2: 将存活对象逐个复制到 To Space
→ 紧凑排列,无内存碎片
→ 同时更新所有引用指针
Step 3: 复制完成后,清空整个 From Space(极快,直接重置指针)
Step 4: 交换 From 和 To 的角色
→ 之前是 From 的变成 To(下次 GC 的空闲空间)
→ 之前是 To 的变成 From(下次 GC 的活跃空间)
耗时:通常 1~5 ms(空间小,只处理 1~8 MB)
优点:
- 不需要遍历整个堆,只扫描存活对象
- 天然实现内存紧凑(compact),无碎片
- 存活对象少时极快
缺点:
- 只能处理小空间(1~8 MB)
- 需要复制对象(长命对象会被反复复制)
- 只有一半空间可用(From Space 用满就触发 GC)
3.4 晋升(Promotion):从新生代到老生代
对象满足以下任一条件时,从新生代晋升到老生代:
javascript
// 条件 1:经历过两次 Scavenger GC 还活着
function lifecycle1() {
let obj = { data: 'test' };
// 如果 GC 发生 2 次而 obj 仍被引用 → 晋升
}
// 条件 2:To Space 使用率超过 25%
// 防止 To Space 被大对象撑满导致频繁 GC
// 条件 3:对象大小超过 Semi-space 的 25%
// 大对象直接进入老生代(或 Large Object Space)
晋升过程:
sql
New Space (From) Old Space
│ │
obj (第1次 GC 存活) ──────→ 仍在 New Space,年龄+1
│
obj (第2次 GC 存活) ═══════→ 晋升到 Old Space
(不再参与频繁的 Scavenger)
四、老生代 GC:Major GC(Mark-Sweep-Compact)
4.1 老生代的特点与挑战
vbnet
特点:
├── 空间大(几十 MB ~ 几 GB)
├── 对象存活率高(经过筛选晋升上来的都是"长命"对象)
└── 存活对象非常多
挑战:
├── 全量扫描耗时极长(几百 ms ~ 几秒)
├── 扫描期间如果 Stop-The-World → 用户感知到明显卡顿
└── 如何减少卡顿?→ 并发 / 增量 / 并行 GC
4.2 Mark-Sweep(标记-清除)
less
Step 1: Mark(标记)
从根对象出发,遍历所有可达对象,打上"存活"标记
Roots
│
├──→ obj1 [存活 ✓]
│ ├──→ obj1.a [存活 ✓]
│ └──→ obj1.b [存活 ✓]
│
└──→ obj2 [存活 ✓]
obj3 [无标记 → 垃圾] 有人创建了它,但没有任何引用指向它
obj4 [无标记 → 垃圾]
obj5 [无标记 → 垃圾]
Step 2: Sweep(清除)
遍历堆中所有对象,回收没有标记的对象
Before: [obj1] [obj3_垃圾] [obj2] [obj4_垃圾] [obj5_垃圾]
After: [obj1] [____空闲___] [obj2] [____空闲____][__空闲___]
↑
内存碎片!
问题:内存碎片。 空闲内存被分割成小块,即使总空闲量够,大对象也可能无法分配连续空间。
4.3 Mark-Compact(标记-整理)
在 Mark-Sweep 基础上增加一步:
ini
Step 3: Compact(整理)
将所有存活对象移动到内存的一端,空闲空间合并成连续的一大块
Before: [obj1] [空闲] [obj2] [空闲] [空闲]
After: [obj1] [obj2] [_____________连续空闲空间_____________]
Compact 非常耗时(需要移动对象 + 更新所有引用指针),所以只在不必要时不做。
V8 的权衡策略:通常只做 Mark-Sweep,当碎片化严重时才做 Mark-Compact。
4.4 三色标记法(Tri-color Marking)
这是 V8 增量标记的基础数据结构:
markdown
白色(White): 还未被 GC 访问到 → 垃圾候选
灰色(Gray): 已被标记,但其引用的子对象还未被标记 → 待处理
黑色(Black): 对象及其所有子对象都已被标记 → 确认存活
初始状态:所有对象都是白色
│
▼
将根对象标记为灰色,放入标记工作队列
│
▼
循环:取一个灰色对象
├── 将其标记为黑色
└── 将其引用的白色对象标记为灰色,加入队列
│
▼
队列为空,标记完成 → 剩余白色对象 = 垃圾
三色标记的最大价值:支持增量 GC------应用程序可以在"灰色对象被处理"的间隙中执行,两个步骤之间暂停标记,让 JS 执行一会儿,再继续。
五、Orinoco:V8 的并发/并行 GC 优化
5.1 Orinoco 项目的四个阶段
V8 从 Chrome 64 开始逐步引入 Orinoco 项目,大幅减少 GC 导致的卡顿:
scss
┌───────────────────────────────────────────────────────────────┐
│ GC 执行策略 │
├──────────┬──────────┬──────────┬──────────┬──────────────────┤
│ Parallel│ Increment│ Concurrent│ Concurrent│ Concurrent │
│ Scavenge│ al Mark │ Mark │ Sweep │ Compact │
├──────────┼──────────┼──────────┼──────────┼──────────────────┤
│ 多线程 │ 增量 │ 后台线程 │ 后台线程 │ 后台线程 │
│ 并行执行 │ 标记 │ 标记 │ 清除 │ 整理(部分) │
│ │ 主线程 │ (无STW) │ (无STW) │ │
│ │ 可穿插 │ │ │ │
└──────────┴──────────┴──────────┴──────────┴──────────────────┘
5.2 Parallel Scavenge(并行清除)
ini
主线程 + 多个 Helper 线程同时参与新生代 Scavenger GC
主线程: [复制 obj1] ────────────── [复制 obj4] ──→ 继续执行 JS
Helper 1: [复制 obj2] ──────
Helper 2: [复制 obj3] ──────────
三线程并行,新生代 GC 耗时从 5ms 降到 1~2ms
5.3 Incremental Marking(增量标记)
markdown
不用 Stop-The-World 一次性标记完,而是在 JS 执行间隙分批标记:
时间线:
JS 执行 ████████████████░░░░████████████░░░░████████░░░░████────→
标记 ________████████________________████████________████_______→
每批标记 5~10ms → pause(让 JS 运行)→ 下一批标记 5~10ms → pause → ...
整体标记时间可能更长,但单次暂停控制在 5ms 以内,用户无感知
增量标记的挑战------写屏障(Write Barrier):
javascript
// JS 在标记间隙中修改了对象引用关系
// 增量标记暂停时,obj1 已标记为黑色
// JS 此时执行:obj1.ref = obj_new; // 新引用了一个白色对象
// 如果不处理,obj_new 会被错误回收(黑色不应再引用白色)
// 解决方案:写屏障
// JS 写入时触发 Hook,如果写入目标是黑色对象,将新引用的白色对象直接标记为灰色
5.4 Concurrent Marking(并发标记)
markdown
标记完全在后台线程执行,主线程不停顿!
主线程: ██████████████████████████████████████────→ JS 始终运行
Worker: ████████████████████████████████────→ 后台标记
挑战:
- 需要读屏障(JS 读对象时可能触发额外逻辑)
- 标记期间的"最终暂停"还是需要的(处理标记期间 JS 修改的引用)
- 但暂停时间极短(通常 < 1ms)
5.5 各 GC 阶段耗时对比
| 阶段 | 旧方式(STW) | Orinoco 优化后 | 用户感知 |
|---|---|---|---|
| 新生代 GC | ~5ms 暂停 | ~1ms 暂停(并行) | 几乎无感知 |
| 老生代标记 | 500~2000ms 暂停 | 5~10ms 增量 × N 次 | 无感知 |
| 老生代清除 | 100~500ms 暂停 | 后台并发(无暂停) | 无感知 |
| 老生代整理 | 100~2000ms 暂停 | 部分并发 + 碎片化时触发 | 偶尔感知 |
记住:现代 V8 的 GC 暂停已经做到 < 10ms/次,正常业务代码不会因此卡顿。但如果你的代码在短时间内创建海量对象(如大循环中 new 巨量对象),Old Space 会被迅速填满,触发 Full GC,还是可能卡上百毫秒。
六、GC 触发的完整时机
sql
触发 Scavenger(新生代 GC):
├── Semi-space 分配指针到底了(From Space 满)
├── 频率:通常是几 MB 分配就触发,非常高
└── 代价:很低(1~2ms),用户无感
触发 Major GC(老生代 GC):
├── Old Space 使用率达到阈值(动态调整,通常 > 90%)
├── 老生代碎片化严重,无法分配连续空间
├── Large Object Space 满了
└── 代价:增量标记 + 后台并发,累计 200~500ms(分摊到多次小暂停)
触发 Full GC(完全 GC):
├── Old Space 真的满了,增量标记跟不上分配速度
├── 所有半空间 + 老生代一起回收
└── 必须 STW,通常 500ms ~ 数秒,用户感知明显卡顿
七、日常项目中的内存问题诊断
7.1 Chrome DevTools Memory 面板
打开方式:F12 → Memory 标签页
三种快照模式:
| 模式 | 作用 | 使用场景 |
|---|---|---|
| Heap Snapshot | 拍一张当前堆内存的快照 | 对比两次快照,看哪些对象没有释放 |
| Allocation instrumentation | 按时间线记录每次内存分配 | 看到内存"锯齿"增长的时间点 |
| Allocation sampling | 采样式记录内存分配 | 轻量级,对性能影响小 |
7.2 定位内存泄漏的标准流程
vbnet
Step 1: 打开 Memory 面板,选择 "Heap Snapshot"
Step 2: 拍一张初始快照(Snapshot 1)
← 页面刚加载完成,稳定状态
Step 3: 执行疑似泄漏的操作(打开弹窗 → 关闭、切换路由 → 返回...)
← 模拟用户操作,重复几次
Step 4: 拍第二张快照(Snapshot 2)
Step 5: 在 Snapshot 2 的视图切换到 "Comparison",对比 Snapshot 1
Step 6: 查看 "New" / "Deleted" / "Delta"
← 重点看 Size Delta 大且不应该存在的对象
Step 7: 展开对象,查看其 Retainers(引用链)
← 找到是谁持有了这个对象的引用
关键指标解读:
yaml
Shallow Size: 对象自身占的内存
Retained Size: 对象自身 + 它独占引用的所有子对象的内存
(如果删掉这个对象,能释放多少内存)
内存泄漏的判断:
两次快照对比 → Delta > 0 → 这些对象应该在 Step 3 后被回收,
但还存活着 → 说明有引用被保留了 → 泄漏
7.3 快照分析实战示例
javascript
// 泄漏代码
class LeakyComponent {
constructor() {
this.data = new Array(10000).fill('leaky data');
// ★ 泄漏点:组件销毁后,定时器回调仍持有 this
this.timer = setInterval(() => {
console.log(this.data.length);
}, 1000);
}
destroy() {
// 忘记清除定时器!!!
// this.timer 已无法被外部访问,但定时器内部回调持有 this 引用
// this.data 永不被回收
}
}
DevTools 分析:拍两次快照后对比,会发现:
LeakyComponent对象有多个实例(每创建+销毁一次,就多一个无法回收的实例)- Retained Size 很大(因为
this.data = new Array(10000)) - 展开 Retainers 链 → 看到
setInterval→ 闭包 →this
7.4 Performance Monitor(实时监控)
arduino
F12 → 多点几下(Customize and control DevTools)→ More tools → Performance monitor
实时显示:
├── JS heap size:JS 堆当前大小
├── DOM Nodes:当前 DOM 节点数
├── JS event listeners:事件监听器数量
└── Documents:文档数量
操作页面同时观察:
→ 打开/关闭弹窗 → JS heap size 是否回到原来水平?没回来说明有泄漏
→ 切换路由 → DOM Nodes 是否减少?没减少说明旧 DOM 没被清理
→ 操作后 → JS event listeners 是否增加?持续增加说明监听器没解绑
7.5 使用 performance.memory API 编程监控
javascript
// 定时打印内存使用情况(仅 Chrome 支持)
setInterval(() => {
if (performance.memory) {
const { usedJSHeapSize, totalJSHeapSize, jsHeapSizeLimit } = performance.memory;
const usedMB = (usedJSHeapSize / 1024 / 1024).toFixed(2);
const totalMB = (totalJSHeapSize / 1024 / 1024).toFixed(2);
const percent = ((usedJSHeapSize / jsHeapSizeLimit) * 100).toFixed(1);
console.log(`[Memory] 已用: ${usedMB}MB | 总量: ${totalMB}MB | 占比: ${percent}%`);
}
}, 3000);
// 检测内存是否异常增长(简化版)
let lastUsed = 0;
let growthCount = 0;
setInterval(() => {
const used = performance.memory?.usedJSHeapSize || 0;
if (used > lastUsed * 1.1) { // 10% 增长
growthCount++;
if (growthCount > 5) {
console.warn('⚠️ 检测到内存持续增长,可能存在内存泄漏!');
growthCount = 0;
}
} else {
growthCount = 0;
}
lastUsed = used;
}, 10000);
八、常见内存泄漏模式与修复
模式 1:全局变量意外保留
javascript
// ❌ 泄漏
function createData() {
// 忘记 var/let/const,变成全局变量
hugeData = new Array(1000000).fill('data');
}
// ❌ 泄漏
function init() {
this.data = hugeObject; // this 指向 window(非严格模式)
}
init();
// ✅ 修复
function createData() {
const hugeData = new Array(1000000).fill('data'); // 局部变量,createData 返回后标记为可回收
return hugeData;
}
// ✅ 修复
function init() {
'use strict';
this.data = hugeObject; // 严格模式下 this 是 undefined,报错暴露问题
}
模式 2:遗忘的定时器
javascript
// ❌ 泄漏
class Player {
start() {
this.timer = setInterval(() => {
this.update(); // 回调持有 this → 整个 Player 实例无法回收
}, 100);
}
}
// ✅ 修复
class Player {
start() {
this.timer = setInterval(() => {
this.update();
}, 100);
}
destroy() {
if (this.timer) {
clearInterval(this.timer); // ★ 清除定时器
this.timer = null;
}
}
}
模式 3:遗忘的事件监听器
javascript
// ❌ 泄漏(Vue 2/React 类组件中常见)
mounted() {
window.addEventListener('resize', this.handleResize);
document.addEventListener('scroll', this.handleScroll);
// destroyed 时忘记 removeEventListener
}
// ✅ 修复
mounted() {
window.addEventListener('resize', this.handleResize);
document.addEventListener('scroll', this.handleScroll);
},
beforeUnmount() {
window.removeEventListener('resize', this.handleResize);
document.removeEventListener('scroll', this.handleScroll);
}
// ✅ 更优雅:使用 AbortController(现代浏览器)
mounted() {
this.controller = new AbortController();
window.addEventListener('resize', this.handleResize, {
signal: this.controller.signal,
});
},
beforeUnmount() {
this.controller.abort(); // 一次性移除所有监听器
}
模式 4:闭包意外保留
javascript
// ❌ 泄漏
function createLargeClosure() {
const hugeData = new Array(1000000).fill('data');
return function() {
// 这个闭包只用了 hugeData 的一个属性
return hugeData[0]; // 但整个 hugeData 都被保留了!
};
}
const fn = createLargeClosure();
// fn 不被释放 → hugeData 永远不被回收 → 即使只用了一个元素
// ✅ 修复:只保留需要的
function createOptimizedClosure() {
const hugeData = new Array(1000000).fill('data');
const firstElement = hugeData[0]; // 提取需要的
return function() {
return firstElement; // 闭包只引用一个值,hugeData 可以被 GC
};
}
模式 5:脱离的 DOM 引用(Detached DOM)
javascript
// ❌ 泄漏:JS 中持有 DOM 引用,但 DOM 已从页面移除
let cachedButton = document.getElementById('my-button');
document.body.removeChild(cachedButton); // DOM 从页面移除
// 但 cachedButton 仍持有 DOM 对象 → DOM 节点无法被 GC
// → 这个 DOM 称为 Detached DOM Tree,在内存中但不可见
// ✅ 修复
function removeButton() {
const button = document.getElementById('my-button');
if (button) {
button.remove(); // 或 parent.removeChild(button)
}
// button 变量超出作用域 → 可以被 GC
cachedRef = null; // 如果有全局缓存,记得置空
}
模式 6:Map/Set/WeakRef 的选择
javascript
// ❌ Map 强引用 → 键对象永远不被回收
const cache = new Map();
function process(obj) {
if (!cache.has(obj)) {
cache.set(obj, heavyComputation(obj));
}
return cache.get(obj);
}
// obj 即使外部不再使用,因为 Map 持有引用,永远无法回收
// ✅ WeakMap → 键被回收时,值自动回收
const cache = new WeakMap();
function process(obj) {
if (!cache.has(obj)) {
cache.set(obj, heavyComputation(obj));
}
return cache.get(obj);
}
// obj 没有被其他任何地方引用时 → 被 GC
// → cache 中对应的 key-value 自动消失
// 使用场景对比:
// Map: 键是字符串/数字等基本类型,或你需要主动控制生命周期
// WeakMap:键必须是对象,让 GC 自动管理(DOM 节点缓存、私有属性)
// WeakSet:只存对象引用,外部无引用时自动消失
模式 7:Console 对象的内存泄漏
javascript
// 生产环境保留 console.log 也会造成内存泄漏
// console.log 的参数(特别是大对象)会被 DevTools 保留
// 用户打开 F12 时,所有历史 log 中的对象都会被引用
function reportData() {
const hugeData = fetchLargeData();
console.log(hugeData); // ★ 生产环境不要直接用 console.log 打印大对象
return hugeData;
}
// ✅ 修复:生产环境处理
const isDev = process.env.NODE_ENV === 'development';
function safeLog(...args) {
if (isDev) console.log(...args);
}
九、Vue/React 框架特有的内存注意点
9.1 Vue 3
javascript
// ❌ 泄漏:响应式对象持有大外部对象
const state = reactive({
bigData: null,
});
// 组件卸载时 reactive 可能被其它引用持有,导致 bigData 无法释放
// ✅ 修复:手动释放
onBeforeUnmount(() => {
state.bigData = null;
});
// ❌ 泄漏:watch 未停止
watch(source, callback); // 组件卸载不会自动停止
// ✅ 修复
const stop = watch(source, callback);
onBeforeUnmount(stop);
// ⚠️ 注意:watchEffect 在 setup 中会自动随组件销毁而停止
// 但在 setup 之外创建的 watchEffect 也需要手动停止
9.2 React(Hooks)
javascript
// ❌ 泄漏:useEffect 中的定时器未清理
useEffect(() => {
const timer = setInterval(() => { /* ... */ }, 1000);
// 没有 return 清理函数!
}, []);
// ✅ 修复
useEffect(() => {
const timer = setInterval(() => { /* ... */ }, 1000);
return () => clearInterval(timer); // ★ cleanup
}, []);
// ❌ 泄漏:useRef 持有大对象
const bigDataRef = useRef(fetchHugeData());
// bigDataRef.current 在整个组件生命周期内都不会释放
// ✅ 修复:用 state,不需要时置空
const [bigData, setBigData] = useState(null);
// 或者不需要时:setBigData(null)
9.3 SPA 路由切换检查清单
scss
从 A 页面切换到 B 页面时,检查 A 页面的以下内容是否处理:
□ setInterval / setTimeout → clearInterval / clearTimeout
□ addEventListener → removeEventListener
□ WebSocket → close()
□ IntersectionObserver → disconnect()
□ MutationObserver → disconnect()
□ ResizeObserver → disconnect()
□ requestAnimationFrame → cancelAnimationFrame()
□ 全局 EventBus / mitt → off()
□ Redux / Pinia store 中的页面私有数据 → 是否需要清空
□ 外部库创建的实例(ECharts / Map 等)→ dispose()
十、Node.js 环境的内存排查
10.1 使用 --inspect + Chrome DevTools
bash
# 启动时开启 inspector
node --inspect --expose-gc server.js
# Chrome 打开 chrome://inspect → 找到 Node 进程 → inspect
# 进入 Memory 面板,操作和前端完全一样
10.2 使用 process.memoryUsage() 监控
javascript
// 定时打印 Node 进程内存
setInterval(() => {
const mem = process.memoryUsage();
// rss: 常驻集大小(OS 分配的实际物理内存)
// heapTotal: V8 堆总量
// heapUsed: V8 堆已用
// external: C++ 对象(Buffer 等)绑定的内存
// arrayBuffers: ArrayBuffer 和 SharedArrayBuffer
console.log({
rss: (mem.rss / 1024 / 1024).toFixed(2) + ' MB',
heapTotal: (mem.heapTotal / 1024 / 1024).toFixed(2) + ' MB',
heapUsed: (mem.heapUsed / 1024 / 1024).toFixed(2) + ' MB',
external: (mem.external / 1024 / 1024).toFixed(2) + ' MB',
});
}, 5000);
10.3 生成 Heap Snapshot 文件
javascript
// 方式1:代码中生成
const v8 = require('v8');
const fs = require('fs');
// 手动触发 GC 后拍快照(需要 --expose-gc)
if (global.gc) global.gc();
const snapshot = v8.writeHeapSnapshot();
console.log('Heap snapshot written to', snapshot);
// → Heap-2024-05-14T10-30-00.heapsnapshot
// 方式2:kill -USR2 <pid> 发送信号
// Node 会生成 heapdump 文件
10.4 检测内存泄漏的 CLI 工具
bash
# clinic.js - Node.js 性能诊断工具套件
npm install -g clinic
clinic doctor -- node server.js # 整体诊断
clinic heapprofiler -- node server.js # 专查内存
# node-memwatch(需编译)
npm install @airbnb/node-memwatch
# 监听 leak 事件、GC 统计
# 0x(火焰图)
npm install -g 0x
0x server.js # 生成火焰图,看哪些函数分配内存最多
十一、内存优化实战总结
11.1 日常开发检查清单
javascript
编码时:
□ 大列表用虚拟滚动(vue-virtual-scroller / react-window)
□ 事件监听器配对 add/remove
□ 定时器配对 setInterval/clearInterval
□ WebSocket 明确关闭时机
□ 缓存大对象使用 WeakMap
□ 避免在闭包中引用不必要的大对象
□ 全局变量/单例要节制
发布前:
□ 用 DevTools Memory 走一遍核心业务流程,拍对比快照
□ 用 Performance Monitor 观察长时间操作后各项指标是否回落
□ 用 Lighthouse 跑一遍,关注 Memory 相关建议
□ 生产环境移除 console.log(特别是大对象打印)
11.2 性能监控代码片段(集成到项目)
javascript
// 轻量级内存监控(可集成到 SPA 路由切换时)
class MemoryMonitor {
constructor(options = {}) {
this.threshold = options.threshold || 100; // MB 阈值
this.samples = [];
this.maxSamples = options.maxSamples || 20;
}
sample() {
if (!performance.memory) return null;
const { usedJSHeapSize, totalJSHeapSize, jsHeapSizeLimit } = performance.memory;
const usedMB = usedJSHeapSize / 1024 / 1024;
this.samples.push({ time: Date.now(), usedMB });
if (this.samples.length > this.maxSamples) {
this.samples.shift();
}
// 检查是否超过阈值
if (usedMB > this.threshold) {
console.warn(`[MemoryMonitor] 堆内存超过阈值: ${usedMB.toFixed(1)}MB > ${this.threshold}MB`);
}
// 检查是否持续增长(5 个样本持续上升 = 可疑)
if (this.samples.length >= 5) {
const recent = this.samples.slice(-5);
const isGrowing = recent.every((s, i) => i === 0 || s.usedMB >= recent[i - 1].usedMB);
if (isGrowing) {
console.warn('[MemoryMonitor] 检测到内存持续增长趋势,可能存在泄漏');
}
}
return { usedMB, totalMB: totalJSHeapSize / 1024 / 1024 };
}
start(interval = 10000) {
this.timer = setInterval(() => this.sample(), interval);
}
stop() {
if (this.timer) {
clearInterval(this.timer);
this.timer = null;
}
}
}
// 使用示例
const monitor = new MemoryMonitor({ threshold: 150 });
monitor.start(15000); // 每 15 秒采样
// SPA 路由切换时采样
router.afterEach(() => {
const report = monitor.sample();
if (report) console.log(`[路由切换] 堆内存: ${report.usedMB.toFixed(1)}MB`);
});
十二、参考资料
- V8 Blog - Trash Talk: Orinoco - V8 官方 GC 系列文章
- V8 Blog - Concurrent Marking - 并发标记详解
- V8 Blog - Orinoco: young generation garbage collection
- MDN - Memory Management
- Chrome DevTools - Memory Problems
- Chrome DevTools - Heap Profiler
- Fixing Memory Problems (Chrome DevTools)
- Node.js - Memory Diagnostics
- Clinic.js - Node.js 性能诊断工具