NodeJS V8引擎的記憶體和垃圾回收器(GC)

子慕大詩人發表於2023-03-29

一、為什麼需要GC

程式應用執行需要使用記憶體,其中記憶體的兩個分割槽是我們常常會討論的概念:棧區和堆區。

棧區是線性的佇列,隨著函式執行結束自動釋放的,而堆區是自由的動態記憶體空間、堆記憶體是手動分配釋放或者 垃圾回收程式(Garbage Collection,後文都簡稱GC)自動分配釋放的。

軟體發展早期或者一些語言對於堆記憶體都是手動操作分配和釋放,比如 CC++。雖然能精準操作記憶體,達到儘可能的最優記憶體使用,但是開發效率卻非常低,也容易出現記憶體操作不當。

隨著技術發展,高階語言(例如Java Node)都不需要開發者手動操作記憶體,程式語言自動會分配和釋放空間。同時也誕生了 GC(Garbage Collection)垃圾回收器,幫助釋放和整理記憶體。開發者大部分情況不需要關心記憶體本身,可以專注業務開發。後文主要是討論堆記憶體和 GC

二、GC發展

GC執行會消耗CPU資源,GC執行的過程會觸發STW(stop-the-world)暫停業務程式碼執行緒,為什麼會 STW 呢?是為了保證在 GC 的過程中,不會和新建立的物件起衝突。

GC主要是伴隨記憶體大小增加而發展演化。大致分為3個大的代表性階段:

  • 階段一 單執行緒GC(代表:serial)

單執行緒GC,在它進行垃圾收集時,必須完全暫停其他所有的工作執行緒 ,它是最初階段的GC,效能也是最差的

  • 階段二 並行多執行緒GC(代表:Parallel Scavenge, ParNew)

在多 CPU 環境中利用多條 GC 執行緒同時並行執行,從而垃圾回收的時間減少、使用者執行緒停頓的時間也減少,這個演算法也會STW,完全暫停其他所有的工作執行緒

  • 階段三 多執行緒併發 concurrent GC(代表:CMS (Concurrent Mark Sweep) G1)

這裡的併發是指:GC多執行緒執行可以和業務程式碼併發執行。

在前面的兩個發展階段的 GC 演算法都會完全 STW,而在 concurrent GC 中,有部分階段 GC 執行緒可以和業務程式碼併發執行,保證了更短的 STW 時間。但是這個模式就會存在標記錯誤,因為 GC 過程中可能有新物件進來,當然演算法本身會修正和解決這個問題

上面的三個階段只是並不代表 GC 一定是上面描述三種的其中一種。不同程式語言的 GC 根據不同需求採用多種演算法組合實現。

三、v8 記憶體分割槽與GC

堆記憶體設計與GC設計是緊密相關的。V8 把堆記憶體分為幾大區域,採用分代策略。

盜圖:

image.png

  • 新生代(new-space 或 young-generation):空間小,分為了兩個半空間(semi-space),其中的資料存活期短。
  • 老生代(old-space 或 old-generation):空間大,可增量,其中的資料存活期長
  • 大物件空間(large-object-space):預設超過256K的物件會在此空間下,下文解釋
  • 程式碼空間(code-space):即時編譯器(JIT)在這裡儲存已編譯的程式碼
  • 元空間 (cell space):這個空間用於儲存小的、固定大小的JavaScript物件,比如數字和布林值。
  • 屬性元空間 (property cell space):這個空間用於儲存特殊的JavaScript物件,比如訪問器屬性和某些內部物件。
  • Map Space:這個空間用於儲存用於JavaScript物件的元資訊和其他內部資料結構,比如Map和Set物件。

3.1 分代策略:新生代和老生代

新老生代.png

在 Node.js 中,GC 採用分代策略,分為新、老生代區,記憶體資料大都在這兩個區域。

3.1.1 新生代

