Skip to content

内存快照分析:Heap Snapshot 的使用

当你的应用出现内存问题时,如何找到根本原因?Chrome DevTools的Heap Snapshot是最强大的内存分析工具,它能够拍摄内存的"快照",让你看到每个对象在内存中的分布、大小和引用关系。

本章将深入探讨如何使用Heap Snapshot分析内存问题,掌握这个技能,你将能够快速定位内存泄漏和优化内存使用。

什么是Heap Snapshot

Heap Snapshot是V8堆内存的完整快照:

javascript
// 模拟Heap Snapshot的数据结构
class HeapSnapshotSimulator {
  constructor() {
    this.nodes = [];
    this.edges = [];
    this.strings = [];
    this.nextNodeId = 1;
    this.nextStringId = 0;
  }
  
  // 添加节点(对象)
  addNode(type, name, size) {
    const id = this.nextNodeId++;
    const stringId = this.addString(name);
    
    const node = {
      id,
      type,  // 'object', 'array', 'string', etc.
      nameStringId: stringId,
      size,  // 浅大小(字节)
      edgesStartIndex: this.edges.length,
      edgesCount: 0
    };
    
    this.nodes.push(node);
    return node;
  }
  
  // 添加边(引用关系)
  addEdge(fromNode, toNode, edgeName, edgeType = 'property') {
    const nameStringId = this.addString(edgeName);
    
    const edge = {
      type: edgeType,  // 'property', 'element', 'internal'
      nameStringId,
      toNodeId: toNode.id
    };
    
    this.edges.push(edge);
    fromNode.edgesCount++;
  }
  
  // 添加字符串
  addString(str) {
    const existingIndex = this.strings.indexOf(str);
    if (existingIndex !== -1) {
      return existingIndex;
    }
    
    const id = this.nextStringId++;
    this.strings.push(str);
    return id;
  }
  
  // 演示快照结构
  demonstrate() {
    console.log('=== Heap Snapshot 结构 ===\n');
    
    // 创建一些节点
    const obj = this.addNode('object', 'MyObject', 48);
    const arr = this.addNode('array', 'Array', 32);
    const str = this.addNode('string', '"hello"', 16);
    
    // 创建引用关系
    this.addEdge(obj, arr, 'items', 'property');
    this.addEdge(obj, str, 'name', 'property');
    this.addEdge(arr, str, '0', 'element');
    
    console.log('节点 (Nodes):');
    for (const node of this.nodes) {
      const name = this.strings[node.nameStringId];
      console.log(`  [${node.id}] ${node.type} "${name}" - ${node.size}B`);
    }
    
    console.log('\n边 (Edges - 引用关系):');
    for (const edge of this.edges) {
      const name = this.strings[edge.nameStringId];
      console.log(`  ${edge.type}: "${name}" -> Node[${edge.toNodeId}]`);
    }
    
    console.log('\n字符串表 (Strings):');
    this.strings.forEach((str, index) => {
      console.log(`  [${index}] "${str}"`);
    });
  }
}

const snapshot = new HeapSnapshotSimulator();
snapshot.demonstrate();

拍摄Heap Snapshot

在Chrome DevTools中拍摄快照:

javascript
// Heap Snapshot拍摄指南
class HeapSnapshotGuide {
  static demonstrateBasicWorkflow() {
    console.log('\n=== Heap Snapshot 基本工作流程 ===\n');
    
    console.log('步骤1:打开Chrome DevTools');
    console.log('  - 快捷键: F12 或 Ctrl+Shift+I (Windows/Linux)');
    console.log('  - 快捷键: Cmd+Option+I (Mac)\n');
    
    console.log('步骤2:切换到Memory标签');
    console.log('  - 点击顶部的"Memory"标签\n');
    
    console.log('步骤3:选择Heap Snapshot');
    console.log('  - 单选"Heap snapshot"');
    console.log('  - 点击"Take snapshot"按钮\n');
    
    console.log('步骤4:等待快照生成');
    console.log('  - 大型应用可能需要几秒到几分钟');
    console.log('  - 快照会出现在左侧列表中\n');
    
    console.log('步骤5:分析快照');
    console.log('  - 点击快照查看详细信息');
    console.log('  - 使用不同的视图分析内存\n');
  }
  
