共计 8068 个字符,预计需要花费 21 分钟才能阅读完成。
图片起源:debugging-memory-leaks-node-js-applications
本文作者:肖思元
在 node 中能够通过 v8.getHeapSnapshot 来获取利用以后的堆快照信息,该调用会生成一份 .heapsnapshot
文件,官网并没有对该文件的内容有一个具体的解释,本文将次要对该文件内容进行解析,并演示了一个理解文件内容后能够做的乏味的事件
v8.getHeapSnapshot
首先简略回顾下 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 进行查看
对于下面的步骤咱们也能够间接 查看视频演示
当咱们将.heapsnapshot
文件导入到 Chrome Dev Tools 之后,咱们会看到相似上面的内容:
上图表格列出了以后堆中的所有对象,其中列的含意是:
- Constructor,示意对象是应用该函数结构而来
- Constructor 对应的实例的数量,在 Constructor 前面的
x2
中显示 - Shallow size,对象本身大小(单位是 Byte),比方下面的
HugeObj
,它的实例的 Shallow size 就是本身占用的内存大小,比方,对象外部为了保护属性和值的对应关系所占用的内存,并不蕴含持有对象的大小
比方hugeData
属性援用的Buffer
对象的大小,并不会计算在HugeObj
实例的 Shallow size 中 - Retained size,对象本身大小加上它依赖链路上的所有对象的本身大小(Shallow size)之和
- Distance,示意从根节点(Roots)达到该对象通过的最短门路的长度
heapsnapshot 文件
Chrome Dev Tools 只是 .heapsnapshot
文件的一种展示模式,如果咱们心愿最大水平利用这些信息,则须要进一步理解其文件格式
咱们能够应用任意的文本编辑器关上该文件,能够发现文件内容其实是 JSON 格局的:
因为目前没有具体的阐明文档,前面的内容咱们将联合源码来剖析该文件的内容
文件内容概览
在原始输入的文件内容中,能够发现 snapshot
字段局部是去除空白的,而 nodes
和 edges
字段的内容都是有换行分隔的,整体文件有十分多的行数
为了不便了解,咱们能够将节点折叠,这样能够看出该文件的整体内容:
随后咱们在源码中,以该 v8.getHeapSnapshot
的 binding
着手,定位到该文件内容是办法 HeapSnapshotGenerator::GenerateSnapshot 的运行后果
并且咱们晓得对象在内存中的拓扑模式须要应用 Graph 数据结构 来示意,因而输入文件中有 nodes
和 edges
字段别离用于示意堆中的对象,以及对象间的连贯关系:
图片援用自 [Graphs
](https://guides.codepath.com/c…
不过nodes
和edges
中并没有间接存储对象的信息,而都是一连串数字,咱们须要进一步剖析其中的内容
nodes
nodes 中的每一个 Node 的序列化办法是:HeapSnapshotJSONSerializer::SerializeNode
从源码来看,每输入完 node 的所有属性值后,会跟着输入 n0
,这也是输入后果中 nodes
数组是一行行数字的起因。不过咱们晓得 n0
在 JSON 反序列化的时候因为会因为本身合乎空白的定义而被疏忽掉,所以这样的换行能够了解是为了不便间接查看源文件
咱们来看一个例子,比方:
{
"nodes":[9,1,1,0,10,0 // 第一行
,9,2,3,0,23,0 // 第二行
}
下面的内容,每行别离示意一个 node,每一行都是对象的属性的 value
(咱们先不必思考为什么 value 都是数值)。而属性的 name
咱们通过源码中输入的程序能够整理出来:
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
中
Field Type
接下来咱们来看为什么 nodes 数组保留的属性 value 都是数值
还是下面的例子,因为咱们曾经晓得了,属性名称和属性值是按索引程序对应上的,那么对于下面第一个 node 的 propertyName(propertyValue)
列表能够示意为:
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
中只寄存属性值,咱们须要计算一下偏移量(上面会讲到),来确定属性的类型:- 如果是数值类型,那么该值就是自身的内容
- 如果是数组,则 value 对应数组中的索引
- 如果是字符串,则 value 对应
strings
数组的内容
咱们能够用上面的图来示意三者之间的关系:
咱们通过一个例子来串联下面的内容。比方咱们要看索引为 1000 的对象(留神区别 id
属性)的 name
属性的值,应用上面的形式:
- 取
name
属性在snapshot.meta.node_fields
中的索引为1
- 取
snapshot.meta.node_fields
数组的长度为6
- 则索引为 1000 的对象的起始索引为:
1000 * 6
(因为对象属性的数量是固定的) - 加上
name
属性的偏移量1
,则name
在nodes
数组中的索引为6001 = 1000 * 6 + 1
- 取
name
属性在snapshot.meta.node_types
中的类型,即snapshot.meta.node_types[1]
,在这个例子中是string
- 则
strings[6001]
的内容就是name
属性值的最终内容
其余一些字段的含意是:
- id,对象的 id,v8 会确保该对象在本次利用生命周期中的屡次的 dump 下中放弃雷同的 id
- self_size,也就是上文提到的 shallow size
- edge_count,就是从该对象进来的边的条数,也就是子对象的数量
- trace_node_id,能够临时不去思考,只有在同时应用
node --track-heap-objects
启动利用的状况下,该内容才不会为0
。它能够联合trace_tree
和trace_function_infos
一起晓得对象是在什么调用栈下被创立的,换句话说就是晓得通过一系列什么调用创了该对象。文本不会探讨这部分内容,或者会在当前的章节中开展
edges
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 是如何对应的呢?
Node 和 Edge 的对应关系
从头以 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
咱们结尾说理解文件内容能够做一些乏味的事件,接下来咱们将演示一个小程序 heapquery(Rust 劝入版),它能够将 .heapsnapshot
文件的内容导入到 sqlite 中,而后咱们就能够通过 SQL 来查问本人感兴趣的内容了(尽管远没有 osquery 高级,然而间接通过 SQL 来查问堆上的内容,想想都会很乏味吧)
除此以外,它还能够:
- 验证上文对 heapsnapshot 文件格式的剖析
- 对上文的文字描述提供一个可运行的代码的补充解释
因为 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
属性的作用是什么。首先,通常函数蕴含的信息有:
- 定义所在的源文件地位
- 原始代码(在具备 JIT 的运行时中用于 Deoptimize)
- 一组在业务上可复用的指令(Opcode or JITed)
- PC 寄存器信息,示意当然执行到外部哪一个指令,并在将来复原时能够继续执行
- BP 寄存器信息,示意以后调用栈帧在栈上的起始地址
- 函数对象创立时对应的闭包援用
其中「定义所在的源文件地位」、「原始代码」、「一组在业务上可复用的指令(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!