深入理解之V8引擎的垃圾回收機制

很白的小白發表於2022-07-07

本文謹用於筆者個人理解和總結V8引擎的垃圾回收機制,本文主要參考一文搞懂V8引擎的垃圾回收

在瞭解V8垃圾回收機制之前,我們先來闡述一些概念:

「全停頓」:垃圾回收演算法在執行前,需要將應用邏輯暫停,執行完垃圾回收後再執行應用邏輯。

如果一次GC需要50ms,應用邏輯就會暫停50ms。為什麼會暫停呢?

①因為js是單執行緒執行的,進入垃圾回收後,js應用邏輯需要暫停,以留出空間給垃圾回收演算法執行。
②垃圾回收其實是非常耗時間的操作。

V8引擎垃圾回收策略:

  • V8的垃圾回收策略主要是基於分代式垃圾回收機制,其根據物件的存活時間將記憶體的垃圾回收進行不同的分代,然後對不同的分代採用不同的垃圾回收演算法。
  • 在新生代的垃圾回收過程中主要採用了Scavenge演算法;在老生代採用Mark-Sweep(標記清除)Mark-Compact(標記整理)演算法。

在瞭解新生代和老生代的垃圾管理演算法之前,我們不妨先來了解一下V8引擎垃圾管理的記憶體結構;

V8引擎垃圾管理的記憶體結構:

  • 新生代(new_space):大多數的物件開始都會被分配在這裡,這個區域相對較小但是垃圾回收特別頻繁,該區域被分為兩半,一半用來分配記憶體,另一半用於在垃圾回收時將需要保留的物件複製過來。
    (筆者看到了兩種分割槽說法:①From區:To區=1:1②From區:To區:To區=8:1:1,本文僅用來了解回收機制,不對此處過多討論)
  • 老生代(old_space):新生代中的物件在存活一段時間後就會被轉移到老生代記憶體區,相對於新生代該記憶體區域的垃圾回收頻率較低。老生代又分為老生代指標區和老生代資料區,前者包含大多數可能存在指向其他物件的指標的物件,後者只儲存原始資料物件,這些物件沒有指向其他物件的指標。
  • 大物件區(large_object_space):存放體積超越其他區域大小的物件,每個物件都會有自己的記憶體,垃圾回收不會移動大物件區。
  • 程式碼區(code_space):程式碼物件,會被分配在這裡,唯一擁有執行許可權的記憶體區域。
  • map區(map_space):存放Cell和Map,每個區域都是存放相同大小的元素,結構簡單。

新生代區:

新生代區主要採用Scavenge演算法實現,它將新生代區劃分為啟用區(new space)又稱為From區未啟用區(inactive new space)又稱為To區
程式中生命的物件會被儲存在From空間中,當新生代進行垃圾回收時,處於From區中的尚存的活躍物件會複製到To區進行儲存,然後對From中的物件進行回收,並將From空間和To空間角色對換,即To空間會變為新的From空間,原來的From空間則變為To空間。

因此,該演算法是一個犧牲空間來換取時間的演算法。

基於上述演算法,演算法圖解實現如下(轉載):

  1. 假設我們在From空間中分配了三個物件A、B、Cimage.png
  2. 當程式主執行緒任務第一次執行完畢後進入垃圾回收時,發現物件A已經沒有其他引用,則表示可以對其進行回收image.png
  3. 物件B和物件C此時依舊處於活躍狀態,因此會被複制到To空間中進行儲存image.png
  4. 接下來將From空間中的所有非存活物件全部清除image.png
  5. 此時From空間中的記憶體已經清空,開始和To空間完成一次角色互換image.png
  6. 當程式主執行緒在執行第二個任務時,在From空間中分配了一個新物件Dimage.png
  7. 任務執行完畢後再次進入垃圾回收,發現物件D已經沒有其他引用,表示可以對其進行回收image.png
  8. 象B和物件C此時依舊處於活躍狀態,再次被複制到To空間中進行儲存image.png
  9. 再次將From空間中的所有非存活物件全部清除image.png
  10. From空間和To空間繼續完成一次角色互換image.png

物件晉升:

當一個物件在經過多次複製之後依舊存活,那麼它會被認為是一個生命週期較長的物件,在下一次進行垃圾回收時,該物件會被直接轉移到老生代中,這種物件從新生代轉移到老生代的過程我們稱之為晉升
物件晉升的條件主要有以下兩個(滿足其一即可):

  • 物件是否經歷過一次Scavenge演算法
  • To空間的記憶體佔比是否已經超過25%