  static demonstrateComparisonWorkflow() {
    console.log('=== 对比快照工作流程(查找泄漏)===\n');
    
    console.log('步骤1:拍摄基线快照');
    console.log('  - 在执行任何操作前拍摄快照');
    console.log('  - 命名为"Baseline"\n');
    
    console.log('步骤2:执行操作');
    console.log('  - 执行可能泄漏的操作');
    console.log('  - 例如:打开和关闭模态框10次\n');
    
    console.log('步骤3:拍摄第二个快照');
    console.log('  - 操作完成后拍摄快照');
    console.log('  - 命名为"After Operation"\n');
    
    console.log('步骤4:对比快照');
    console.log('  - 选择第二个快照');
    console.log('  - 在顶部下拉框选择"Comparison"');
    console.log('  - 在下一个下拉框选择基线快照');
    console.log('  - 查看增长的对象\n');
    
    console.log('步骤5:分析泄漏');
    console.log('  - 按"# Delta"列排序');
    console.log('  - 展开增长最多的构造函数');
    console.log('  - 查看对象的Retainers(保留路径)');
    console.log('  - 找到根引用,确定泄漏原因\n');
  }
  
  static runAll() {
    this.demonstrateBasicWorkflow();
    this.demonstrateComparisonWorkflow();
  }
}

HeapSnapshotGuide.runAll();

快照视图详解

Heap Snapshot提供四种视图:

javascript
// 快照视图说明
class SnapshotViews {
  static demonstrateSummaryView() {
    console.log('=== Summary 视图(摘要)===\n');
    
    console.log('用途:查看按构造函数分组的对象\n');
    
    console.log('主要列:');
    console.log('  • Constructor: 构造函数名称');
    console.log('  • Distance: 到GC根的最短路径长度');
    console.log('  • Objects Count: 该类型对象数量');
    console.log('  • Shallow Size: 对象自身大小(不含引用)');
    console.log('  • Retained Size: 对象及其保留对象的总大小\n');
    
    console.log('示例分析:');
    console.log('  Constructor    Distance  #Objects  Shallow  Retained');
    console.log('  (array)             5      1,234    98KB     2.1MB');
    console.log('  (string)            7      5,678    45KB     45KB');
    console.log('  MyClass             3         10    2KB      500KB');
    console.log('');
    console.log('  分析:MyClass对象虽然只有10个,但保留了500KB内存');
    console.log('       可能每个对象引用了大量其他对象\n');
  }
  
  static demonstrateComparisonView() {
    console.log('=== Comparison 视图(对比)===\n');
    
    console.log('用途:对比两个快照,找出新增对象\n');
    
    console.log('主要列:');
    console.log('  • Constructor: 构造函数名称');
    console.log('  • # New: 新增对象数量');
    console.log('  • # Deleted: 删除对象数量');
    console.log('  • # Delta: 净变化(New - Deleted)');
    console.log('  • Alloc. Size: 新分配的内存大小');
    console.log('  • Freed Size: 释放的内存大小');
    console.log('  • Size Delta: 净内存变化\n');
    
    console.log('示例分析:');
    console.log('  Constructor    #New  #Del  #Delta  Size Delta');
    console.log('  (array)         100     5     +95      +1.2MB');
    console.log('  (string)        500   490     +10      +8KB');
    console.log('  EventHandler     50     0     +50      +800KB  ⚠️');
    console.log('');
    console.log('  分析:EventHandler有50个对象未释放');
    console.log('       占用800KB,可能是事件监听器泄漏\n');
  }
  
  static demonstrateContainmentView() {
    console.log('=== Containment 视图(包含关系)===\n');
    
    console.log('用途:查看从GC根到对象的完整引用树\n');
    
    console.log('GC根类型:');
    console.log('  • Window objects: 全局window对象');
    console.log('  • GC roots: V8垃圾回收器的根');
    console.log('  • Native objects: 浏览器原生对象\n');
    
    console.log('示例树结构:');
    console.log('  Window');
    console.log('  └─ myApp');
    console.log('     └─ components');
    console.log('        └─ [0]');
    console.log('           └─ _listeners');
    console.log('              └─ [0]');
    console.log('                 └─ handler (closure)');
    console.log('                    └─ largeData (array)  ⚠️\n');
    console.log('');
    console.log('  分析:从window可达,说明对象未被回收');
    console.log('       largeData被闭包意外持有\n');
  }
  
