# 内存泄漏排查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服务端内存