mcpField
H
heap-snapshot-analysis

heap-snapshot-analysis

github.com/microsoft开发194.4k167 次安装今天更新
代码仓库

关于 heap-snapshot-analysis

用于分析 V8 `.heapsnapshot` 的技能,借助快照比较和保留路径追踪调查内存泄漏,适合处理 VS Code 的内存问题。

主要功能

  1. 01比较前后堆快照
  2. 02统计对象数量与大小变化
  3. 03追踪对象保留路径
  4. 04支持超大快照流式分析
  5. 05识别弱引用与常见保留模式

适用场景

  1. 01调查对象为何未被回收
  2. 02比较工作流前后的内存变化
  3. 03分析大型堆快照中的可疑对象

安装

复制下面的命令,在终端中运行。

$npx skills add https://github.com/microsoft/vscode --skill heap-snapshot-analysis

安装后在 Claude Code 中输入/heap-snapshot-analysis确认已加载。

包含的文件

除 SKILL.md 外,安装时一起下载的参考资料和脚本。

helpers/
parseSnapshot.ts
findRetainers.ts
streamSnapshot.mjs
compareSnapshots.ts
scratchpad/
README.md
.gitignore

详细介绍

堆快照分析

通过 V8 堆快照(.heapsnapshot 文件)调查内存泄漏。此技能从快照已存在时开始:快照可能由用户提供、由 DevTools 导出,或由其他工作流生成。使用此处的辅助工具比较快照、对对象增量进行分组,并追踪保留路径。

忽略先前的调查

每次调查都要从头开始。 不要阅读、参考或受以下位置中先前调查的影响:

  • /memories/(用户、会话或仓库记忆)
  • .github/skills/heap-snapshot-analysis/scratchpad/(先前按日期划分的子文件夹及其中的 findings.md 文件)
  • 任何其他先前会话留下的笔记

先前的发现可能会使分析偏向已不再相关的可疑对象,也可能导致 Agent 跳过步骤、贸然下结论。以当前快照为准。除非用户明确要求,否则不要参考先前的工作。

适用场景

  • 用户提供了 .heapsnapshot 文件(工作流执行前后)
  • 用户使用其他技能或脚本捕获了堆快照
  • 需要查明是什么保留了已释放的对象(保留路径分析)
  • 比较两个快照中的对象数量和大小
  • 调查特定对象为何在垃圾回收后仍然存在

工作流

如果用户需要 Agent 启动 VS Code、执行某个场景并先捕获快照,请先使用 VS Code 性能工作流技能,然后再回到此处进行底层快照分析。

1. 解析快照