  static demonstrateDominatorsView() {
    console.log('=== Dominators 视图(支配树)===\n');
    
    console.log('用途:查看支配关系,快速找到占用大量内存的对象\n');
    
    console.log('支配概念:');
    console.log('  • 如果删除节点A会导致节点B不可达');
    console.log('  • 则A支配B');
    console.log('  • B的保留大小会计入A的保留大小\n');
    
    console.log('示例分析:');
    console.log('  Object              Shallow  Retained');
    console.log('  Window                  24B      15MB  (全局对象)');
    console.log('  └─ myApp               48B       8MB');
    console.log('     └─ dataStore        32B       5MB  ⚠️');
    console.log('        └─ cache        128B       4MB  ⚠️');
    console.log('           └─ items     2KB       4MB');
    console.log('');
    console.log('  分析:cache对象占用4MB,是优化重点');
    console.log('       可以考虑限制cache大小\n');
  }
  
  static runAll() {
    this.demonstrateSummaryView();
    this.demonstrateComparisonView();
    this.demonstrateContainmentView();
    this.demonstrateDominatorsView();
  }
}

SnapshotViews.runAll();

Shallow Size vs Retained Size

理解这两个概念是分析内存的关键:

javascript
// Shallow Size 和 Retained Size 详解
class SizeMetrics {
  static demonstrate() {
    console.log('=== Shallow Size vs Retained Size ===\n');
    
    // 模拟内存结构
    const memoryStructure = {
      name: 'Container',
      shallowSize: 48,  // 对象自身大小
      children: [
        {
          name: 'largeArray',
          shallowSize: 32,
          elements: {
            name: 'array elements',
            shallowSize: 1048576  // 1MB
          }
        },
        {
          name: 'smallObject',
          shallowSize: 24
        },
        {
          name: 'sharedString',  // 被其他对象引用
          shallowSize: 64,
          sharedBy: 'other objects'
        }
      ]
    };
    
    console.log('内存结构:');
    console.log('  Container (48B)');
    console.log('  ├─ largeArray (32B)');
    console.log('  │  └─ elements (1MB)');
    console.log('  ├─ smallObject (24B)');
    console.log('  └─ sharedString (64B) [被其他对象共享]\n');
    
    // 计算Shallow Size
    const shallowSize = 48;
    console.log(`Shallow Size(浅大小): ${shallowSize}B`);
    console.log('  定义:对象自身占用的内存');
    console.log('  不包含:引用的其他对象\n');
    
    // 计算Retained Size
    const retainedSize = 48 + 32 + 1048576 + 24;  // 不包含共享的string
    console.log(`Retained Size(保留大小): ${(retainedSize / 1024 / 1024).toFixed(2)}MB`);
    console.log('  定义:删除该对象会释放的总内存');
    console.log('  包含:只被该对象引用的所有对象');
    console.log('  不包含:被其他对象共享的对象(sharedString)\n');
    
    console.log('关键区别:');
    console.log('  • Shallow Size: 对象"房子"的大小');
    console.log('  • Retained Size: 对象"王国"的大小');
    console.log('  • Retained Size ≥ Shallow Size');
    console.log('  • 优化时关注Retained Size大的对象\n');
  }
  
  static demonstrateSharedReferences() {
    console.log('=== 共享引用的影响 ===\n');
    
    console.log('场景1:独占引用');
    console.log('  A → B → C (1MB)');
    console.log('  A的Retained Size: 1MB+');
    console.log('  删除A会释放B和C\n');
    
    console.log('场景2:共享引用');
    console.log('  A → B → C (1MB)');
    console.log('  D → C (同一个C)');
    console.log('  A的Retained Size: 不包含C');
    console.log('  删除A不会释放C(D仍引用)\n');
    
    console.log('实际意义:');
    console.log('  • 共享对象不计入任何单个对象的Retained Size');
    console.log('  • 分析时要追踪引用关系,找到真正的"所有者"\n');
  }
  
  static runAll() {
    this.demonstrate();
    this.demonstrateSharedReferences();
  }
}

SizeMetrics.runAll();

实战案例:查找内存泄漏

完整的内存泄漏分析流程:

