Valdi 内存泄漏调试指南:使用 Hermes 堆转储(Heap Snapshot)定位泄漏对象
Valdi 内存泄漏调试指南:使用 Hermes 堆转储(Heap Snapshot)定位泄漏对象
Valdi 运行时支持将 VM 内的 JavaScript 堆以 V8 Heap Snapshot 格式整体导出,这份快照记录了 VM 中所有存活对象与函数及其相互引用关系,可直接交给 Chrome DevTools 或 Meta Memlab 等工具分析,从而定位"对象为何迟迟不被回收"这类内存泄漏问题。本篇指南将完整讲解堆转储的启用前提、通过 VSCode 调试器触发的操作步骤、Chrome DevTools 中的分析技巧,以及 Valdi 特有的原生侧引用(System / ExportedReferences)泄漏排查方法,并结合仓库源码说明堆转储的底层实现原理。
前置条件:启用 Hermes 引擎
堆转储(Heap Dump)目前仅支持 Hermes 引擎,因此在开始之前需要确认应用运行在 Hermes 上,具体启用方式参见 Hermes 调试器指南:
- 通过
valdi bootstrap生成的项目默认会尽可能使用 Hermes; - 其他项目需要在
.bazelrc中加入构建选项:build --valdi_js_engine=hermes - Hermes 集成在 iOS 与 Android 的 debug 构建中编译启用,在 macOS 的所有构建类型中启用;
- 调试(包括堆转储请求)依赖 Valdi hot reloader 正在运行并与设备连接。
从源码结构看,堆转储能力确实以条件编译形式内置于运行时:在 JavaScriptRuntime.cpp 中,dumpHeap 这一内部 API 只有在 Valdi::shouldEnableJsHeapDump() 成立时才被绑定到 runtime 对象上,dumpHeap() 本体(JavaScriptRuntime.cpp)同样受该开关保护,未启用时返回空数据。
请求堆转储:通过 VSCode 调试器触发
堆转储的获取方式是:将 VSCode 调试器连接到正在运行的 Valdi 应用,然后请求一次 Heap Snapshot(堆快照)。
- 确保 Hermes 已启用、应用以 debug 模式运行、hot reloader 已连接;
- 在 VSCode 中选择
Valdi attach调试配置并启动调试会话(绿色三角或按F5)。该配置由valdi projectsync自动生成到.vscode/launch.json,当新增模块后需要重新执行valdi projectsync以刷新源码映射; - 调试器底部状态栏变为橙色即表示附加成功;
- 在调试会话的 Profiling 面板中请求 Heap Snapshot,即可得到
heapsnapshot文件。
值得注意的是,堆转储的导出在运行时是跨 JS 上下文统一执行的:JavaScriptRuntime::dumpHeap() 通过 dispatchSynchronouslyOnJsThread 调度到 JS 线程,并调用 lockAllJSContexts 锁住当前所有 JS 上下文后,再交给 _javaScriptBridge.dumpHeap(...) 生成完整的 V8 格式数据(见 JavaScriptRuntime.cpp)。这意味着快照并不是单个页面的局部视图,而是整个 VM 存活对象的全局快照。
堆转储格式与底层实现
Valdi 导出的堆转储使用 V8 格式,因此天然兼容 Chrome DevTools 与 Meta Memlab。堆转储中记录了 VM 内所有当前存活的对象与函数,以及它们之间的关系,是一个完整的对象关系图。
从源码结构看,这份 V8 格式快照由 JavaScriptHeapDumpBuilder.hpp 中的 JavaScriptHeapDumpBuilder 生成,它按照 V8 快照规范维护 nodes、edges、strings 表,并定义了完整的节点类型与边类型枚举:
- 节点类型(Node Type):
HIDDEN(VM 内部元素,在 DevTools 中归入(system)类别,虽非用户对象但常出现在 retainer 路径中)、ARRAY(内部列表,含(object properties)/(object elements),对应对象的字符串键属性与数字键属性)、STRING、OBJECT(用户自定义对象,以及因被嵌套函数使用而不得不在堆上分配的Context)、CODE、CLOSURE、REGEXP、NUMBER、NATIVE、SYNTHETIC(不对应真实内存分配,用于区分各类 GC 根)、SYMBOL、BIGINT、OBJECT_SHAPE; - 边类型(Edge Type):
CONTEXT、ELEMENT(数字键属性)、PROPERTY(命名属性)、INTERNAL、HIDDEN(不对应 JS 可见名称但仍重要的引用,例如函数闭包的 context)、SHORTCUT(对某条路径的更易读表示,如Function.prototype.bind生成的绑定参数捷径边)、WEAK(弱引用不保持目标存活,在 Retainers 视图中会被省略)。
在引擎侧,QuickJSJavaScriptContext::dumpHeap(QuickJSJavaScriptContext.cpp)展示了具体的堆遍历方式:通过 JS_SetRuntimeOpaque 将 builder 挂到运行时上,随后调用 JS_VisitAllGCObjects 让引擎回调每个 GC 对象的起始、属性边与内部边,逐一把对象和引用写入快照。可以推断,Valdi 通过统一的 JavaScriptHeapDumpBuilder 屏蔽了各 JS 引擎在快照导出上的差异,从而保证最终产出的是标准、可被外部工具直接消费的 V8 格式文件。
在 Chrome DevTools 中分析堆转储
拿到 heapsnapshot 文件后,使用 Chrome DevTools 打开分析:
- 打开 Chrome,进入菜单栏 View(视图) -> Developer(开发者) -> Developer Tools(开发者工具);
- 切换到 Memory(内存) 面板,点击 Load(加载) 并选择
heapsnapshot文件:
理解浅层大小与保留大小
Memory 视图会列出快照中的所有对象及其构造函数名。选中对象后,右侧面板展示两个关键指标:
- shallow size(浅层大小):对象自身直接占用的内存字节数;
- retained size(保留大小):对象通过引用链间接"拖住"的内存总量,即若该对象被回收,能够一并释放的内存量。排查内存泄漏时,
retained size是判断"某个根对象到底占了多少内存"的核心指标。
查看对象持有的引用
展开某个对象,工具会列出该对象持有的全部引用(references)。引用呈现规则值得注意:
- 灰色引用(例如
<shape>):这是 VM 持有的隐式隐藏引用(对应上文的HIDDEN边),并非用户代码显式创建的属性; - 其他类型的引用:对应对象的属性(property)或数组元素(element);
- 当前快照只导出引用类型,数字(number)与布尔值(boolean)这类原始值不会出现在引用列表中。
用 Retainers 逆向定位存活原因
窗口底部是 Retainers(保留者) 区域,列出所有引用当前选中对象的对象。它是内存泄漏排查中最有价值的视角:它回答的不是"这个对象指向谁",而是"谁在让这个对象保持存活"。沿着 Retainers 逐级向上追溯,通常可以找到泄漏的源头——某个本应被释放的全局缓存、事件监听器或长期存活的容器对象。
原生侧引用:排查 System / ExportedReferences
在 Valdi 这类跨平台框架中,还有一个特有的泄漏来源:TypeScript 对象被导出(export)到原生代码。
当 TypeScript 中的对象或函数被导出到原生(C++、Objective-C 或 Kotlin)代码时,Valdi 运行时会为它创建一个引用,并关联一个对应的原生对象。只要这个原生对象仍存活,该 JS 对象就会被一直保留;反之,当关联的原生对象被释放(deallocated)或完成垃圾回收后,导出的引用才会随之拆除(torn down)。
这类原生侧引用在堆转储中统一归入名为 System / ExportedReferences 的数组。从源码实现可以看到这一点:QuickJSJavaScriptContext::dumpHeap 会取出所有被 stash(暂存)的 JS 值,并将它们作为一个独立的 HIDDEN 类型节点写入快照,节点名即 "System / ExportedReferences",随后按元素索引为每个导出的值建立边(见 QuickJSJavaScriptContext.cpp)。
在实际排查中的判断方法:
- 在 DevTools 的 Retainers 视图中,如果某个疑似泄漏的 JS 对象被
System / ExportedReferences所持有,说明它被原生侧"借用"了; - 此时应到对应的原生代码(C++ / Objective-C / Kotlin)中查找持有该导出引用、却迟迟未释放的对象;
- 确认原生对象是否在正确时机调用析构/释放逻辑——只有原生对象被回收,导出的 JS 引用才会被拆除,内存才能被 GC 正常回收。
排查流程小结
把以上要点串起来,一个完整的 Valdi 内存泄漏排查闭环如下:
- 准备:确认
.bazelrc中已设置build --valdi_js_engine=hermes,应用以 debug 模式运行,hot reloader 已连接; - 采集:在 VSCode 中附加
Valdi attach调试会话,在 Profiling 面板请求 Heap Snapshot,得到heapsnapshot文件; - 初筛:在 Chrome DevTools 的 Memory 面板加载快照,按
retained size排序,找出"保留内存最大但本不该存在"的对象; - 追链:利用 Retainers 逐级向上追溯,判断对象是被 JS 侧全局引用还是被
System / ExportedReferences原生引用持有; - 定性:JS 侧引用检查模块级变量、闭包、事件监听器;原生侧引用则定位到具体的 C++/Objective-C/Kotlin 持有者并修复其生命周期;
- 验证:修复后重新导出堆转储,确认对象可被正常回收,
retained size显著下降。
对于需要自动化、回归化验证的团队,还可以将导出的 heapsnapshot 文件交给支持 V8 快照格式的 Memlab 类工具做批量对象分析,将内存泄漏检查纳入常规测试流程。


