《深入淺出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 來減少全停頓帶來的影響。