新生代是一個小的、儲存年齡小的物件、快速的記憶體池,分為了兩個半空間(semi-space),一半的空間是空閒的(稱為to空間),另一半的空間是儲存了資料(稱為from空間)。

當物件首次建立時,它們被分配到新生代 from 半空間中,它的年齡為1。當 from 空間不足或者超過一定大小數量之後,會觸發 Minor GC(採用複製演算法 Scavenge),此時,GC 會暫停應用程式的執行(STW,stop-the-world),標記(from空間)中所有活動物件,然後將它們整理連續移動到新生代的另一個空閒空間(to空間)中。最後原本的 from 空間的記憶體會被全部釋放而變成空閒空間,兩個空間就完成 fromto 的對換,複製演算法是犧牲了空間換取時間的演算法。

新生代的空間更小,所以此空間會更頻繁的觸發 GC。同時掃描的空間更小,GC效能消耗也更小、它的 GC 執行時間也更短。

每當一次 Minor GC 完成存活的物件年齡就+1,經歷過多次Minor GC還存活的物件(年齡大於N),它們將被移動到老生代記憶體池中。

3.1.2 老生代

老生代是一個大的記憶體池,用於儲存較長壽命的物件。老生代記憶體採用 標記清除(Mark-Sweep)標記壓縮演算法(Mark-Compact)。它的一次執行叫做 Mayor GC。當老生代中的物件佔滿一定比例時,即存活物件與總物件的比例超過一定的閾值,就會觸發一次 標記清除標記壓縮

因為它的空間更大,它的GC執行時間也更長,頻率相對新生代更低。如果老生代完成 GC 回收之後空間還是不足,V8 就會從系統中申請更多記憶體。

可以手動執行 global.gc() 方法,設定不同引數,主動觸發GC。
但是需要注意的是,預設情況下,Node.js 是禁用了global.gc()。如果要啟用,可以透過啟動 Node.js 應用程式時新增 --expose-gc 引數來開啟,例如:

node --expose-gc app.js

V8 在老生代中主要採用了 Mark-SweepMark-Compact 相結合的方式進行垃圾回收。

Mark-Sweep 是標記清除的意思,它分為兩個階段,標記和清除。Mark-Sweep 在標記階段遍歷堆中的所有物件,並標記活著的物件,在隨後的清除階段中,只清除未被標記的物件。

Mark-Sweep 最大的問題是在進行一次標記清除回收後,記憶體空間會出現不連續的狀態。這種記憶體碎片會對後續的記憶體分配造成問題,因為很可能出現需要分配一個大物件的情況,這時所有的碎片空間都無法完成此次分配,就會提前觸發垃圾回收,而這次回收是不必要的。

為了解決 Mark-Sweep 的記憶體碎片問題,Mark-Compact 被提出來。Mark-Compact 是標記整理的意思,是在 Mark-Sweep 的基礎上演進而來的。它們的差別在於物件在標記為死亡後,在整理過程中,將活著的物件往一端移動,移動完成後,直接清理掉邊界外的記憶體。V8 也會根據一定邏輯,釋放一定空閒的記憶體還給系統。

3.2 大物件空間 large object space

大物件會直接在大物件空間建立,並且不會移動到其它空間。那麼到底多大的物件會直接在大物件空間建立,而不是在新生代 from 區中建立呢?查閱資料和原始碼終於找到了答案。預設情況下是 256KV8 似乎並沒有暴露修改命令,原始碼中的 v8_enable_hugepage 配置應該是打包的時候設定的。

https://chromium.googlesource.com/v8/v8.git/+/5.1.281.35/src/heap/spaces.h

 // There is a separate large object space for objects larger than
 // Page::kMaxRegularHeapObjectSize, so that they do not have to move during
 // collection. The large object space is paged. Pages in large object space
 // may be larger than the page size.

https://source.chromium.org/chromium/chromium/src/+/main:v8/src/common/globals.h;l=538;drc=cb95e30fa939a18bc0845b57b0946a102b86cf9d?q=kmaxregular&ss=chromium%2Fchromium%2Fsrc

