# 内存泄漏排查SOP
从"应该避免什么"进阶到"出了问题怎么找"——资深与初级的分水岭
前置知识:2_1_v8Learn(V8 GC机制)+ 第05章(浏览器全链路内存)+ 第06章(JS堆与GPU显存桥梁)
阅读指南(初学者必看)
为什么你需要学习内存泄漏排查?
你已经知道了内存泄漏是什么(该释放的对象没被释放),也知道了常见原因(事件监听器、闭包、全局缓存)。但真正的问题是:
- 游戏上线后内存持续增长,重启才恢复,怎么找到泄漏点?
- 开发者工具里一堆数据,不知道看哪个?
- 修了一个泄漏,过两天又出现新的?
初级程序员的思维方式:"我知道这些可能导致泄漏,我会避免。" 资深程序员的思维方式:"泄漏一定会有,关键是能快速定位和修复。"
学完本章,你能回答:
- Chrome DevTools Memory 面板各功能怎么用?
- 三快照对比法的原理和操作步骤
- 5 种常见泄漏模式的排查方法
- 内存泄漏排查的 SOP(标准操作流程)
本文结构
第一部分:Chrome DevTools Memory 面板(4种功能详解)
第二部分:三快照对比法(最实用的排查方法)
第三部分:5 种常见泄漏模式与修复
第四部分:排查 SOP(标准操作流程)
第五部分:自动化检测与预防
一、Chrome DevTools Memory 面板
四种功能对比
| 功能 | 用途 | 开销 | 适用场景 | 学习难度 |
|---|---|---|---|---|
| Heap Snapshot | 某一时刻的堆快照 | 高(暂停JS) | 对比两个时间点,找增长的对象 | ⭐⭐ |
| Allocation Timeline | 实时分配追踪 | 中 | 定位频繁分配的代码位置 | ⭐⭐⭐ |
| Allocation Sampling | 采样分配统计 | 低 | 长时间运行的生产环境 | ⭐ |
| Performance Monitor | 实时指标 | 极低 | 持续监控 JS 堆大小 | ⭐ |
Heap Snapshot 详解
Heap Snapshot 显示的信息:
1. Constructor(构造函数)── 对象的类型
2. Distance(距离)── 到 GC 根的最短路径长度
3. Shallow Size(浅层大小)── 对象自身占用的内存
4. Retained Size(保留大小)── 对象及其引用的所有对象的总内存
关键概念:
- Shallow Size ≠ Retained Size
- 一个小引用(几十字节)可能 Retain 大量内存(几MB)
- 例:一个闭包引用了一个 10MB 的数组 → Shallow: 几十字节, Retained: 10MB
Retainers 面板(引用链)
Heap Snapshot → 选中一个对象 → 看 Retainers 面板
示例:
Object @12345 (10MB)
← retained by: Array @67890
← retained by: closure (onResize handler)
← retained by: window (global)
解读:
对象 @12345 被 Array @67890 引用
Array 被一个闭包引用
闭包被 window 全局对象引用
→ 泄漏原因:onResize 闭包引用了 Array,而 onResize 没有被移除
→ 修复:removeEventListener('resize', onResize)
Allocation Timeline 详解
使用步骤:
1. 点击 "Allocation instrumentation on timeline"
2. 点击 "Start"
3. 操作你的游戏(如:打开→关闭弹窗 5 次)
4. 点击 "Stop"
5. 看到一条时间线,上面有蓝色和红色的柱子
蓝色柱子 = 新分配的对象
红色柱子 = 被释放的对象
灰色区域 = 仍然存活的对象(可能泄漏)
操作:
- 选中某个时间段的蓝色柱子
- 查看这个时间段内分配了哪些对象
- 如果操作结束后这些对象还存活 → 可能泄漏
三种记录模式什么时候用?
- Heap Snapshot:适合分析"当前堆上有什么"
- Allocation Timeline:适合分析"内存什么时候分配的、哪些没释放"
- Allocation Sampling:适合分析"哪段代码分配了最多内存"
二、三快照对比法(最实用的排查方法)
操作步骤
1. 打开页面,操作到"稳定状态" → 拍 Snapshot 1
2. 执行一轮操作(如:打开→关闭一个弹窗)
3. 拍 Snapshot 2
4. 重复步骤 2(再打开→关闭弹窗)
5. 拍 Snapshot 3
6. 选择 Snapshot 3,过滤条件选 "Objects allocated between Snapshot 1 and 2"
7. 如果有持续增长的对象 → 就是泄漏!
为什么拍三次?
第1次操作可能产生缓存(合理的增长):
Snapshot 1 → Snapshot 2:+5MB(可能是正常的缓存)
第2次操作如果还增长就是泄漏:
Snapshot 2 → Snapshot 3:+5MB(和上次一样的增长 → 泄漏!)
如果是缓存,第2次操作不会增长:
Snapshot 2 → Snapshot 3:+0MB(缓存命中,没有新分配)
实战演示:排查弹窗泄漏
场景:游戏里打开→关闭商店弹窗,每次内存增长 2MB
步骤1:打开游戏,确保商店弹窗没打开过 → 拍 Snapshot 1
步骤2:打开商店弹窗,浏览商品,关闭弹窗 → 拍 Snapshot 2
步骤3:再次打开商店弹窗,浏览商品,关闭弹窗 → 拍 Snapshot 3
分析 Snapshot 3:
- 过滤:"Objects allocated between Snapshot 1 and 2"
- 发现:HTMLElement 增长了 200 个,Closure 增长了 50 个
- 查看 Retainers:事件监听器没有移除
- 修复:在关闭弹窗时 removeEventListener
验证:
- 修复后重复步骤1~3
- Snapshot 3 不再有持续增长的对象 → 修复成功!
高级技巧:用 Comparison 视图
Heap Snapshot 顶部有视图切换:
1. Summary ── 按构造函数分组(最常用)
2. Comparison ── 对比两个快照的差异
3. Containment ── 从 GC 根开始查看
4. Statistics ── 饼图统计
推荐流程:
1. 用 Summary 视图找到增长的构造函数
2. 用 Comparison 视图量化增长量
3. 用 Containment 视图追踪引用链
三、5 种常见泄漏模式与修复
模式1:事件监听器未移除
// ❌ 泄漏:打开弹窗时添加监听器,关闭时忘了移除
class ShopPopup {
constructor() {
this.onResize = () => this.adjustLayout();
window.addEventListener('resize', this.onResize);
this.onScroll = () => this.loadMore();
window.addEventListener('scroll', this.onScroll);
}
close() {
this.node.style.display = 'none'; // 只隐藏了,没移除监听器!
}
}
// ✅ 修复:关闭时移除所有监听器
class ShopPopup {
constructor() {
this.onResize = () => this.adjustLayout();
this.onScroll = () => this.loadMore();
window.addEventListener('resize', this.onResize);
window.addEventListener('scroll', this.onScroll);
}
close() {
window.removeEventListener('resize', this.onResize); // 必须移除!
window.removeEventListener('scroll', this.onScroll); // 必须移除!
this.node.style.display = 'none';
}
}
H5 游戏中常见的事件监听器泄漏:
// Cocos Creator 中的事件监听泄漏
// ❌ 泄漏
onEnable() {
this.node.on('touchstart', this.onTouch, this);
}
// 没有 onDisable 移除!
// ✅ 修复
onEnable() {
this.node.on('touchstart', this.onTouch, this);
}
onDisable() {
this.node.off('touchstart', this.onTouch, this);
}
// ✅ 更好的做法:用 target 参数自动管理
onEnable() {
// 第三个参数 this 会在 this 销毁时自动移除
this.node.on('touchstart', this.onTouch, this);
}
模式2:闭包持有引用
// ❌ 泄漏:闭包持有整个 game 对象
function createCallback(game) {
return function() {
console.log(game.state); // 闭包持有 game 引用
// game 可能很大(几MB),但闭包只需要 state
};
}
// ✅ 修复:只保存需要的值
function createCallback(game) {
const state = game.state; // 只保存需要的值
return function() {
console.log(state);
};
}
// ✅ 更好的修复:使用 WeakMap
const callbackMap = new WeakMap(); // key 是弱引用
function createCallback(game) {
callbackMap.set(game, game.state);
return function() {
const state = callbackMap.get(game);
console.log(state);
};
}
// game 对象被 GC 回收时,WeakMap 的条目自动消失
模式3:定时器未清除
// ❌ 泄漏:定时器一直运行
class GameLoop {
start() {
this.timer = setInterval(() => this.update(), 16);
}
// 没有 stop 方法!
}
// ✅ 修复
class GameLoop {
start() {
this.timer = setInterval(() => this.update(), 16);
}
stop() {
clearInterval(this.timer);
this.timer = null;
}
}
// Cocos Creator 中的定时器泄漏
// ❌ 泄漏
schedule(this.update, 0.016);
// 场景切换时没有 unschedule!
// ✅ 修复
schedule(this.update, 0.016);
// Cocos 会在节点销毁时自动 unschedule
// 但手动管理更安全:
unschedule(this.update);
模式4:全局缓存无限增长
// ❌ 泄漏:缓存只增不减
const textureCache = {};
function loadTexture(name) {
if (!textureCache[name]) {
textureCache[name] = createTexture(name);
}
return textureCache[name];
}
// 1000 个纹理 = 1000MB 显存!永远不会释放!
// ✅ 修复:LRU 缓存 + 手动释放
class LRUCache {
constructor(maxSize, onEvict) {
this.maxSize = maxSize;
this.cache = new Map(); // Map 保持插入顺序
this.onEvict = onEvict; // 淘汰时的回调
}
get(key) {
if (!this.cache.has(key)) return null;
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);
while (this.cache.size >= this.maxSize) {
const oldestKey = this.cache.keys().next().value; // 最老的
const oldValue = this.cache.get(oldestKey);
this.cache.delete(oldestKey);
if (this.onEvict) this.onEvict(oldestKey, oldValue); // 释放资源
}
this.cache.set(key, value);
}
}
// 使用
const textureCache = new LRUCache(100, (name, texture) => {
gl.deleteTexture(texture); // ⭐ 淘汰时释放 GPU 显存!
console.log(`淘汰纹理: ${name}`);
});
模式5:Detached DOM
// ❌ 泄漏:JS 引用阻止 GC 回收已移除的 DOM 节点
const container = document.getElementById('container');
const child = container.firstChild; // JS 持有引用
container.innerHTML = ''; // DOM 移除了,child 变成 Detached DOM
// ✅ 修复1:不要在移除之前保存引用
const container = document.getElementById('container');
container.innerHTML = ''; // 先移除
// 之后如果需要子元素,重新查询
// ✅ 修复2:移除后清空引用
const container = document.getElementById('container');
const children = Array.from(container.children); // 需要保存
container.innerHTML = '';
// 操作完成后清空
children.length = 0; // 清空数组
四、排查 SOP(标准操作流程)
步骤1:确认泄漏
1. 打开 Chrome DevTools → Performance Monitor
2. 观察 "JS Heap Size" 指标
3. 执行操作(如:打开→关闭弹窗 10 次)
4. 如果 JS Heap 持续增长不回落 → 确认泄漏
步骤2:定位泄漏对象
1. 使用三快照对比法
2. 找到持续增长的构造函数
3. 记录增长量(如:HTMLElement +200,Closure +50)
步骤3:找到引用链
1. 在 Heap Snapshot 中选中泄漏对象
2. 查看 Retainers 面板
3. 追踪从 GC 根到泄漏对象的引用链
4. 找到"不该存在的引用"
步骤4:找到代码位置
1. Retainers 中可以看到分配该对象的代码行号
2. 或者在 Allocation Timeline 中查看分配调用栈
3. 定位到具体的代码文件和行号
步骤5:修复并验证
1. 修改代码(如:添加 removeEventListener)
2. 刷新页面
3. 重复步骤1~4,确认不再泄漏
排查流程图
Performance Monitor 看到内存增长
│
▼
三快照对比法 ──▶ 找到增长的构造函数
│
▼
Retainers 面板 ──▶ 找到引用链
│
▼
源码定位 ──▶ 找到泄漏代码
│
▼
修复 + 验证 ──▶ 确认不再泄漏
五、自动化检测与预防
自动化泄漏检测脚本
// 在测试环境中使用
class MemoryLeakDetector {
constructor(threshold = 1024 * 1024) { // 默认阈值 1MB
this.threshold = threshold;
this.baseline = null;
}
// 记录基线
recordBaseline() {
if (global.gc) global.gc(); // 如果可用,手动触发 GC
this.baseline = process.memoryUsage().heapUsed;
}
// 检查是否泄漏
check(description) {
if (global.gc) global.gc();
const current = process.memoryUsage().heapUsed;
const diff = current - this.baseline;
if (diff > this.threshold) {
console.error(`⚠️ 内存泄漏检测: ${description}`);
console.error(` 增长量: ${(diff / 1024 / 1024).toFixed(2)}MB`);
console.error(` 当前堆: ${(current / 1024 / 1024).toFixed(2)}MB`);
return true;
}
console.log(`✅ 内存正常: ${description}, 增长: ${(diff / 1024).toFixed(0)}KB`);
return false;
}
}
// 使用示例
const detector = new MemoryLeakDetector();
// 测试弹窗
detector.recordBaseline();
for (let i = 0; i < 100; i++) {
openPopup();
closePopup();
}
detector.check('弹窗打开→关闭 100 次');
Cocos Creator 中的泄漏预防
// 1. 场景切换时释放资源
cc.director.on(cc.Director.EVENT_AFTER_SCENE_LAUNCH, () => {
// 释放上一场景的资源
cc.assetManager.releaseUnusedAssets();
});
// 2. 使用引用计数管理资源
loadSprite('hero', (spriteFrame) => {
this.sprite.spriteFrame = spriteFrame;
spriteFrame.addRef(); // 增加引用计数
});
// 不用时
this.sprite.spriteFrame.decRef(); // 减少引用计数
this.sprite.spriteFrame = null;
// 3. 组件销毁时清理
onDestroy() {
this.node.off('touchstart', this.onTouch, this);
cc.audioEngine.stopAll(); // 停止音频
this.unscheduleAllCallbacks(); // 清除定时器
}
代码审查清单
代码审查时检查这些模式:
□ 事件监听器
□ addEventListener 是否有对应的 removeEventListener?
□ Cocos 的 on 是否有对应的 off?
□ 回调函数是否用了同一个引用(不是新建的匿名函数)?
□ 闭包
□ 闭包是否持有大对象的引用?
□ 是否可以只保存需要的值而不是整个对象?
□ 定时器
□ setInterval 是否有 clearInterval?
□ setTimeout 的回调是否会导致新的泄漏?
□ 缓存
□ 全局缓存是否有上限?
□ 缓存淘汰时是否释放了 GPU 资源?
□ DOM
□ 移除 DOM 前是否清空了 JS 引用?
□ 是否有 Detached DOM?
自问自答
Q:三快照对比法为什么不用两次?两次不就够了? A:两次快照无法区分"正常的缓存增长"和"泄漏"。第1次操作可能产生缓存(合理增长),但第2次操作如果还增长,就说明第1次的增长不是缓存而是泄漏。
Q:Shallow Size 和 Retained Size 哪个更重要? A:定位泄漏时看 Retained Size。一个小引用的 Shallow Size 可能只有几十字节,但它 Retain 的对象可能占几MB。找到 Retained Size 大的对象,追踪其引用链。
Q:为什么手动触发 GC 后内存还是没降下来? A:可能原因:1) 对象还被引用(泄漏),2) 是 GPU 显存而不是 JS 堆(V8 GC 管不着),3) 是 C++ 侧的内存(如 DOM 节点),4) GC 还没完全完成(某些清理是异步的)。
Q:游戏每玩10分钟内存增长1MB,正常吗? A:如果增长是线性的(每10分钟+1MB),且重启后恢复,基本可以确定是泄漏。正常的缓存增长应该是饱和的(增长到一定程度就不再增长)。
实践任务
- 任务1:制造5种泄漏场景(事件监听器、闭包、定时器、全局缓存、Detached DOM),用三快照对比法逐一定位
- 任务2:编写内存泄漏自动化检测脚本,在CI/CD中运行
- 任务3:分析一个真实的 Cocos 游戏,检查是否有上述5种泄漏模式
- 任务4:用 Allocation Timeline 找到游戏中每帧分配最多的对象,评估是否需要对象池
- 任务5:实现 LRU 缓存类,支持淘汰回调(用于释放 GPU 资源)
与其他章节的关联
| 本章内容 | 关联章节 | 关联点 |
|---|---|---|
| V8 GC 机制 | 2_1_v8Learn | GC 原理帮助理解为什么泄漏的对象不会被回收 |
| GPU 显存泄漏 | 第06章 | 纹理泄漏是游戏最常见的显存泄漏 |
| 对象池 | 第09章 | 对象池避免频繁创建销毁,减少 GC 压力 |
| 事件循环 | 第04章 | 闭包泄漏和事件循环的关系 |
上一章:06-JS堆与GPU显存桥梁 下一章:08-Node.js服务端内存