圖片來源: 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!