Z
Toggle Nav

V8 Heap Snapshot 格式解析

查看页面堆内存使用情况时,DevTools 的 Memory 面板是最核心的工具。

但在超大型前端工程中,有些场景使用 Memory 面板定位堆内存使用情况是不太方便的,典型场景例如:

  • 堆内存字符串常量池中有上百万个字符串,这些字符串可能被不同的对象持有,在 Memory 面板中无法知道这些字符串持有对象的分布。
  • 一次只能查看一条持有链,例如上图中 Object@194053 可以看到被谁持有,以及持有谁,但都是以 Object@194053 为核心的,如果有一个较大的循环引用,往往很难发现
  • 没有方便的过滤、排序选项

但所幸 DevTools 支持将堆内存快照保存为文本格式,点击保存按钮就会保存当前快照为 *.heapsnapshot 文件,这就使我们编写自定义分析程序成为可能,分析的第一步就是要了解并解析 heapsnapshot 文件中的关键数据。

堆内存结构

在开始分析文件结构前,先简单了解下堆内存的结构。

上图是视觉上的堆内存表示,堆内存中一个对象有两种方式来占用一些内存:

  • 对象自身直接占用内存
  • 对象持有别的对象,使别的对象无法被 GC 掉,从而间接占用内存

对象之间可以互相引用,不论是通过属性链接到另一个对象上,还是将另一个对象存在自己的数组里,本质上都是建立了对象之间的引用关系,这些引用关系就如同上图一样,所以堆内存结构本质上就是一张 “有向图”,我们解析 heapsnapshot 文件就是要把这张 “有向图” 从数据中发掘出来。

heapsnapshot 文件结构

heapsnapshot 是纯文本文件,可以直接使用文本编辑器打开。打开后可以看到它其实是一份 JSON 文档。

既然是 JSON 文档,那就能用 JS 直接读它并在 DevTools 里查看:

const [fh] = await window.showOpenFilePicker();
const fp = await fh.getFile();
const reader = new FileReader();
reader.onload = () => console.log(JSON.parse(reader.result))
reader.readAsText(fp);

heapsnapshot 文件中核心字段有四个:

  • nodes - 存储所有顶点数据,每一个顶点是堆内存中最小的原子结构
  • edges - 存储所有边的数据,边连接两个顶点
  • snapshot - 这份 snapshot 文件的元数据,包含顶点数量、边数量、顶点字段类型、边字段类型等
  • strings - 所有字符串数据,例如顶点名称,类型名称等

nodes

首先看一下 nodes 序列化时的结构,从 HeapSnapshotJSONSerializer::SerializeNode

方法中可以看到,V8 会把每个顶点的7个属性按顺序保存下来,也就是说在 nodes 数组中,每 7 个值属于一个顶点,他们的值都是数字,却有不同的含义。 snapshot.node_count * snapshot.meta.node_types.length 的结果正是 nodes.length

这7个属性值分别是

  • type - 节点类型,它是 snapshot.meta.node_types[0] 的索引
  • name - 节点名称,它是 strings 的索引
  • id - 节点 id, 这个 id 是 V8 Snapshot 对象的id,也就是上图中 Object@194053 @ 后面的值,该 id 在一个 V8 实例中全局唯一
  • self_size - 节点自身尺寸
  • edge_count - 起点为该节点的边的数量
  • trace_node_id - 可以暂时不管,正常方式启动 Chromium/Chrome 后该字段值始终为 0,只有启动时带上参数 --track-heap-objects 才不为0,用于定位该对象创建的调用栈
  • detachedness - 可以暂时不管,它表示对象是否从主应用中分离(detached),通常都是 0,表示 unknown

看到这里堆 nodes 的结构就很清晰了:

简单的伪代码就是:

for node_base_idx in (0..all_nodes.len()).step_by(meta.node_fields.len()) {
    nodes.push(Node {
        node_type: meta.node_types[0].get_value(all_nodes[node_base_idx] as usize),
        name: self.get_string(all_nodes[node_base_idx + 1] as usize),
        id: all_nodes[node_base_idx + 2],
        self_size: all_nodes[node_base_idx + 3],
        edge_count: all_nodes[node_base_idx + 4],
        trace_node_id: all_nodes[node_base_idx + 5],
        detachedness: all_nodes[node_base_idx + 6],
    })
}