1.png

image.png

(1 << (18 - 1)) 的結果 256K
(1 << (19 - 1)) 的結果 256K
(1 << (21 - 1)) 的結果 1M(如果開啟了hugPage)

四、V8 新老分割槽大小

4.1 老生代分割槽大小

在v12.x 之前:

為了保證 GC 的執行時間保持在一定範圍內,V8 限制了最大記憶體空間,設定了一個預設老生代記憶體最大值,64位系統中為大約1.4G,32位為大約700M,超出會導致應用崩潰。

如果想加大記憶體,可以使用 --max-old-space-size 設定最大記憶體(單位:MB)

node --max_old_space_size=

在v12以後:

V8 將根據可用記憶體分配老生代大小,也可以說是堆記憶體大小,所以並沒有限制堆記憶體大小。以前的限制邏輯,其實不合理,限制了 V8 的能力,總不能因為 GC 過程消耗的時間更長,就不讓我繼續執行程式吧,後續的版本也對 GC 做了更多最佳化,記憶體越來越大也是發展需要。

如果想要做限制,依然可以使用 --max-old-space-size 配置, v12 以後它的預設值是0,代表不限制。

參考文件:
https://nodejs.medium.com/introducing-node-js-12-76c41a1b3f3f

4.2 新生代分割槽大小

新生代中的一個 semi-space 大小 64位系統的預設值是16M,32位系統是8M,因為有2個 semi-space,所以總大小是32M、16M。

--max-semi-space-size

--max-semi-space-size 設定新生代 semi-space 最大值,單位為MB。

此空間不是越大越好,空間越大掃描的時間就越長。這個分割槽大部分情況下是不需要做修改的,除非針對具體的業務場景做最佳化,謹慎使用。

--max-new-space-size

--max-new-space-size 設定新生代空間最大值,單位為KB(不存在)

有很多文章說到此功能,我翻了下 nodejs.org 網頁中 v4 v6 v7 v8 v10的文件都沒有看到有這個配置,使用 node --v8-options 也沒有查到,也許以前的某些老版本有,而現在都應該使用 --max-semi-space-size

五、 記憶體分析相關API

5.1 v8.getHeapStatistics()

執行 v8.getHeapStatistics(),檢視 v8 堆記憶體資訊,查詢最大堆記憶體 heap_size_limit,當然這裡包含了新、老生代、大物件空間等。我的電腦硬體記憶體是 8G,Node版本16x,檢視到 heap_size_limit 是4G。

{
  total_heap_size: 6799360,
  total_heap_size_executable: 524288,
  total_physical_size: 5523584,
  total_available_size: 4340165392,
  used_heap_size: 4877928,
  heap_size_limit: 4345298944,
  malloced_memory: 254120,
  peak_malloced_memory: 585824,
  does_zap_garbage: 0,
  number_of_native_contexts: 2,
  number_of_detached_contexts: 0
}

k8s 容器中查詢 NodeJs 應用,分別檢視了v12 v14 v16版本,如下表。看起來是本身系統當前的最大記憶體的一半。128M 的時候,為啥是 256M,因為容器中還有交換記憶體,容器記憶體實際最大記憶體限制是記憶體限制值 x2,有同等的交換記憶體。

所以結論是大部分情況下 heap_size_limit 的預設值是系統記憶體的一半。但是如果超過這個值且系統空間足夠,V8 還是會申請更多空間。當然這個結論也不是一個最準確的結論。而且隨著記憶體使用的增多,如果系統記憶體還足夠,這裡的最大記憶體還會增長。

容器最大記憶體 heap_size_limit
4G 2G
2G 1G
1G 0.5G
1.5G 0.7G
256M 256M
128M 256M

5.2 process.memoryUsage