javascript
// 内存泄漏分析实战
class LeakAnalysisCase {
  static demonstrateCase1() {
    console.log('=== 案例1:事件监听器泄漏 ===\n');
    
    console.log('症状:');
    console.log('  • 页面使用一段时间后变慢');
    console.log('  • 内存持续增长');
    console.log('  • 特定操作后内存不下降\n');
    
    console.log('分析步骤:\n');
    
    console.log('1. 拍摄基线快照');
    console.log('   操作:打开页面后立即拍摄\n');
    
    console.log('2. 重复操作');
    console.log('   操作:打开关闭模态框10次\n');
    
    console.log('3. 拍摄第二个快照');
    console.log('   操作:操作完成后拍摄\n');
    
    console.log('4. 对比快照');
    console.log('   结果:');
    console.log('     Constructor       #Delta   Size Delta');
    console.log('     HTMLDivElement      +10       +800KB');
    console.log('     (closure)          +100      +1.2MB  ⚠️');
    console.log('');
    console.log('   发现:闭包数量增长了100个\n');
    
    console.log('5. 查看Retainers');
    console.log('   路径:');
    console.log('     Window');
    console.log('     └─ listeners array');
    console.log('        └─ [99] (closure)');
    console.log('           └─ context');
    console.log('              └─ this (Modal instance)');
    console.log('                 └─ data (large array)\n');
    
    console.log('6. 定位代码');
    console.log('   问题:Modal关闭时未移除事件监听器');
    console.log('   代码:addEventListener() 未配对 removeEventListener()\n');
    
    console.log('7. 修复');
    console.log('   方案:在Modal.close()中移除监听器');
    console.log(`
    close() {
      this.element.removeEventListener('click', this.handler);
      this.handler = null;
      this.data = null;
    }
    `);
  }
  
  static demonstrateCase2() {
    console.log('\n=== 案例2:分离DOM泄漏 ===\n');
    
    console.log('症状:');
    console.log('  • 删除DOM后内存未下降');
    console.log('  • Detached DOM节点数量增长\n');
    
    console.log('分析步骤:\n');
    
    console.log('1. 在Summary视图搜索"Detached"');
    console.log('   发现:Detached HTMLDivElement: 50个,2MB\n');
    
    console.log('2. 展开查看对象');
    console.log('   选择一个Detached节点');
    console.log('   查看Retainers\n');
    
    console.log('3. 追踪引用路径');
    console.log('   路径:');
    console.log('     Window');
    console.log('     └─ myApp');
    console.log('        └─ cache');
    console.log('           └─ nodes array');
    console.log('              └─ [0] (HTMLDivElement)  ⚠️\n');
    
    console.log('4. 定位问题');
    console.log('   问题:cache数组保存了DOM引用');
    console.log('   即使DOM从文档移除,JavaScript仍持有引用\n');
    
    console.log('5. 修复');
    console.log('   方案:移除DOM时清理cache');
    console.log(`
    removeNode(node) {
      node.remove();
      // 清理缓存中的引用
      const index = this.cache.nodes.indexOf(node);
      if (index > -1) {
        this.cache.nodes.splice(index, 1);
      }
    }
    `);
  }
  
  static demonstrateCase3() {
    console.log('\n=== 案例3:闭包意外引用 ===\n');
    
    console.log('症状:');
    console.log('  • 简单函数占用大量内存');
    console.log('  • Retained Size远大于预期\n');
    
    console.log('分析步骤:\n');
    
    console.log('1. 在Summary视图查看(closure)');
    console.log('   发现:某个闭包Retained Size: 10MB\n');
    
    console.log('2. 展开闭包对象');
    console.log('   内容:');
    console.log('     context (internal)');
    console.log('     └─ largeData (array): 10MB  ⚠️\n');
    
    console.log('3. 查看源代码');
    console.log('   代码:');
    console.log(`
    function processData() {
      const largeData = loadLargeDataset();  // 10MB
      
      // 返回的函数实际不需要largeData
      return function getLength() {
        return largeData.length;  // 只需要length!
      };
    }
    `);
    console.log('');
    console.log('   问题:闭包持有整个largeData数组\n');
    
    console.log('4. 修复');
    console.log('   方案:只保留需要的数据');
    console.log(`
    function processData() {
      const largeData = loadLargeDataset();
      const length = largeData.length;  // 提取需要的数据
      
      // 闭包只捕获length
      return function getLength() {
        return length;  // 仅4-8字节
      };
    }
    `);
  }
  
  static runAll() {
    this.demonstrateCase1();
    this.demonstrateCase2();
    this.demonstrateCase3();
  }
}

LeakAnalysisCase.runAll();

过滤和搜索技巧

高效使用Heap Snapshot的技巧:

