理解 Node.js 的 GC 機制

《深入淺出Node.js》第五章《內存控制》閱讀筆記

隨着 Node 的發展,JavaScript 的應用場景早已不再侷限在瀏覽器中。本文不討論網頁應用、命令行工具等短時間執行,且隻影響終端用戶的場景。由於運行時間短,隨着進程的退出,內存會釋放,幾乎沒有內存管理的必要。但隨着 Node 在服務端的廣泛應用,JavaScript 的內存管理需要引起我們的重視。

V8 的內存限制

在一般的後端開發語言中,在基本的內存使用上沒有什麼限制,然而在 Node 中通過 JavaScript 使用內存時就會發現只能使用部分內存(64位系統下約爲1.4GB,32位系統下約爲0.7GB)。在這樣的限制下,將會導致 Node 無法直接操作大內存對象。

造成這個問題的主要原因在於 Node 的 JavaScript 執行引擎 V8。

在 V8 中,所有的 JavaScript 對象都是通過堆來進行分配的。Node 提供了 V8 中內存的使用量查看方法 process.memoryUsage()

  • heapTotal 已申請到的堆內存
  • heapUsed 當前使用的堆內存

爲什麼 V8 要限制堆的大小:

  1. V8 爲瀏覽器而設計,不太可能遇到用大量內存的場景
  2. V8 的垃圾回收機制的限制。(按官方的說法,以1.5GB的垃圾回收堆內存爲例,V8做一次小的垃圾回收需要50ms以上,做一次非增量式的垃圾回收需要1s以上)

V8提供了選項讓我們可以控制使用內存的大小

  • node --max-old-space-size=1700 test.js 設置老生代內存空間最大值,單位爲MB
  • node --max-new-space-size=1024 test.js 設置新生代內存空間最大值,單位爲KB

比較遺憾的是,這兩個最大值需要在啓動時執行。這意味着 V8 使用的內存沒辦法根據使用的情況自動擴充,當內存分配過程中超過極限值時,就會引起進程出錯。

V8 的垃圾回收機制

V8 的垃圾回收策略主要基於分代式垃圾回收機制。在 V8 中,主要將內存分爲新生代和老生代兩代。新生代中的對象爲存活時間較短的對象,老生代中的對象爲存活時間較長或常駐內存的對象。

V8 堆的整體大小就是新生代的內存空間加上老生代的內存空間

Scavenge 算法

在分代的基礎上,新生代中的對象主要通過 Scavenge 算法進行垃圾回收。在 Scavenge 的具體實現中,主要採用了 Cheney 算法。

Cheney 算法是一種採用複製的方式實現的垃圾回收算法。它將堆內存一分爲二,每一部分空間成爲 semispace。在這兩個 semispace 空間中,只有一個處於使用中,另一個處於閒置中。處於使用中的 semispace 空間成爲 From 空間,處於閒置狀態的空間成爲 To 空間。當我們分配對象時,先是在 From 空間中進行分配。當開始進行垃圾回收時,會檢查 From 空間中的存活對象,這些存活對象將被複制到 To 空間中,而非存活對象佔用的空間將被釋放。完成複製後, From 空間和 To 空間的角色發生對換。

Scavenge 的缺點是隻能使用堆內存的一半,但 Scavenge 由於只複製存活的對象,並且對於生命週期短的場景存活對象只佔少部分,所以它在時間效率上表現優異。Scavenge 是典型的犧牲空間換取時間的算法,無法大規模地應用到所有的垃圾回收中,但非常適合應用在新生代中。

晉升

對象從新生代中移動到老生代中的過程稱爲晉升。

From 空間中的存活對象在複製到 To 空間之前需要進行檢查,在一定條件下,需要將存活週期長的對象移動到老生代中,也就是完成對象的晉升。

晉升條件主要有兩個:

  1. 對象是否經歷過一次 Scavenge 回收
  2. To 空間已經使用超過 25%

設置 25% 這個限制值得原因是當這次 Scavenge 回收完成後,這個 To 空間將變成 From 空間,接下來的內存分配將在這個空間中進行,如果佔比過高,會影響後續的內存分配。

Mark-Sweep & Mark-Compact

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

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

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

爲了解決 Mark-Sweep 的內存碎片問題,Mark-Compact 被提出來。Mark-Compact是標記整理的意思,是在 Mark-Sweep 的基礎上演進而來的。它們的差別在於對象在標記爲死亡後,在整理過程中,將活着的對象往一端移動,移動完成後,直接清理掉邊界外的內存。

下表爲3種主要垃圾回收算法的簡單比較

從表中可以看出,在 Mark-Sweep 和 Mark-Compact 之間,由於 Mark-Compact 需要移動對象,所以它的執行速度不可能很快,所以在取捨上,V8 主要使用 Mark-Sweep,在空間不足以從新生代中晉升過來的對象進行分配時才使用 Mark-Compact 。

Incremental Marking

爲了避免出現 JavaScript 應用邏輯與垃圾回收器看到的不一致的情況,垃圾回收的3種算法都需要將應用邏輯暫停下來,這種行爲稱爲“全停頓” (stop-the-world)。

由於新生代配置的空間較小,存活對象較少,全停頓對新生代影響不大。但老生代通常配置的空間較大,且存活對象較多,全堆垃圾回收(full 垃圾回收)的標記、清除、整理等動作造成的停頓就會比較可怕。

爲了降低全堆垃圾回收帶來的停頓時間,V8 先從標記階段入手,將原本要一口氣停頓完成的動作改成增量標記(Incremental Marking),也就是拆分爲許多小“步進”,每做完一“步進”就讓JavaScript應用邏輯執行一小會兒,垃圾回收和應用邏輯交替執行直到標記階段完成。

V8 在經過增量標記的改進後,垃圾回收的最大停頓時間可以減少到原本的 1/6 左右。

查看GC日誌

查看垃圾回收日誌的方式主要是在啓動時添加 --trace_gc 參數。

小結

  1. Node 的 JavaScript 執行引擎爲 V8,內存使用和控制也受限於 V8。
  2. V8 把內存分爲新生代和老生代,分別存放存活時間較短和存活時間較長或常駐內存的對象。
  3. 在新生代中使用 Scavenge 算法進行垃圾回收,優點是速度快無內存碎片,缺點是佔用雙倍內存空間。
  4. 在老生代中將 Mark-Sweep 和 Mark-Compact 兩種算法結合使用,主要使用 Mark-Sweep,優點的是無需移動對象,缺點是產生內存碎片。Mark-Compact 是對 Mark-Sweep 的補充,在空間不足以對新晉升的對象進行分配時整理內存,清除內存碎片,由於要移動對象,速度較慢。
  5. V8 使用 Incremental Marking 來減少全停頓帶來的影響。
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章