process.memoryUsage()
{
  rss: 35438592,
  heapTotal: 6799360,
  heapUsed: 4892976,
  external: 939130,
  arrayBuffers: 11170
}

透過它可以檢視當前程式的記憶體佔用和使用情況 heapTotalheapUsed,可以定時獲取此介面,然後繪畫出折線圖幫助分析記憶體佔用情況。以下是 Easy-Monitor 提供的功能:

image.png

建議本地開發環境使用,開啟後,嘗試大量請求,會看到記憶體曲線增長,到請求結束之後,GC觸發後會看到記憶體曲線下降,然後再嘗試多次傳送大量請求,這樣往復下來,如果發現記憶體一直在增長低谷值越來越高,就可能是發生了記憶體洩漏。

5.3 開啟列印GC事件

使用方法

node --trace_gc app.js
// 或者
v8.setFlagsFromString('--trace_gc');
  • --trace_gc
[40807:0x148008000]   235490 ms: Scavenge 247.5 (259.5) -> 244.7 (260.0) MB, 0.8 / 0.0 ms  (average mu = 0.971, current mu = 0.908) task 
[40807:0x148008000]   235521 ms: Scavenge 248.2 (260.0) -> 245.2 (268.0) MB, 1.2 / 0.0 ms  (average mu = 0.971, current mu = 0.908) allocation failure 
[40807:0x148008000]   235616 ms: Scavenge 251.5 (268.0) -> 245.9 (268.8) MB, 1.9 / 0.0 ms  (average mu = 0.971, current mu = 0.908) task 
[40807:0x148008000]   235681 ms: Mark-sweep 249.7 (268.8) -> 232.4 (268.0) MB, 7.1 / 0.0 ms  (+ 46.7 ms in 170 steps since start of marking, biggest step 4.2 ms, walltime since start of marking 159 ms) (average mu = 1.000, current mu = 1.000) finalize incremental marking via task GC in old space requested
GCType <heapUsed before> (<heapTotal before>) -> <heapUsed after> (<heapTotal after>) MB

上面的 ScavengeMark-sweep 代表GC型別,Scavenge 是新生代中的清除事件,Mark-sweep 是老生代中的標記清除事件。箭頭符號前是事件發生前的實際使用記憶體大小,箭頭符號後是事件結束後的實際使用記憶體大小,括號內是記憶體空間總值。可以看到新生代中事件發生的頻率很高,而後觸發的老生代事件會釋放總記憶體空間。

  • --trace_gc_verbose

展示堆空間的詳細情況

v8.setFlagsFromString('--trace_gc_verbose');

[44729:0x130008000] Fast promotion mode: false survival rate: 19%
[44729:0x130008000]    97120 ms: [HeapController] factor 1.1 based on mu=0.970, speed_ratio=1000 (gc=433889, mutator=434)
[44729:0x130008000]    97120 ms: [HeapController] Limit: old size: 296701 KB, new limit: 342482 KB (1.1)
[44729:0x130008000]    97120 ms: [GlobalMemoryController] Limit: old size: 296701 KB, new limit: 342482 KB (1.1)
[44729:0x130008000]    97120 ms: Scavenge 302.3 (329.9) -> 290.2 (330.4) MB, 8.4 / 0.0 ms  (average mu = 0.998, current mu = 0.999) task 
[44729:0x130008000] Memory allocator,       used: 338288 KB, available: 3905168 KB
[44729:0x130008000] Read-only space,        used:    166 KB, available:      0 KB, committed:    176 KB
[44729:0x130008000] New space,              used:    444 KB, available:  15666 KB, committed:  32768 KB
[44729:0x130008000] New large object space, used:      0 KB, available:  16110 KB, committed:      0 KB
[44729:0x130008000] Old space,              used: 253556 KB, available:   1129 KB, committed: 259232 KB
[44729:0x130008000] Code space,             used:  10376 KB, available:    119 KB, committed:  12944 KB
[44729:0x130008000] Map space,              used:   2780 KB, available:      0 KB, committed:   2832 KB
[44729:0x130008000] Large object space,     used:  29987 KB, available:      0 KB, committed:  30336 KB
[44729:0x130008000] Code large object space,     used:      0 KB, available:      0 KB, committed:      0 KB
[44729:0x130008000] All spaces,             used: 297312 KB, available: 3938193 KB, committed: 338288 KB
[44729:0x130008000] Unmapper buffering 0 chunks of committed:      0 KB
[44729:0x130008000] External memory reported:  20440 KB
[44729:0x130008000] Backing store memory:  22084 KB
[44729:0x130008000] External memory global 0 KB
[44729:0x130008000] Total time spent in GC  : 199.1 ms
  • --trace_gc_nvp

