内存泄漏排查
00:00
JavaScript内存泄漏排查详解:Chrome DevTools Memory面板、堆快照、分配时间线。
1. 内存泄漏概述
1.1 什么是内存泄漏
内存泄漏是指程序不再使用的内存未被释放,导致可用内存持续减少:
1.2 常见泄漏原因
| 原因 | 示例 |
|---|---|
| 意外的全局变量 | function() { leaked = 'data' } |
| 遗忘的定时器 | setInterval 未清除 |
| 闭包引用 | 闭包持有大对象引用 |
| DOM 引用 | JS 变量持有已删除 DOM 节点 |
| 事件监听器 | 未移除的事件监听器 |
| Map/Set 无限增长 | 缓存未设上限 |
| Promise 未处理 | 永远 pending 的 Promise |
2. Chrome DevTools Memory 面板
2.1 三种分析模式
| 模式 | 用途 |
|---|---|
| Heap Snapshot | 某时刻的堆内存快照 |
| Allocation Timeline | 实时查看内存分配 |
| Allocation Sampling | 低开销采样,查看分配热点 |
2.2 堆快照分析流程
1. 打开 DevTools → Memory → Heap snapshot
2. 拍摄初始快照(Snapshot 1)
3. 执行操作(触发可能的泄漏)
4. 拍摄第二次快照(Snapshot 2)
5. 选择 "Snapshot 2" → 视图切换为 "Comparison"
6. 按 "Delta" 排序,找出增长最多的对象
2.3 快照视图
| 视图 | 说明 |
|---|---|
| Summary | 按构造函数分组 |
| Comparison | 两个快照的差异 |
| Containment | 对象引用关系(GC 根 → 对象) |
| Statistics | 内存分布饼图 |
2.4 关键列含义
| 列名 | 说明 |
|---|---|
| Constructor | 构造函数名 |
| Distance | 到 GC 根的距离 |
| Shallow Size | 对象自身占用的内存 |
| Retained Size | 对象及其引用占用的总内存 |
| Delta | 两次快照间的变化量 |
3. 常见泄漏排查
3.1 Detached DOM 节点
// 泄漏:引用了已从 DOM 移除的节点
const detachedNodes = [];
function createAndRemove() {
const el = document.createElement('div');
el.textContent = 'temporary';
document.body.appendChild(el);
document.body.removeChild(el);
detachedNodes.push(el); // el 已从 DOM 移除,但被数组引用
}
排查步骤:
- 拍摄堆快照
- 在搜索框输入
Detached - 查看
Detached HTMLDivElement等 - 点击查看 Retainers(引用链)
3.2 闭包泄漏
// 泄漏:闭包持有不必要的引用
function setup() {
const hugeData = new Array(1000000).fill('x');
return function process() {
// 只用了 hugeData.length,但整个数组被闭包持有
return hugeData.length;
};
}
const fn = setup(); // hugeData 无法被 GC
排查步骤:
- 拍摄堆快照
- 搜索
closure - 展开闭包的
[[Scope]] - 查看闭包捕获了哪些变量
3.3 事件监听器泄漏
// 泄漏:重复添加监听器
function setup() {
const element = document.getElementById('btn');
element.addEventListener('click', handler); // 每次调用都添加
}
// 修复:先移除再添加
function setup() {
const element = document.getElementById('btn');
element.removeEventListener('click', handler);
element.addEventListener('click', handler);
}
排查步骤:
- DevTools → Elements → Event Listeners
- 查看每个元素上的监听器数量
- 勾选 “Ancestors” 查看所有祖先的监听器
3.4 Map/Set 无限增长
// 泄漏:缓存无限增长
const cache = new Map();
function getData(key) {
if (!cache.has(key)) {
cache.set(key, fetchExpensiveData(key));
}
return cache.get(key);
}
// 修复:使用 LRU 缓存
class LRUCache {
constructor(maxSize = 100) {
this.maxSize = maxSize;
this.cache = new Map();
}
get(key) {
if (!this.cache.has(key)) return undefined;
const value = this.cache.get(key);
this.cache.delete(key);
this.cache.set(key, value); // 移到末尾
return value;
}
set(key, value) {
if (this.cache.has(key)) this.cache.delete(key);
this.cache.set(key, value);
if (this.cache.size > this.maxSize) {
const firstKey = this.cache.keys().next().value;
this.cache.delete(firstKey);
}
}
}
4. Allocation Timeline
4.1 使用方法
1. DevTools → Memory → Allocation instrumentation on timeline
2. 点击 Start
3. 执行操作
4. 点击 Stop
5. 蓝色柱状条表示仍在内存中的分配
6. 灰色柱状条表示已被 GC 回收的分配
4.2 识别泄漏模式
正常模式: ████████░░░░░░████░░░░░░░ (分配后回收)
泄漏模式: ████████████████████████ (持续增长不回收)
5. 自动化检测
5.1 Puppeteer 内存测试
const puppeteer = require('puppeteer');
(async () => {
const browser = await puppeteer.launch();
const page = await browser.newPage();
await page.goto('http://localhost:3000');
// 测量初始内存
const initial = await page.metrics();
// 模拟操作
for (let i = 0; i < 100; i++) {
await page.click('#add-item');
await page.waitForTimeout(100);
}
// 强制 GC
await page.evaluate(() => gc?.());
// 测量最终内存
const final = await page.metrics();
const growth = final.JSHeapUsedSize - initial.JSHeapUsedSize;
console.log(`内存增长: ${(growth / 1024 / 1024).toFixed(2)} MB`);
if (growth > 5 * 1024 * 1024) {
console.error('可能存在内存泄漏!');
}
await browser.close();
})();
5.2 Node.js 内存监控
// 使用 --expose-gc 标志启动
// node --expose-gc test.js
function checkMemory() {
global.gc();
const used = process.memoryUsage().heapUsed / 1024 / 1024;
console.log(`Heap used: ${used.toFixed(2)} MB`);
return used;
}
const baseline = checkMemory();
// 执行操作
for (let i = 0; i < 10000; i++) {
createObject();
}
const after = checkMemory();
const leak = after - baseline;
console.log(`潜在泄漏: ${leak.toFixed(2)} MB`);