V8 記憶體分配與垃圾回收

發表於2017-08-25

V8 將記憶體空間主要分為:新生代 和 老生代 兩種 。

1、新生代空間

新生代空間中的物件為存活時間較短的物件,大多數的物件被分配在這裡,這個區域很小但是垃圾回特別頻繁 。

它將堆記憶體一分為二,每一部分空間稱為 semispace,其中一個處於使用狀態(from 空間),另一個處於閒置狀態(to 空間)

對於新產生的物件,將從 from 空間中分配記憶體 。

新生代分配記憶體非常容易,我們只需要儲存一個指向記憶體區的指標,不斷根據新物件的大小進行遞增即可。當該指標到達了新生代記憶體區的末尾,就會觸發一次垃圾回收。

新生代的垃圾回收採用 Scavenge 演算法 ,其工作原理如下:

首先檢查 from 空間,將存活物件複製到 to 空間,非存活物件將會被釋放。完成複製後,from 空間和 to 空間角色發生轉換。新產生的物件始終從 from 空間中分配記憶體,to 空間則處於閒置狀態。當再次進行垃圾回收時,也會執行和第一次同樣的操作,如果存在以下兩種情況,存活物件就會被複制到老生代空間中,這個過程稱為物件晉升

  • 存活物件已經經歷過一次 Scavenge 回收 。
  • to 空間記憶體佔用比例超過 25% (保證下次新物件有足夠的空間可分配)

2、老生代空間

老生代空間中的物件為存活時間長或常駐記憶體物件,大多數從新生代晉升的物件會被移動到這裡。

老生代佔用記憶體較多,如果使用 Scavenge演算法,不僅會浪費一半空間,複製如此大塊的記憶體消耗時間將會很長,所以 Scavenge 演算法顯然不適合。

V8 對於老生代中的垃圾回收,採用 Mark-Sweep (標記清除) 和 Mark-Compact(標記整理) 相結合 。

(1) Mark-Sweep

Mark-Sweep 分為 標記 和 清除 兩個階段 。

在標記階段需要遍歷堆中的所有物件,並標記那些活著的物件,然後進入清除階段。在清除階段,只清除沒有被標記的物件。由於標記清除只清除死亡物件,而死亡物件在老生代中佔用的比例很小,所以效率較高。

標記清除存在的問題是,進行一次標記清除後,記憶體空間往往是不連續的,會出現很多的記憶體碎片。如果後續需要分配一個需要記憶體空間較多的物件時,如果所有的記憶體碎片都不夠用,將會使得V8無法完成這次分配,提前觸發垃圾回收。

(2) Mark-Compact

標記整理正是為了解決標記清除所帶來的記憶體碎片的問題。標記整理在標記清除的基礎進行修改,將其的清除階段變為緊縮極端。在整理的過程中,將活著的物件向記憶體區的一段移動,移動完成後直接清理掉邊界外的記憶體。緊縮過程涉及物件的移動,所以效率並不是太好,但是能保證不會生成記憶體碎片。

3、三種回收策略比較

從圖中可以看出,在 Mark-Sweep 和 Mark-Compact 之間,由於 Mark-Compact 需要移動物件,所以它的執行速度最慢。

所以在取捨上,V8 主要使用 Mark-Sweep,在空間不足以對新生代中晉升過來的物件進行分配時才使用 Mark-Compact 。

4、垃圾回收引起的效能問題

為了避免出現 JavaScript 應用邏輯 與 垃圾回收操作 產生不一致的衝突,垃圾回收的三種基本演算法都需要將應用邏輯暫停下來,待垃圾回收完成後,再恢復執行應用邏輯,這種行為被稱為全停頓 。

按官方說法,以 1.5G 的垃圾回收堆記憶體為例,V8 做一次小的垃圾回收需要 50ms 以上,做一次非增量式垃圾回收甚至需要 1s 以上。這是垃圾回收中引起的 JavaScript 執行緒暫停執行時間,在這樣的時間花銷下,應用效能和響應能力都會直線下降。

在 V8 的分代式垃圾回收中,一次小垃圾回收只收集新生代,由於新生代預設配置的較小,且其中活動物件通常較少,所以即便它是全停頓,影響也不大。

但 V8 的老生代通常配置較大,且存活物件較多,全堆垃圾回收的標記、清理、整理等動作造成的停頓就會比較嚴重。

為降低全堆垃圾回收而導致的停頓時間,V8 做了以下改善措施:

(1) 限制堆記憶體大小

  •  新生代:64 位系統 和 32 位系統分別為 32M 和 16 M (from 和 to 空間各佔一半)
  •  老生代:64 位系統 和 32 位系統分別為 1400M 和 700 M

(2) 增量式垃圾回收

V8 先從標記階段入手,將原來一口氣停頓完成的動作改為 增量標記(Incremental Marking),也就是拆分為許多小步進,每做完一步進,就讓 JavaScript 應用邏輯執行一小會兒,垃圾回收與應用邏輯交替執行,直到標記階段完成。V8 後續還引入 Lazy Sweep(延遲清除)、Incremental Compaction (增量式整理),讓清理與整理動作也變成增量式的。同時還計劃引入並行標記與並行整理,進一步利用多核效能來降低每次停頓的時間。

5、垃圾回收的觸發條件

  •  作用域: 能形成作用域的函式呼叫、with 語句 以及 全域性作用域。
  •  閉包:  V8 無法主動回收記憶體中的閉包引用和全域性變數引用。

6、記憶體洩漏

通常,造成記憶體洩漏的原因有如下幾個:

  • 佇列消費不及時
  • 作用域未釋放

參考文獻:

[1]  樸靈,深入淺出 Node.js,人民郵電出版社,2013

相關文章