使用 parseSnapshot.ts 中的辅助工具加载快照。文件通常大于 500 MB,无法通过 JSON.parse 将其作为字符串解析——这些辅助工具使用基于 Buffer 的提取方式。在 scratchpad 脚本中,从 ../helpers/*.ts 导入辅助工具。

对于特别大的快照,辅助工具仍可能一次读取过多数据。Node 无法创建大于约 2 GiB 的 Buffer,因此超过此大小的快照在解析前就可能因 ERR_FS_FILE_TOO_LARGE 而失败。此时,不要尝试提高 --max-old-space-size 后重试读取同一个完整文件。请改用流式脚本。

import { parseSnapshot, buildGraph } from '../helpers/parseSnapshot.ts';

const data = parseSnapshot('/path/to/snapshot.heapsnapshot');
const graph = buildGraph(data);
大于 2 GiB 的快照

当快照过大,无法载入单个 Buffer 时,请编写临时脚本,仅扫描和解析问题所需的部分。对于常用的流式处理基础功能,请使用 streamSnapshot.mjs,而不要在临时脚本之间重复复制这些代码。

实用技巧:

  • 先查找顶层分区的偏移量。将文件视为字节流,扫描 "nodes":、"edges":、"strings": 和 "trace_function_infos": 等标记。这样,后续脚本便可直接跳转到大型数组,而不必反复搜索整个文件。
  • 单独解析 snapshot.meta,而不是解析文件开头的小型标头。使用 meta.node_fields、meta.node_types、meta.edge_fields 和 meta.edge_types,避免将元组宽度硬编码。
  • 分块流式处理数值数组。对于 nodes 和 edges,在每个数据块之间保留少量未处理字符串,按逗号拆分,并在完整数值标记到达时进行处理。
  • 除非调查确实需要,否则不要将完整的 strings 表加载到内存中。如果只需要可疑名称,先收集匹配节点/边的字符串索引,再通过第二次流式扫描仅解析这些索引。
  • 如果确实需要许多字符串,只存储简短预览和类别计数。完整源字符串、引用列表字符串和提示词负载可能占用大量内存,导致分析器自身变成内存泄漏源。
  • 将中间输出写入临时目录中的文件。大型堆分析需要反复迭代,而且速度较慢;缓存节点 ID、偏移量和保留者追踪可避免重复进行耗时数分钟的扫描。
  • 对于大型图,优先使用自身大小归因和字段级所有权。完整的保留大小遍历可能会严重高估共享服务、根节点、映射和单例缓存的大小。
  • 对疑似所有者进行量化时,分别统计明显归其所有的字段:包装对象、键数组、数组元素、直接字符串,以及切片/拼接字符串的父字符串。与单独统计直接字符串总量相比,这通常能得出更准确的下限。
  • 明确说明近似范围。字段级小计通常会漏算监听器/观察器/反向引用,但可以避免更严重的问题:将整个运行时都归因于一个对象。

大型快照工作流示例:

import { findArrayStart, findTokenOffsets, parseMeta, streamNumberTuples } from '../../helpers/streamSnapshot.mjs';

const { size, offsets } = findTokenOffsets(snapshotPath);
const meta = parseMeta(snapshotPath);
const nodeFieldCount = meta.node_fields.length;
const nodesStart = findArrayStart(snapshotPath, offsets.get('"nodes"'));

streamNumberTuples(snapshotPath, nodesStart, offsets.get('"edges"'), nodeFieldCount, (node, nodeIndex) => {
    // node is reused for speed; copy it before storing.
});
cd .github/skills/heap-snapshot-analysis
node --max-old-space-size=24576 scratchpad/YYYY-MM-DD-topic/findOffsets.mjs /path/to/Heap.heapsnapshot
node --max-old-space-size=24576 scratchpad/YYYY-MM-DD-topic/streamAnalyze.mjs /path/to/Heap.heapsnapshot > scratchpad/YYYY-MM-DD-topic/streamAnalyze.out
node --max-old-space-size=24576 scratchpad/YYYY-MM-DD-topic/traceNodes.mjs /path/to/Heap.heapsnapshot 12345 67890 > scratchpad/YYYY-MM-DD-topic/traceNodes.out
2. 比较前后结果

使用 compareSnapshots.ts 对比两个快照:

import { compareSnapshots } from '../helpers/compareSnapshots.ts';

const result = compareSnapshots('/path/to/before.heapsnapshot', '/path/to/after.heapsnapshot');
// result.topBySize, result.topByCount, result.newObjectGroups, result.summary
3. 查找保留路径

使用 findRetainers.ts 追踪对象保持存活的原因:

import { findRetainerPaths } from '../helpers/findRetainers.ts';

// Find what keeps ChatModel instances alive (skipping weak edges)
findRetainerPaths(graph, 'ChatModel', { maxPaths: 5, maxDepth: 25, maxAttempts: 200 });
4. 编写调查脚本

在 scratchpad 目录中编写针对具体调查的脚本。此目录已被 gitignore 忽略,可放心用于一次性分析。

将临时目录中的工作整理到按日期命名的子文件夹中,文件夹名格式为 YYYY-MM-DD-short-description/(例如 2026-04-09-chat-model-retainers/)。每个子文件夹应包含:

  • 分析脚本(.mjs、.mts 等)
  • 一个 findings.md 文件,记录完整的调查过程:考虑过的所有思路、哪些思路促成了更改、哪些被否决(以及原因)、更改前后的测量结果,以及结果摘要。这样用户便可审查 Agent 的推理、决定保留哪些更改,并跟进暂缓处理的想法。

脚本可以导入这些辅助工具:

cd .github/skills/heap-snapshot-analysis
node --max-old-space-size=16384 scratchpad/2026-04-09-chat-model-retainers/analyze.mjs

核心概念

V8 堆快照格式

.heapsnapshot 文件是 JSON,包含以下关键部分:

  • snapshot.meta:节点和边的字段定义
  • nodes:扁平数组,每 N 个值表示一个节点(N = meta.node_fields.length,通常为 6:type, name, id, self_size, edge_count, detachedness)
  • edges:扁平数组,每 M 个值表示一条边(M = meta.edge_fields.length,通常为 3:type, name_or_index, to_node)
  • strings:字符串表,由节点/边中的 name 字段索引
重要的边类型
类型含义是否阻止 GC?
property命名的 JS 属性是
element数组索引是
context闭包变量是
internalV8 内部引用是
hiddenV8 隐藏引用是
weakWeakRef/WeakMap 键否
shortcut便捷链接视情况而定

追踪保留路径时,始终跳过 weak 边。 WeakMap 条目会显示为从键指向底层数组的边,但它们不会阻止对象被回收——这些只是干扰项。

常见的 VS Code 保留模式
  1. RowCache 模板:ListView 的 RowCache 会存储模板行。模板中的 currentElement 指向旧的视图模型项。如果切换会话时没有清除,就会保留整个模型链。

  2. 资源池:pool.clear() 只会释放空闲项。如果 _onDidUpdateViewModel.fire() 在 pool.clear() 之后运行,已释放的项会重新进入空池,永远不会被释放。应先触发事件,再清空资源池。

  3. autorunIterableDelta 的 lastValues:闭包会捕获一个保存上次迭代值的 Map。这些值会一直保留到 autorun 再次运行。异步释放的延迟会让模型在可观察存储中停留的时间超出预期。

  4. HoverService._delayedHovers:全局单例 Map 会通过 show 闭包 → resolveHoverOptions 闭包 → this 保留已释放的对象。如果 hover 清理 disposable 没有触发,整个对象树都会被保留。

  5. ObjectMutationLog._previous:增量序列化器会保留上次序列化状态的完整快照。每个已加载的 ChatModel 都会保留 2 倍的数据:当前数据 + _previous。

  6. _previousModelRef 模式:MutableDisposable 的 setter 会释放旧值。如果读取 .value 并将其存储在别处,然后设置 .value = undefined,存储的引用也会被释放。使用 clearAndLeak() 提取值而不释放。

防御性置空

在 dispose() 中将占用大量内存的字段置空,即使其他对象保留了已释放对象,也能打断保留链:

override dispose() {
    super.dispose();
    this._requests.length = 0;      // conversation data
    this.dataSerializer = undefined;  // serialization snapshot
    this._editingSession = undefined; // editing session + TextModels
    this._session = undefined!;       // back-reference cycles
}

注意:不要将视图模型项的字段置空(ChatResponseViewModel._model)。在父视图模型释放之后、setChildren 替换这些项之前,树的 diffIdentityProvider 仍会访问这些字段。

需要留意的误报保留对象
  • DevTools 调试器全局句柄:如果是在打开 DevTools 后捕获快照,大型源字符串、已编译脚本、预览数据、已检查对象或调试器内部记录可能会通过 DevTools debugger(internal) → synthetic::(Global handles) → GC 根这样的路径被保留。在有证据证明并非如此之前,应将其视为调试器导致的保留。这些对象在打开 DevTools 之前可能并不存在于应用中,不应与应用自身导致的内存泄漏混为一谈。
  • DevToolsLogger._aliveInstances(Map):由 VSCODE_DEV_DEBUG_OBSERVABLES 环境变量启用。它会保留所有被观察的 observable。在调查以 observable 为根的路径之前,先检查它是否已启用。
  • GCBasedDisposableTracker(FinalizationRegistry):如果使用 register(target, held, target)(target === 注销令牌),就会创建强自引用,从而阻止 GC。目前在生产环境中已注释掉。
  • WeakMap 底层数组:会出现在保留路径中,但不会阻止对象被回收。

运行分析

所有辅助脚本都使用 ESM,需要使用额外内存运行 Node:

node --max-old-space-size=16384 scratchpad/analyze.mjs

根据快照大小,典型的分析每个快照需要 30–120 秒。

评论

支持 Markdown