預設情況下,我們建立的物件都會分配在From空間中,當進行垃圾回收時,在將物件從From空間複製到To空間之前,會先檢查該物件的記憶體地址來判斷是否已經經歷過一次Scavenge演算法,如果地址已經發生變動則會將該物件轉移到老生代中,不會再被複制到To空間。
流程圖表示:image.png

如果物件沒有經歷過Scavenge演算法,會被複制到To空間,但是如果此時To空間的記憶體佔比已經超過25%,則該物件依舊會被轉移到老生代,如下圖所示:image.png

之所以有25%的記憶體限制是因為To空間在經歷過一次Scavenge演算法後會和From空間完成角色互換,會變為From空間,後續的記憶體分配都是在From空間中進行的,如果記憶體使用過高甚至溢位,則會影響後續物件的分配,因此超過這個限制之後物件會被直接轉移到老生代來進行管理。

老生代區:

在講解老生代Mark-Sweep(標記清除)Mark-Compact(標記整理)演算法之前,先來回顧一下引用計數法:對於物件A,任何一個物件引用了A的值,計數器+1,引用失效時計數器-1,當計數器為0時責備回收,但是會存在迴圈引用的情況,可能會導致記憶體洩漏,自2012年起,所有的現代瀏覽器均放棄了這種演算法。

function foo() {//迴圈引用樣例
    let a = {};
    let b = {};
    a.a1 = b;
    b.b1 = a;
}
foo();

Mark-Sweep(標記清除)演算法:
Mark-Sweep(標記清除)分為標記和清除兩個階段,在標記階段會遍歷堆中的所有物件,然後標記活著的物件,在清除階段中,會將死亡的物件進行清除。Mark-Sweep演算法主要是通過判斷某個物件是否可以被訪問到,從而知道該物件是否應該被回收,具體步驟如下:

  1. 垃圾回收器會在內部構建一個根列表,用於從根節點出發去尋找那些可以被訪問到的變數。比如在JavaScript中,window全域性物件可以看成一個根節點。
  2. 垃圾回收器從所有根節點出發,遍歷其可以訪問到的子節點,並將其標記為活動的,根節點不能到達的地方即為非活動的,將會被視為垃圾。
  3. 垃圾回收器將會釋放所有非活動的記憶體塊,並將其歸還給作業系統。
    image.png
    但是經過標記清除之後的記憶體空間會⽣產很多不連續的碎⽚空間,這種不連續的碎⽚空間中,
    在遇到較⼤的物件時可能會由於空間不⾜⽽導致⽆法儲存。
    為了解決記憶體碎⽚的問題,需要使⽤另外⼀種演算法:標記-整理(Mark-Compact)。

標記-整理(Mark-Compact):
標記整理對待未存活物件不是⽴即回收,⽽是將存活物件移動到⼀邊,然後直接清掉端邊界以外的記憶體。
這裡為了便於理解,引用兩個流程圖。

  1. 假設在老生代中有A、B、C、D四個物件image.png
  2. 在垃圾回收的標記階段,將物件A和物件C標記為活動的image.png
  3. 在垃圾回收的整理階段,將活動的物件往堆記憶體的一端移動image.png
  4. 在垃圾回收的清除階段,將活動物件左側的記憶體全部回收image.png

image.png
至此就完成了一次老生代垃圾回收的全部過程,但是由於前文提到的「全停頓」的存在,在標記階段同樣會阻礙主執行緒的執行,一般來說,老生代會儲存大量存活的物件,如果在標記階段將整個堆記憶體遍歷一遍,那麼勢必會造成嚴重的卡頓。因此,V8引擎有引入了Incremental Marking(增量標記)的概念。
Incremental Marking(增量標記)

將原本需要一次性遍歷堆記憶體的操作改為增量標記的方式,先標記堆記憶體中的一部分物件,然後暫停,將執行權重新交給JS主執行緒,待主執行緒任務執行完畢後再從原來暫停標記的地方繼續標記,直到標記完整個堆記憶體。
即:把垃圾回收這個⼤的任務分成⼀個個⼩任務,穿插在 JavaScript任務中間執⾏

這個理念其實有點像React框架中的Fiber架構,只有在瀏覽器的空閒時間才會去遍歷Fiber Tree執行對應的任務,否則延遲執行,儘可能少地影響主執行緒的任務,避免應用卡頓,提升應用效能。
image.png
得益於增量標記的好處,V8引擎後續繼續引入了延遲清理(lazy sweeping)和增量式整理(incremental compaction),讓清理和整理的過程也變成增量式的。同時為了充分利用多核CPU的效能,也將引入並行標記和並行清理,進一步地減少垃圾回收對主執行緒的影響,為應用提升更多的效能。
最後附上V8-GC的觸發機制:
image.png
參考文獻及圖片出處:

相關文章