每次GC事件的詳細資訊,GC型別,各種時間消耗,記憶體變化等

v8.setFlagsFromString('--trace_gc_nvp');

[45469:0x150008000]  8918123 ms: pause=0.4 mutator=83.3 gc=s reduce_memory=0 time_to_safepoint=0.00 heap.prologue=0.00 heap.epilogue=0.00 heap.epilogue.reduce_new_space=0.00 heap.external.prologue=0.00 heap.external.epilogue=0.00 heap.external_weak_global_handles=0.00 fast_promote=0.00 complete.sweep_array_buffers=0.00 scavenge=0.38 scavenge.free_remembered_set=0.00 scavenge.roots=0.00 scavenge.weak=0.00 scavenge.weak_global_handles.identify=0.00 scavenge.weak_global_handles.process=0.00 scavenge.parallel=0.08 scavenge.update_refs=0.00 scavenge.sweep_array_buffers=0.00 background.scavenge.parallel=0.00 background.unmapper=0.04 unmapper=0.00 incremental.steps_count=0 incremental.steps_took=0.0 scavenge_throughput=1752382 total_size_before=261011920 total_size_after=260180920 holes_size_before=838480 holes_size_after=838480 allocated=831000 promoted=0 semi_space_copied=4136 nodes_died_in_new=0 nodes_copied_in_new=0 nodes_promoted=0 promotion_ratio=0.0% average_survival_ratio=0.5% promotion_rate=0.0% semi_space_copy_rate=0.5% new_space_allocation_throughput=887.4 unmapper_chunks=124
[45469:0x150008000]  8918234 ms: pause=0.6 mutator=110.9 gc=s reduce_memory=0 time_to_safepoint=0.00 heap.prologue=0.00 heap.epilogue=0.00 heap.epilogue.reduce_new_space=0.04 heap.external.prologue=0.00 heap.external.epilogue=0.00 heap.external_weak_global_handles=0.00 fast_promote=0.00 complete.sweep_array_buffers=0.00 scavenge=0.50 scavenge.free_remembered_set=0.00 scavenge.roots=0.08 scavenge.weak=0.00 scavenge.weak_global_handles.identify=0.00 scavenge.weak_global_handles.process=0.00 scavenge.parallel=0.08 scavenge.update_refs=0.00 scavenge.sweep_array_buffers=0.00 background.scavenge.parallel=0.00 background.unmapper=0.04 unmapper=0.00 incremental.steps_count=0 incremental.steps_took=0.0 scavenge_throughput=1766409 total_size_before=261207856 total_size_after=260209776 holes_size_before=838480 holes_size_after=838480 allocated=1026936 promoted=0 semi_space_copied=3008 nodes_died_in_new=0 nodes_copied_in_new=0 nodes_promoted=0 promotion_ratio=0.0% average_survival_ratio=0.5% promotion_rate=0.0% semi_space_copy_rate=0.3% new_space_allocation_throughput=888.1 unmapper_chunks=124

5.4 記憶體快照

const { writeHeapSnapshot } = require('node:v8');
v8.writeHeapSnapshot()

