关于 heap-snapshot-analysis
用于分析 V8 `.heapsnapshot` 的技能,借助快照比较和保留路径追踪调查内存泄漏,适合处理 VS Code 的内存问题。
主要功能
- 01比较前后堆快照
- 02统计对象数量与大小变化
- 03追踪对象保留路径
- 04支持超大快照流式分析
- 05识别弱引用与常见保留模式
适用场景
- 01调查对象为何未被回收
- 02比较工作流前后的内存变化
- 03分析大型堆快照中的可疑对象
安装
复制下面的命令,在终端中运行。
npx skills add https:/ / github.com/ microsoft/ vscode --skill heap-snapshot-analysis安装后在 Claude Code 中输入/heap-snapshot-analysis确认已加载。
包含的文件
除 SKILL.md 外,安装时一起下载的参考资料和脚本。
helpers/parseSnapshot.tsfindRetainers.tsstreamSnapshot.mjscompareSnapshots.tsscratchpad/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 | 闭包变量 | 是 |
internal | V8 内部引用 | 是 |
hidden | V8 隐藏引用 | 是 |
weak | WeakRef/WeakMap 键 | 否 |
shortcut | 便捷链接 | 视情况而定 |
追踪保留路径时,始终跳过 weak 边。 WeakMap 条目会显示为从键指向底层数组的边,但它们不会阻止对象被回收——这些只是干扰项。
常见的 VS Code 保留模式
-
RowCache 模板:ListView 的
RowCache会存储模板行。模板中的currentElement指向旧的视图模型项。如果切换会话时没有清除,就会保留整个模型链。 -
资源池:
pool.clear()只会释放空闲项。如果_onDidUpdateViewModel.fire()在pool.clear()之后运行,已释放的项会重新进入空池,永远不会被释放。应先触发事件,再清空资源池。 -
autorunIterableDelta的 lastValues:闭包会捕获一个保存上次迭代值的Map。这些值会一直保留到 autorun 再次运行。异步释放的延迟会让模型在可观察存储中停留的时间超出预期。 -
HoverService._delayedHovers:全局单例 Map 会通过show闭包 →resolveHoverOptions闭包 →this保留已释放的对象。如果 hover 清理 disposable 没有触发,整个对象树都会被保留。 -
ObjectMutationLog._previous:增量序列化器会保留上次序列化状态的完整快照。每个已加载的 ChatModel 都会保留 2 倍的数据:当前数据 +_previous。 -
_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 秒。