图片来源: debugging-memory-leaks-node-js-applications
本文做者: 肖思元
在 node 中能够经过 v8.getHeapSnapshot 来获取应用当前的堆快照信息,该调用会生成一份 .heapsnapshot
文件,官方并无对该文件的内容有一个详细的解释,本文将主要对该文件内容进行解析,并演示了一个了解文件内容后能够作的有趣的事情html
首先简单回顾下 v8.getHeapSnapshot
是如何使用的:前端
// test.js const { writeHeapSnapshot } = require("v8"); class HugeObj { constructor() { this.hugeData = Buffer.alloc((1 << 20) * 50, 0); } } // 注意下面的用法在实际应用中一般是 anti-pattern, // 这里只是为了方便演示,才将对象挂到 module 上以防止被 GC 释放 module.exports.data = new HugeObj(); writeHeapSnapshot();
将上面的代码保存到 test.js
中,而后运行 node test.js
,会生成文件名相似 Heap.20210228.154141.9320.0.001.heapsnapshot
的文件,该文件可使用 Chrome Dev Tools 进行查看node
对于上面的步骤咱们也能够直接 查看视频演示
当咱们将.heapsnapshot
文件导入到 Chrome Dev Tools 以后,咱们会看到相似下面的内容:
![]()
上图表格列出了当前堆中的全部对象,其中列的含义是:
x2
中显示HugeObj
,它的实例的 Shallow size 就是自身占用的内存大小,好比,对象内部为了维护属性和值的对应关系所占用的内存,并不包含持有对象的大小hugeData
属性引用的 Buffer
对象的大小,并不会计算在 HugeObj
实例的 Shallow size 中Chrome Dev Tools 只是 .heapsnapshot
文件的一种展示形式,若是咱们但愿最大程度利用这些信息,则须要进一步了解其文件格式
咱们可使用任意的文本编辑器打开该文件,能够发现文件内容实际上是 JSON 格式的:
由于目前没有具体的说明文档,后面的内容咱们将结合源码来分析该文件的内容git
在原始输出的文件内容中,能够发现 snapshot
字段部分是去除空白的,而 nodes
和 edges
字段的内容都是有换行分隔的,总体文件有很是多的行数
为了方便理解,咱们能够将节点折叠,这样能够看出该文件的总体内容:
随后咱们在源码中,以该 v8.getHeapSnapshot
的 binding
着手,定位到该文件内容是方法 HeapSnapshotGenerator::GenerateSnapshot 的运行结果
而且咱们知道对象在内存中的拓扑形式须要使用 Graph 数据结构 来表示,所以输出文件中有 nodes
和 edges
字段分别用于表示堆中的对象,以及对象间的链接关系:github
图片引用自 [Graphs
]( https://guides.codepath.com/c...
不过nodes
和edges
中并无直接存储对象的信息,而都是一连串数字,咱们须要进一步分析其中的内容
nodes 中的每个 Node 的序列化方法是:HeapSnapshotJSONSerializer::SerializeNode
从源码来看,每输出完 node 的全部属性值后,会跟着输出 n0
,这也是输出结果中 nodes
数组是一行行数字的缘由。不过咱们知道 n0
在 JSON 反序列化的时候由于会由于自身符合空白的定义而被忽略掉,因此这样的换行能够理解是为了方便直接查看源文件
咱们来看一个例子,好比:web
{ "nodes":[9,1,1,0,10,0 // 第一行 ,9,2,3,0,23,0 // 第二行 }
上面的内容,每行分别表示一个 node,每一行都是对象的属性的 value
(咱们先不用考虑为何 value 都是数值)。而属性的 name
咱们经过源码中输出的顺序能够整理出来:sql
0. type 1. name 2. id 3. self_size 4. edge_count 5. trace_node_id
由于 value
的输出顺序和上面的 name
是对应的,因此咱们能够根据属性 name
的顺序做为索引,去关联其 value
的值
不过实际上并不能省略属性名称列表的输出,由于属性的内容是可能在后续的 node 版本中变化的(主要是跟随 v8 的变化),为了和对应的数据消费端解耦,文件中会将属性 name
列出输出,保存在 snapshot.meta.node_fields
中chrome
接下来咱们来看为何 nodes 数组保存的属性 value 都是数值
仍是上面的例子,由于咱们已经知道了,属性名称和属性值是按索引顺序对应上的,那么对于上面第一个 node 的 propertyName(propertyValue)
列表能够表示为:json
0. type(9) 1. name(1) 2. id(1) 3. self_size(0) 4. edge_count(10) 5. trace_node_id(0)
好比第 1 号属性 name
,它就是对象的名称,不过根据对象的类型不一样,该值也会有不一样的取值方式。好比对于通常对象而言,它的内容就是其构造函数的名称,对于 Regexp 对象而言,它的值就是 pattern
字符串,更多得能够参考 V8HeapExplorer::AddEntry
假如咱们直接保存属性的值,那么若是堆中有 1000 个由 HugeObj
构造的对象,HugeObj
字符串就要保存 1000 个拷贝
由于 heapdump 顾名思义,输出大小几乎就和当前 Node 应用所占内存大小一致(并不彻底一致,这里 heapdump 只包含受 GC 管理的内容),为了让输出的结果尽量的紧凑,v8 在输出属性值的时候,按必定的规则进行了压缩,压缩的秘诀是:小程序
snapshot.meta.node_types
,来存放属性的类型,和 snapshot.meta.node_fields
相似,它们和属性值之间也是经过索引(顺序)关联的nodes
中只存放属性值,咱们须要计算一下偏移量(下面会讲到),来肯定属性的类型:
strings
数组的内容咱们能够用下面的图来表示三者之间的关系:
咱们经过一个例子来串联上面的内容。好比咱们要看索引为 1000 的对象(注意区别 id
属性)的 name
属性的值,使用下面的方式:
name
属性在 snapshot.meta.node_fields
中的索引为 1
snapshot.meta.node_fields
数组的长度为 6
1000 * 6
(由于对象属性的数量是固定的)name
属性的偏移量 1
,则 name
在 nodes
数组中的索引为 6001 = 1000 * 6 + 1
name
属性在 snapshot.meta.node_types
中的类型,即 snapshot.meta.node_types[1]
,在这个例子中是 string
strings[6001]
的内容就是 name
属性值的最终内容其他一些字段的含义是:
node --track-heap-objects
启动应用的状况下,该内容才不会为 0
。它能够结合 trace_tree
和 trace_function_infos
一块儿知道对象是在什么调用栈下被建立的,换句话说就是知道通过一系列什么调用创了该对象。文本不会讨论这部份内容,或许会在之后的章节中展开edges 中的 Edge 的序列化方式是:HeapSnapshotJSONSerializer::SerializeEdge
字段内容分别是:
0. type 1. edge_name_or_index(idx or stringId) 2. to
和上面的 nodes 数组相似,edges 数组也是都存的属性的值,所以在取最终值的时候,须要结合 snapshot.meta.edge_fields
snapshot.meta.edge_types
来操做
惟一的问题在于,咱们知道 Edge 表示的对象之间的关系,并且这里是有向图,那么必定有 From
和 To
两个字段,而上面的字段内容只有 To
,那么 nodes 和 edges 是如何对应的呢?
从头以 HeapSnapshotGenerator::GenerateSnapshot 方法开始分析,看看 nodes 和 edges 是如何产生的,下面是该方法中的相关主要内容:
bool HeapSnapshotGenerator::GenerateSnapshot() { // ... // 加入 Root 节点,做为活动对象的起点 snapshot_->AddSyntheticRootEntries(); // 即 HeapSnapshotGenerator::FillReferences 方法,nodes 和 edges // 都是由该方法构建的,这里的 nodes 和 edges 指的是 HeapSnapshot 的 // 数据成员 `entries_` 和 `edges_` if (!FillReferences()) return false; // 输出文件中的 edges 实际是经过 `FillChildren` 从新组织顺序的, // 从新组织后的内容保存在 HeapSnapshot 的数据成员 children_ 中 snapshot_->FillChildren(); snapshot_->RememberLastJSObjectId(); progress_counter_ = progress_total_; if (!ProgressReport(true)) return false; // ... }
能够暂时不去深刻了解 Node 和 Edge 是如何生成的,看一下 HeapSnapshot::FillChildren 方法是如何从新组织输出的 edges 内容的:
void HeapSnapshot::FillChildren() { // ... int children_index = 0; for (HeapEntry& entry : entries()) { children_index = entry.set_children_index(children_index); } // ... children().resize(edges().size()); for (HeapGraphEdge& edge : edges()) { edge.from()->add_child(&edge); } }
其中 entry.set_children_index
和 edge.from()->add_child
方法内容分别是:
int HeapEntry::set_children_index(int index) { // Note: children_count_ and children_end_index_ are parts of a union. int next_index = index + children_count_; children_end_index_ = index; return next_index; } void HeapEntry::add_child(HeapGraphEdge* edge) { snapshot_->children()[children_end_index_++] = edge; }
因此对于每一个 entry(即 node)都有一个属性 children_index
,它表示 entry 的 children 在 children_
数组中的起始索引(上面注释中已经提到,heapsnapshot 文件中的 edges
数组的内容就是根据 children_
数组输出的)
综合来看,edges
数组的内容和 nodes
之间的对应关系大体是:
好比上面 edge0
的 From
就是 nodes[0 + 2]
,其中:
nodes
表示 nodes 数组0
的位置表示该 node 在 nodes
数组中的索引,这里也就是第一个元素2
表示 id
属性在 snapshot.meta.node_fields
数组中的偏移量node0
的 edge_count
能够表示成 nodes[0 + 4]
:
4
表示 edge_count
属性在 snapshot.meta.node_fields
数组中的偏移量因此 edges
数组中,从 0
开始的 node0.edge_count
个 edge 的 From
都是 node0.id
由于 node[n].edge_count
是变量,因此咱们没法快速根据索引定位到某个 edge 的 From,咱们必须从索引 0
开始,而后步进 node[n].edge_count
次(n
从 0
开始),步进次数内的 edge 的 From 都为 node[n].id
,步进结束后对 n = n + 1
,进而在下一次迭代中关联下一个 node 的 edges
咱们开头说了解文件内容能够作一些有趣的事情,接下来咱们将演示一个小程序 heapquery(Rust 劝入版),它能够将 .heapsnapshot
文件的内容导入到 sqlite 中,而后咱们就能够经过 SQL 来查询本身感兴趣的内容了(虽然远没有 osquery 高级,可是直接经过 SQL 来查询堆上的内容,想一想都会颇有趣吧)
除此之外,它还能够:
由于 heapquery 的程序内容很是简单(仅仅是解析格式并导入而已),因此就不赘述了。只简单看一下涉及的表结构,由于仅仅是演示用,到最后其实只有两张表:
Node 表
CREATE TABLE IF NOT EXISTS node ( id INTEGER PRIMARY KEY, /* 对象 id */ name VARCHAR(50), /* 对象所属类型名称 */ type VARCHAR(50), /* 对象所属类型枚举,取自 `snapshot.meta.node_types` */ self_size INTEGER, /* 对象自身大小 */ edge_count INTEGER, /* 对象持有的子对象数量 */ trace_node_id INTEGER );
Edge 表
CREATE TABLE IF NOT EXISTS edge ( from_node INTEGER, /* 父对象 id */ to_node INTEGER, /* 子对象 id */ type VARCHAR(50), /* 关系类型,取自 `snapshot.meta.edge_types` */ name_or_index VARCHAR(50) /* 关系名称,属性名称或者索引 */ );
在本文开头的位置,咱们定义了一个 HugeObj
类,在实例化该类的时候,会建立一个大小为 50M 的 Buffer
对象,并关联到其属性 hugeData
上
接下来咱们将进行一个小演练,假设咱们事先并不知道 HugeObj
,咱们如何经过可能的内存异常现象反推定位到它
首先咱们须要将 .heapsnapshot
导入到 sqlite 中:
npx heapquery path_to_your_heapdump.heapsnapshot
命令运行完成后,会在当前目录下生成 path_to_your_heapdump.db
文件,咱们能够选择本身喜欢的 sqlite browser 打开它,好比这里使用的 DB Browser for SQLite
而后咱们执行一条 SQL 语句,将 node 按 self_size
倒序排列后输出:
SELECT * FROM node ORDER By self_size DESC
咱们会获得相似下面的结果:
咱们接着从大小可疑的对象入手,固然这里就是先看截图中 id
为 51389
的这条数据了
接下来咱们再执行一条 SQL 语句,看看是哪一个对象持有了对象 51389
SELECT from_node, B.name AS from_node_name FROM edge AS A JOIN node AS B ON A.from_node = B.id WHERE A.to_node = 51389
咱们会获得相似下面的输出:
上面的输出中,咱们知道持有 51389
的对象是 51387
,而且该对象的类型是 ArrayBuffer
由于 ArrayBuffer
是环境内置的类,咱们并不能看出什么问题,所以须要利用上面的 SQL,继续查看 51387
是被哪一个对象持有的:
和上面的输出相似,此次的 Buffer
依然是内置对象,因此咱们继续重复上面的步骤:
此次咱们获得了一个业务对象 HugeObj
,咱们看看它是在哪里定义的。对象的定义就是它的构造函数,所以咱们须要找到它的 constructor
,为此咱们先列出对象的全部属性:
SELECT * FROM edge WHERE from_node = 46141 AND `type` = "property"
接着咱们在原型中继续查找:
SELECT * FROM edge WHERE from_node = 4575 AND `type` = "property"
咱们找到了 constructor
对象 4577
,接着咱们来找到它的 shared
内部属性:
SELECT * FROM edge WHERE from_node = 4577 AND name_or_index = "shared"
咱们简单解释一下 shared
属性的做用是什么。首先,一般函数包含的信息有:
其中「定义所在的源文件位置」、「原始代码」、「一组在业务上可复用的指令(Opcode or JITed)」是没有必要制造出多份拷贝的,所以相似这样的内容,在 v8 中就会放到 shared
对象中
接下来咱们能够输出 shared
对象 43271
的属性:
SELECT * FROM edge WHERE from_node = 43271
咱们继续输出 script_or_debug_info
属性持有的对象 8463
:
SELECT * FROM edge WHERE from_node = 8463
最后咱们输出 name
属性持有的对象 4587
:
这样咱们就找到了对象定义的文件,而后就能够在该文件中继续肯定业务代码是否存在泄漏的可能
或许有人会对上面的步骤感到繁琐,其实没必要担忧,咱们能够结合本身实际的查询需求,将经常使用的查询功能编写成子程序,这样之后只要给一个输入,就能帮助咱们分析出想要的结果了
本文以分析 .heapsnapshot
文件的格式为切入点,结合 node 的源码,解释了 .heapsnapshot
文件格式和其生成的方式,并提供了个 heapquery 的小程序,演示了了解其结构能够帮助咱们得到不局限于现有工具的信息。最后祝你们上分愉快!
本文发布自 网易云音乐大前端团队,文章未经受权禁止任何形式的转载。咱们常年招收前端、iOS、Android,若是你准备换工做,又刚好喜欢云音乐,那就加入咱们 grp.music-fe(at)corp.netease.com!