列印快照,將會STW,服務停止響應,記憶體佔用越大,時間越長。此方法本身就比較費時間,所以生成的過程預期不要太高,耐心等待。

注意:生成記憶體快照的過程,會STW(程式將暫停)幾乎無任何響應,如果容器使用了健康檢測,這時無法響應的話,容器可能被重啟,導致無法獲取快照,如果需要生成快照、建議先關閉健康檢測。

相容性問題:此 API arm64 架構不支援,執行就會卡住程式 生成空快照檔案 再無響應,
如果使用庫 heapdump,會直接報錯:

(mach-o file, but is an incompatible architecture (have (arm64), need (x86_64))

API 會生成一個 .heapsnapshot 字尾快照檔案,可以使用 Chrome 偵錯程式的“記憶體”功能,匯入快照檔案,檢視堆記憶體具體的物件數和大小,以及到GC根結點的距離等。也可以對比兩個不同時間快照檔案的區別,可以看到它們之間的資料量變化。

六、利用記憶體快照分析記憶體洩漏

一個 Node 應用因為記憶體超過容器限制經常發生重啟,透過容器監控後臺看到應用記憶體的曲線是一直上升的,那應該是發生了記憶體洩漏。

使用 Chrome 偵錯程式對比了不同時間的快照。發現物件增量最多的是閉包函式,繼而展開檢視整個列表,發現資料量較多的是 mongo 文件物件,其實就是閉包函式內的資料沒有被釋放,再透過檢視 Object 列表,發現同樣很多物件,最外層的詳情顯示的是 MongooseConnection 物件。

image.png

image.png

到此為止,已經大概定位到一個類的 mongo 資料儲存邏輯附近有記憶體洩漏。

再看到 Timeout 物件也比較多,從 GC 根節點距離來看,這些物件距離非常深。點開詳情,看到這一層層的巢狀就定位到了程式碼中準確的位置。因為那個類中有個定時任務使用 setInterval 定時器去分批處理一些不緊急任務,當一個 setInterval 把事情做完之後就會被 clearInterval 清除。

image.png
image.png

洩漏解決和最佳化

透過程式碼邏輯分析,最終找到了問題所在,是 clearInterval 的觸發條件有問題,導致定時器沒有被清除一直迴圈下去。定時器一直執行,這段程式碼和其中的資料還在閉包之中,無法被 GC 回收,所以記憶體會越來越大直至達到上限崩潰。

這裡使用 setInterval 的方式並不合理,順便改成了利用 for await 佇列順序執行,從而達到避免同時間大量併發的效果,程式碼也要清晰許多。由於這塊程式碼比較久遠,就不考慮為啥當初使用 setInterval 了。

釋出新版本之後,觀察了十多天,記憶體平均保持在100M出頭,GC 正常回收臨時增長的記憶體,呈現為波浪曲線,沒有再出現洩漏。

image.png

至此利用記憶體快照,分析並解決了記憶體洩漏。當然實際分析的時候要曲折一點,這個記憶體快照的內容並不好理解、並不那麼直接。快照資料的展示是型別聚合的,需要透過看不同的建構函式,以及內部的資料詳情,結合自己的程式碼綜合分析,才能找到一些線索。
比如從當時我得到的記憶體快照看,有大量資料是 閉包、string、mongo model類、Timeout、Object等,其實這些增量的資料都是來自於那段有問題的程式碼,並且無法被 GC 回收。

六、 最後

不同的語言 GC 實現都不一樣,比如 JavaGo

Java:瞭解 JVM (對應Node V8)的知道,Java 也採用分代策略,它的新生代中還存在一個 eden 區,新生的物件都在這個區域建立。而 V8 新生代沒有 eden 區。

Go:採用標記清除,三色標記演算法

不同的語言的 GC 實現不同,但是本質上都是採用不同演算法組合實現。在效能上,不同的組合,帶來的各方面效能效率不一樣,但都是此消彼長,只是偏向不同的應用場景而已。

相關文章