垃圾回收追蹤
本指南將介紹垃圾回收追蹤的基礎知識。
讀完本指南後,你將能夠
- 在你的 Node.js 應用程式中啟用追蹤
- 解讀追蹤日誌
- 識別你的 Node.js 應用程式中潛在的記憶體問題
關於垃圾回收器(GC)的工作原理有很多知識需要學習,但如果你只記住一件事,那就是當 GC 執行時,你的程式碼是不執行的。
你可能想知道垃圾回收執行的頻率和時長,以及其結果是什麼。
準備工作
為了本指南的目的,我們將使用這個指令碼
// script.mjs
import from 'node:os';
let = 1_000_000;
const = new ();
function () {
const = {
: .(),
: .(),
: .(),
: .(),
};
.();
}
function () {
.(`Total: ${.} entries`);
}
// execution
(() => {
while ( > 0) {
();
..(`~~> ${} entries to record\r`);
--;
}
();
})();
儘管這裡的洩漏很明顯,但在真實世界的應用程式中,找到洩漏的源頭可能很麻煩。
帶垃圾回收追蹤執行
你可以使用 --trace-gc 標誌在程序的控制檯輸出中看到垃圾回收的追蹤資訊。
$ node --trace-gc script.mjs
注意:你可以在 Node.js Diagnostics 倉庫中找到此練習的原始碼。
它應該會輸出類似這樣的內容
[39067:0x158008000] 2297 ms: Scavenge 117.5 (135.8) -> 102.2 (135.8) MB, 0.8 / 0.0 ms (average mu = 0.994, current mu = 0.994) allocation failure
[39067:0x158008000] 2375 ms: Scavenge 120.0 (138.3) -> 104.7 (138.3) MB, 0.9 / 0.0 ms (average mu = 0.994, current mu = 0.994) allocation failure
[39067:0x158008000] 2453 ms: Scavenge 122.4 (140.8) -> 107.1 (140.8) MB, 0.7 / 0.0 ms (average mu = 0.994, current mu = 0.994) allocation failure
[39067:0x158008000] 2531 ms: Scavenge 124.9 (143.3) -> 109.6 (143.3) MB, 0.7 / 0.0 ms (average mu = 0.994, current mu = 0.994) allocation failure
[39067:0x158008000] 2610 ms: Scavenge 127.1 (145.5) -> 111.8 (145.5) MB, 0.7 / 0.0 ms (average mu = 0.994, current mu = 0.994) allocation failure
[39067:0x158008000] 2688 ms: Scavenge 129.6 (148.0) -> 114.2 (148.0) MB, 0.8 / 0.0 ms (average mu = 0.994, current mu = 0.994) allocation failure
[39067:0x158008000] 2766 ms: Scavenge 132.0 (150.5) -> 116.7 (150.5) MB, 1.1 / 0.0 ms (average mu = 0.994, current mu = 0.994) allocation failure
Total: 1000000 entries
難以閱讀?或許我們應該回顧一些概念,並解釋一下 --trace-gc 標誌的輸出。
使用 --trace-gc 檢查追蹤日誌
--trace-gc(或 --trace_gc,兩者都可以)標誌會在控制檯輸出所有垃圾回收事件。每一行的構成可以描述為
[13973:0x110008000] 44 ms: Scavenge 2.4 (3.2) -> 2.0 (4.2) MB, 0.5 / 0.0 ms (average mu = 1.000, current mu = 1.000) allocation failure
| 標記值 | 解釋 |
|---|---|
| 13973 | 執行程序的 PID |
| 0x110008000 | Isolate(JS 堆例項) |
| 44 ms | 自程序啟動以來的時間(毫秒) |
| Scavenge | GC 的型別/階段 |
| 2.4 | GC 前已使用的堆記憶體(MB) |
| (3.2) | GC 前的總堆記憶體(MB) |
| 2.0 | GC 後已使用的堆記憶體(MB) |
| (4.2) | GC 後的總堆記憶體(MB) |
| 0.5 / 0.0 ms (average mu = 1.000, current mu = 1.000) | GC 耗時(毫秒) |
| allocation failure | GC 的原因 |
我們這裡只關注兩個事件
- Scavenge
- Mark-sweep
堆被劃分為多個*空間*。其中包括一個名為“新生代”(new space)的空間和另一個名為“老生代”(old space)的空間。
👉 實際上,堆的結構要複雜一些,但本文中我們將使用一個簡化版本。如果你想了解更多細節,我們鼓勵你觀看 Peter Marshall 關於 Orinoco 的這個演講。
Scavenge
Scavenge 是一種在新生代空間執行垃圾回收的演算法名稱。新生代是建立物件的地方。新生代被設計得很小,以便垃圾回收可以快速進行。
讓我們想象一個 Scavenge 場景
- 我們分配了
A、B、C和D。| A | B | C | D | <unallocated> | - 我們想分配
E - 空間不足,記憶體耗盡
- 然後,觸發一次(垃圾)回收
- 死物件被回收
- 存活物件將保留
- 假設
B和D已經死亡| A | C | <unallocated> | - 現在我們可以分配
E了| A | C | E | <unallocated> |
v8 會將經過兩次 Scavenge 操作後仍未被垃圾回收的物件晉升到老生代空間。
👉 完整的 Scavenge 場景
Mark-sweep
Mark-sweep 用於從老生代空間回收物件。老生代是那些從新生代中存活下來的物件所居住的地方。
該演算法由兩個階段組成
- 標記 (Mark):將仍然存活的物件標記為黑色,其他物件標記為白色。
- 清除 (Sweep):掃描白色物件並將它們轉換為空閒空間。
👉 事實上,標記和清除步驟要更復雜一些。請閱讀此文件以獲取更多細節。
--trace-gc 實戰
記憶體洩漏
現在,如果你快速回到之前的終端視窗:你會看到控制檯中有很多 Mark-sweep 事件。我們還看到,事件發生後回收的記憶體量微不足道。
現在我們是垃圾回收專家了!我們能推斷出什麼呢?
我們可能遇到了記憶體洩漏!但我們如何確定呢?(提醒:在這個例子中非常明顯,但在真實世界的應用程式中呢?)
但是我們如何找到上下文呢?
如何獲取不良分配的上下文
- 假設我們觀察到老生代空間在持續增長。
- 減少
--max-old-space-size,使得總堆記憶體更接近限制 - 執行程式直到記憶體溢位。
- 生成的日誌會顯示失敗的上下文。
- 如果發生記憶體溢位(OOM),將堆大小增加約10%,並重復幾次。如果觀察到相同的模式,則表明存在記憶體洩漏。
- 如果沒有記憶體溢位,則將堆大小固定在該值——一個緊湊的堆可以減少記憶體佔用和計算延遲。
例如,嘗試用以下命令執行 script.mjs
node --trace-gc --max-old-space-size=50 script.mjs
你應該會遇到記憶體溢位(OOM)
[...]
<--- Last few GCs --->
[40928:0x148008000] 509 ms: Mark-sweep 46.8 (65.8) -> 40.6 (77.3) MB, 6.4 / 0.0 ms (+ 1.4 ms in 11 steps since start of marking, biggest step 0.2 ms, walltime since start of marking 24 ms) (average mu = 0.977, current mu = 0.977) finalize incrementa[40928:0x148008000] 768 ms: Mark-sweep 56.3 (77.3) -> 47.1 (83.0) MB, 35.9 / 0.0 ms (average mu = 0.927, current mu = 0.861) allocation failure scavenge might not succeed
<--- JS stacktrace --->
FATAL ERROR: Reached heap limit Allocation failed - JavaScript heap out of memory [...]
現在,嘗試將其設定為 100MB
node --trace-gc --max-old-space-size=100 script.mjs
你應該會遇到類似的情況,唯一的區別是最後的 GC 追蹤日誌會顯示一個更大的堆大小。
<--- Last few GCs --->
[40977:0x128008000] 2066 ms: Mark-sweep (reduce) 99.6 (102.5) -> 99.6 (102.5) MB, 46.7 / 0.0 ms (+ 0.0 ms in 0 steps since start of marking, biggest step 0.0 ms, walltime since start of marking 47 ms) (average mu = 0.154, current mu = 0.155) allocati[40977:0x128008000] 2123 ms: Mark-sweep (reduce) 99.6 (102.5) -> 99.6 (102.5) MB, 47.7 / 0.0 ms (+ 0.0 ms in 0 steps since start of marking, biggest step 0.0 ms, walltime since start of marking 48 ms) (average mu = 0.165, current mu = 0.175) allocati
注意:在真實的應用程式中,在程式碼中找到洩漏的物件可能很麻煩。堆快照可以幫助你找到它。請訪問專門介紹堆快照的指南
執行緩慢
你如何判斷垃圾回收是否發生得太頻繁或導致了開銷?
- 檢視追蹤資料,特別是連續回收之間的時間間隔。
- 檢視追蹤資料,特別關注 GC 所花費的時間。
- 如果兩次 GC 之間的時間小於 GC 所花費的時間,那麼應用程式正嚴重受阻。
- 如果兩次 GC 之間的時間和 GC 所花費的時間都非常長,那麼應用程式可能可以使用更小的堆。
- 如果兩次 GC 之間的時間遠大於 GC 所花費的時間,那麼應用程式相對健康。
修復洩漏
現在讓我們來修復這個洩漏。我們可以用一個檔案來儲存我們的條目,而不是使用一個物件。
讓我們對指令碼做一點修改
// script-fix.mjs
import from 'node:fs/promises';
import from 'node:os';
let = 1_000_000;
const = `entries-${.()}`;
async function () {
const = {
: .(),
: .(),
: .(),
: .(),
};
await .(, .() + '\n');
}
async function () {
const = await .();
.(`File size ${.} bytes`);
}
// execution
(async () => {
await .(, '----START---\n');
while ( > 0) {
await ();
..(`~~> ${} entries to record\r`);
--;
}
await ();
})();
使用 Set 儲存資料本身並不是一種壞習慣;你只需要關注程式的記憶體佔用情況。
注意:你可以在 Node.js Diagnostics 倉庫中找到此練習的原始碼。
現在,讓我們執行這個指令碼。
node --trace-gc script-fix.mjs
你應該會觀察到兩件事
- Mark-sweep 事件出現的頻率降低了
- 記憶體佔用不超過 25MB,而第一個指令碼超過了 130MB。
這是很有道理的,因為新版本對記憶體的壓力比第一個版本小。
思考題:你覺得如何改進這個指令碼?你可能發現新版本的指令碼很慢。如果我們再次使用 Set,並且僅在記憶體達到特定大小時才將其內容寫入檔案,會怎麼樣?
getheapstatisticsAPI 可能會對你有幫助。
附加內容:以程式設計方式追蹤垃圾回收
使用 v8 模組
你可能不想在程序的整個生命週期內都獲取追蹤資訊。在這種情況下,可以在程序內部設定標誌。v8 模組提供了一個 API,可以動態設定標誌。
import from 'v8';
// enabling trace-gc
.('--trace-gc');
// disabling trace-gc
.('--notrace-gc');
使用效能鉤子
在 Node.js 中,你可以使用效能鉤子來追蹤垃圾回收。
const { } = ('node:perf_hooks');
// Create a performance observer
const = new ( => {
const = .()[0];
/*
The entry is an instance of PerformanceEntry containing
metrics of a single garbage collection event.
For example:
PerformanceEntry {
name: 'gc',
entryType: 'gc',
startTime: 2820.567669,
duration: 1.315709,
kind: 1
}
*/
});
// Subscribe to notifications of GCs
.({ : ['gc'] });
// Stop subscription
.();
使用效能鉤子檢查追蹤日誌
你可以透過 PerformanceObserver 中的回撥函式獲取 GC 統計資訊,其形式為 PerformanceEntry。
例如:
{
"name": "gc",
"entryType": "gc",
"startTime": 2820.567669,
"duration": 1.315709,
"kind": 1
}
| 屬性 | 解釋 |
|---|---|
| name | 效能條目的名稱。 |
| entryType | 效能條目的型別。 |
| startTime | 標記效能條目開始時間的高解析度毫秒時間戳。 |
| duration | 此條目經過的總毫秒數。 |
| kind | 發生的垃圾回收操作的型別。 |
| flags | 關於 GC 的附加資訊。 |
更多資訊,你可以參考關於效能鉤子的文件。