node 是 Heap 中的实体的映射,所以 node 会有不同的类型。正如前面提到的,node 的类型存储在 snapshot.meta.node_types 中,它们分别是:

  • hidden: 隐藏类型
  • array: 数组
  • string: 字符串
  • object: 除数组与字符串外的 JavaScript 对象
  • code: 编译后的代码
  • closure: 函数闭包(是的,函数闭包不只是一个语法结构,它也存在于堆内存中)
  • regexp: 正则表达式
  • number: 存储在堆中的数字
  • native: 非 V8 引入的 Native 对象
  • synthetic: 合成对象,通常用于打包多个 snapshot 中的项目
  • concatenated string: 串联的字符串(一对指向字符串的指针)
  • sliced string: 字符串切片(另一个字符串的片段)
  • symbol: ES6 中的 Symbol
  • bigint: BigInt

node name 也与 node type 相关联。前文提到,name 也是 node 上的一个属性,表示这个节点的名称,但 name 属性的真实含义是相对于 node type 的。

  • 如果 node type 为 object,则 node name 为它的 constructor name
  • 如果 node type 为 closure,则 node name 为闭包函数名称
  • 如果 node type 为 string,则 node name 为 string value

edges

看懂了 nodes 的结构,edges 的结构就好理解多了,与 nodes 类型,edges 也是三个值组成一个 edge,这三个值分别是:

  • type - 边的类型,它是 snapshot.meta.edge_types[0] 的索引
  • name_or_index - 边的名称索引或自身索引
    • 如果边的类型为 element 或 hidden,则其值为自身索引,表示目标节点在源节点中的位置
    • 否则就是名称索引,表示边的名称,值为 strings 的索引
  • to_node - 边的目标节点索引

唯一的问题是,heap snapshot 是一张有向图,但 edges 中只记录了一条边的 「终点」(to_node),没有 「起点」(from_node),这样的图是不完整的。那么边与它的起点是如何对应的呢,这需要再看一下 HeapSnapshotGenerator::FillChildren

大致的逻辑是:

  • FillChildren 按顺序遍历每一个 node 时,记录从该 node 出发有多少条边,并输出
  • FillChildren 按顺序遍历每一个 edge 时,并输出

所以 node 与 edge 的关系就是:

  • 每一个 node 的 edge_count 为几,说明它有几条边
  • edges 是 nodes 的映射,node 的 edge_count 时几,在 edges 中就有几条边的起始节点是该 node

例如上图中:

  • node0 的 edge_count 为 3,edges[0..3] 的 from_node 就是 node0
  • node1 的 edge_count 为 1, edges[3] 的 from_node 就是 node1
  • node2 的 edge_count 为 0, edges 中没有起点为 node2 的边
  • node3 的 edge_count 为 1,edges[4] 的 from_node 就是 node3

简单的伪代码就是:

let mut edge_from_node_idx = 0;
let mut edge_from_node_acc = 0;

// 按 edge_fields 的长度,步进解析每一条边
for (edge_idx, edge_base_idx) in (0..all_edges.len())
    .step_by(meta.edge_fields.len())
    .enumerate()
{
    // 边的基础信息
    let edge_type = meta.edge_types[0].get_value(all_edges[edge_base_idx] as usize);
    let edge_name_or_index = self.get_string(all_edges[edge_base_idx + 1] as usize);
    let edge_to_node_idx = all_edges[edge_base_idx + 2] as usize / nodes_fields_count;

    // 忽略 edge_count 为 0 的节点
    while nodes[edge_from_node_idx].edge_count == 0 {
        edge_from_node_idx += 1;
        edge_from_node_acc = 0;
    }

    // 设置边的起点/终点
    let edge_idx = edge_idx as u32;
    nodes[edge_from_node_idx].to_edge_index.push(edge_idx);
    nodes[edge_to_node_idx].from_edge_index.push(edge_idx);

    let from_node_id = nodes[edge_from_node_idx].id;
    let to_node_id = nodes[edge_to_node_idx].id;

    edges.push(Edge {
        edge_type,
        name_or_index: edge_name_or_index,
        to_node_index: edge_to_node_idx,
        to_node_id,
        from_node_index: edge_from_node_idx,
        from_node_id,
    });

    edge_from_node_acc += 1;

    // 如果当前边的累加值超过 edge_count,则重置累加器,边索引 +1
    if edge_from_node_acc >= nodes[edge_from_node_idx].edge_count as usize {
        edge_from_node_idx += 1;
        edge_from_node_acc = 0;
    }
}

edge 是 heap 中两个实体的关系映射,例如对象引用、数组引用等。同样,edge 也有多种类型:

  • context 表示目标来源于一个 function context
  • element 表示目标是一个数组中的元素
  • property 表示目标是一个对象上的属性
  • internal 内部引用,这种引用无法被 JavaScript 访问,例如 concatenated string
  • hidden 这种连接在计算持有尺寸时需要,但不对用户暴露
  • shortcut 这种连接在计算持有尺寸时需要排除
  • weak 弱引用连接