javascript
// 快照分析技巧
class SnapshotTips {
  static demonstrateFiltering() {
    console.log('=== 过滤和搜索技巧 ===\n');
    
    console.log('1. 按类名过滤');
    console.log('   输入框:输入构造函数名');
    console.log('   示例:"Array" - 查看所有数组');
    console.log('   示例:"MyClass" - 查看自定义类实例\n');
    
    console.log('2. 按ID搜索');
    console.log('   格式:@<id>');
    console.log('   示例:@12345 - 查找特定对象');
    console.log('   用途:追踪特定对象的引用\n');
    
    console.log('3. 按距离过滤');
    console.log('   筛选器:Distance');
    console.log('   示例:Distance > 10 - 查找深度嵌套的对象');
    console.log('   用途:找到难以访问的对象\n');
    
    console.log('4. 按大小排序');
    console.log('   点击列头:Retained Size');
    console.log('   用途:快速找到占用内存最多的对象\n');
    
    console.log('5. 搜索字符串内容');
    console.log('   类型:(string)');
    console.log('   筛选:包含特定文本');
    console.log('   用途:找到包含特定数据的字符串\n');
  }
  
  static demonstrateCommonPatterns() {
    console.log('=== 常见内存问题模式 ===\n');
    
    console.log('模式1:大量小对象');
    console.log('  特征:Objects Count很高,Retained Size中等');
    console.log('  原因:频繁创建小对象');
    console.log('  解决:对象池、减少创建频率\n');
    
    console.log('模式2:少量大对象');
    console.log('  特征:Objects Count低,Retained Size很高');
    console.log('  原因:缓存、大数组未清理');
    console.log('  解决:限制缓存大小、分页加载\n');
    
    console.log('模式3:Detached DOM增长');
    console.log('  特征:Detached节点持续增加');
    console.log('  原因:移除DOM时未清理JavaScript引用');
    console.log('  解决:移除DOM时清理所有引用\n');
    
    console.log('模式4:闭包占用大');
    console.log('  特征:(closure)的Retained Size很高');
    console.log('  原因:闭包意外捕获大对象');
    console.log('  解决:只保留必要数据,避免捕获整个this\n');
  }
  
  static runAll() {
    this.demonstrateFiltering();
    this.demonstrateCommonPatterns();
  }
}

SnapshotTips.runAll();

最佳实践

javascript
// Heap Snapshot 分析最佳实践
class BestPractices {
  static demonstrate() {
    console.log('=== Heap Snapshot 最佳实践 ===\n');
    
    console.log('1. 分析前强制GC');
    console.log('   • 点击DevTools垃圾桶图标');
    console.log('   • 确保清理临时对象');
    console.log('   • 获得更准确的快照\n');
    
    console.log('2. 使用对比视图查找泄漏');
    console.log('   • 不要只看单个快照');
    console.log('   • 对比操作前后的快照');
    console.log('   • 关注#Delta列\n');
    
    console.log('3. 从Retained Size大的对象开始');
    console.log('   • 优先优化占用内存多的');
    console.log('   • 小对象数量多不一定是问题');
    console.log('   • 关注支配树中的大节点\n');
    
    console.log('4. 追踪完整引用路径');
    console.log('   • 查看Retainers找到根引用');
    console.log('   • 理解为什么对象未被回收');
    console.log('   • 在源代码中定位问题\n');
    
    console.log('5. 重现问题');
    console.log('   • 多次执行操作');
    console.log('   • 观察内存是否持续增长');
    console.log('   • 确认修复后内存稳定\n');
    
    console.log('6. 自动化测试');
    console.log('   • 编写内存测试');
    console.log('   • CI/CD中检测内存回归');
    console.log('   • 防止泄漏再次发生\n');
  }
}

BestPractices.demonstrate();

本章小结

本章深入探讨了Chrome DevTools Heap Snapshot的使用方法。我们学习了以下核心内容:

  1. 快照结构:理解节点(对象)、边(引用)和字符串表的组织方式。

  2. 拍摄快照:掌握基本工作流程和对比快照的方法。

  3. 四种视图:Summary(摘要)、Comparison(对比)、Containment(包含)、Dominators(支配树)。

  4. 大小指标:区分Shallow Size(对象自身)和Retained Size(对象及其保留对象)。

  5. 实战案例:分析事件监听器泄漏、分离DOM泄漏、闭包意外引用等常见问题。

  6. 过滤技巧:使用类名、ID、距离等条件快速定位问题对象。

  7. 最佳实践:分析前GC、使用对比视图、追踪引用路径、自动化测试。

掌握Heap Snapshot,你将能够快速诊断和修复内存问题。在下一章中,我们将探讨FinalizationRegistry API和弱引用的使用。

内存快照分析:Heap Snapshot 的使用 has loaded