v8 Heapsnapshot 文件解析

圖片來源: debugging-memory-leaks-node-js-applications
本文做者: 肖思元

在 node 中能夠經過 v8.getHeapSnapshot 來獲取應用當前的堆快照信息,該調用會生成一份 .heapsnapshot 文件,官方並無對該文件的內容有一個詳細的解釋,本文將主要對該文件內容進行解析,並演示了一個瞭解文件內容後能夠作的有趣的事情html

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 進行查看node

對於上面的步驟咱們也能夠直接 查看視頻演示
當咱們將 .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 格式的:

由於目前沒有具體的說明文檔,後面的內容咱們將結合源碼來分析該文件的內容git

文件內容概覽

在原始輸出的文件內容中,能夠發現 snapshot 字段部分是去除空白的,而 nodesedges 字段的內容都是有換行分隔的,總體文件有很是多的行數
爲了方便理解,咱們能夠將節點摺疊,這樣能夠看出該文件的總體內容:

隨後咱們在源碼中,以該 v8.getHeapSnapshotbinding 着手,定位到該文件內容是方法 HeapSnapshotGenerator::GenerateSnapshot 的運行結果
而且咱們知道對象在內存中的拓撲形式須要使用 Graph 數據結構 來表示,所以輸出文件中有 nodesedges 字段分別用於表示堆中的對象,以及對象間的鏈接關係:
github

圖片引用自 [Graphs
]( https://guides.codepath.com/c...
不過 nodesedges 中並無直接存儲對象的信息,而都是一連串數字,咱們須要進一步分析其中的內容

nodes

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_fieldschrome

Field Type

接下來咱們來看爲何 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 中只存放屬性值,咱們須要計算一下偏移量(下面會講到),來肯定屬性的類型:

    • 若是是數值類型,那麼該值就是自己的內容
    • 若是是數組,則 value 對應數組中的索引
    • 若是是字符串,則 value 對應 strings 數組的內容

咱們能夠用下面的圖來表示三者之間的關係:

咱們經過一個例子來串聯上面的內容。好比咱們要看索引爲 1000 的對象(注意區別 id 屬性)的 name 屬性的值,使用下面的方式:

  • name 屬性在 snapshot.meta.node_fields 中的索引爲 1
  • snapshot.meta.node_fields 數組的長度爲 6
  • 則索引爲 1000 的對象的起始索引爲:1000 * 6(由於對象屬性的數量是固定的)
  • 加上 name 屬性的偏移量 1,則 namenodes 數組中的索引爲 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_treetrace_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 表示的對象之間的關係,並且這裏是有向圖,那麼必定有 FromTo 兩個字段,而上面的字段內容只有 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_indexedge.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 之間的對應關係大體是:

好比上面 edge0From 就是 nodes[0 + 2],其中:

  • nodes 表示 nodes 數組
  • 0 的位置表示該 node 在 nodes 數組中的索引,這裏也就是第一個元素
  • 2 表示 id 屬性在 snapshot.meta.node_fields 數組中的偏移量

node0edge_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 次(n0 開始),步進次數內的 edge 的 From 都爲 node[n].id,步進結束後對 n = n + 1 ,進而在下一次迭代中關聯下一個 node 的 edges

heapquery

咱們開頭說了解文件內容能夠作一些有趣的事情,接下來咱們將演示一個小程序 heapqueryRust 勸入版),它能夠將 .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

咱們會獲得相似下面的結果:

咱們接着從大小可疑的對象入手,固然這裏就是先看截圖中 id51389 的這條數據了
接下來咱們再執行一條 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!
相關文章
相關標籤/搜索