垃圾回收追蹤

本指南將介紹垃圾回收追蹤的基礎知識。

讀完本指南後,你將能夠

  • 在你的 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
0x110008000Isolate(JS 堆例項)
44 ms自程序啟動以來的時間(毫秒)
ScavengeGC 的型別/階段
2.4GC 前已使用的堆記憶體(MB)
(3.2)GC 前的總堆記憶體(MB)
2.0GC 後已使用的堆記憶體(MB)
(4.2)GC 後的總堆記憶體(MB)
0.5 / 0.0 ms (average mu = 1.000, current mu = 1.000)GC 耗時(毫秒)
allocation failureGC 的原因

我們這裡只關注兩個事件

  • Scavenge
  • Mark-sweep

堆被劃分為多個*空間*。其中包括一個名為“新生代”(new space)的空間和另一個名為“老生代”(old space)的空間。

👉 實際上,堆的結構要複雜一些,但本文中我們將使用一個簡化版本。如果你想了解更多細節,我們鼓勵你觀看 Peter Marshall 關於 Orinoco 的這個演講

Scavenge

Scavenge 是一種在新生代空間執行垃圾回收的演算法名稱。新生代是建立物件的地方。新生代被設計得很小,以便垃圾回收可以快速進行。

讓我們想象一個 Scavenge 場景

  • 我們分配了 ABCD
    | A | B | C | D | <unallocated> |
    
  • 我們想分配 E
  • 空間不足,記憶體耗盡
  • 然後,觸發一次(垃圾)回收
  • 死物件被回收
  • 存活物件將保留
  • 假設 BD 已經死亡
    | A | C | <unallocated> |
    
  • 現在我們可以分配 E
    | A | C | E | <unallocated> |
    

v8 會將經過兩次 Scavenge 操作後仍未被垃圾回收的物件晉升到老生代空間。

👉 完整的 Scavenge 場景

Mark-sweep

Mark-sweep 用於從老生代空間回收物件。老生代是那些從新生代中存活下來的物件所居住的地方。

該演算法由兩個階段組成

  • 標記 (Mark):將仍然存活的物件標記為黑色,其他物件標記為白色。
  • 清除 (Sweep):掃描白色物件並將它們轉換為空閒空間。

👉 事實上,標記和清除步驟要更復雜一些。請閱讀此文件以獲取更多細節。

--trace-gc 實戰

記憶體洩漏

現在,如果你快速回到之前的終端視窗:你會看到控制檯中有很多 Mark-sweep 事件。我們還看到,事件發生後回收的記憶體量微不足道。

現在我們是垃圾回收專家了!我們能推斷出什麼呢?

我們可能遇到了記憶體洩漏!但我們如何確定呢?(提醒:在這個例子中非常明顯,但在真實世界的應用程式中呢?)

但是我們如何找到上下文呢?

如何獲取不良分配的上下文

  1. 假設我們觀察到老生代空間在持續增長。
  2. 減少 --max-old-space-size,使得總堆記憶體更接近限制
  3. 執行程式直到記憶體溢位。
  4. 生成的日誌會顯示失敗的上下文。
  5. 如果發生記憶體溢位(OOM),將堆大小增加約10%,並重復幾次。如果觀察到相同的模式,則表明存在記憶體洩漏。
  6. 如果沒有記憶體溢位,則將堆大小固定在該值——一個緊湊的堆可以減少記憶體佔用和計算延遲。

例如,嘗試用以下命令執行 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

注意:在真實的應用程式中,在程式碼中找到洩漏的物件可能很麻煩。堆快照可以幫助你找到它。請訪問專門介紹堆快照的指南

執行緩慢

你如何判斷垃圾回收是否發生得太頻繁或導致了開銷?

  1. 檢視追蹤資料,特別是連續回收之間的時間間隔。
  2. 檢視追蹤資料,特別關注 GC 所花費的時間。
  3. 如果兩次 GC 之間的時間小於 GC 所花費的時間,那麼應用程式正嚴重受阻。
  4. 如果兩次 GC 之間的時間和 GC 所花費的時間都非常長,那麼應用程式可能可以使用更小的堆。
  5. 如果兩次 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,並且僅在記憶體達到特定大小時才將其內容寫入檔案,會怎麼樣?

getheapstatistics API 可能會對你有幫助。

附加內容:以程式設計方式追蹤垃圾回收

使用 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 的附加資訊。

更多資訊,你可以參考關於效能鉤子的文件