一文搞懂V8引擎的垃圾回收機制

2023-06-13 12:00:39

前言

我們平時在寫程式碼的過程中,好像很少需要自己手動進行垃圾回收,那麼V8是如何來減少記憶體佔用,從而避免記憶體溢位而導致程式崩潰的情況的。為了更高效地回收垃圾,V8引入了兩個垃圾回收器,它們分別針對不同場景進行工作。
如果這篇文章有幫助到你,❤️關注+點贊❤️鼓勵一下作者,文章公眾號首發,關注 前端南玖 第一時間獲取最新文章~

垃圾從何而來

我們先來搞清楚這些‘垃圾’是怎麼產生的

不管使用哪一種語言,我們勢必都會頻繁的運算元據,這些資料一般是存放在棧記憶體與堆記憶體中,通常是會在記憶體中建立一塊空間,使用這塊空間,再不需要的時候回收這塊空間。

比如:

var test = {}
test.a = new Array(100)

當執行這段程式碼時,先會為全域性物件(window)新增一個test屬性,並在堆記憶體中建立一個空物件,並將該物件的地址指向test屬性,隨後又建立了一個長度為100的陣列,並將該陣列地址指向了test.a的屬性值。

從上圖我們可以看出,棧中儲存了指向window物件的指標,通過棧中window的地址可以找到window物件,通過window物件可以找到test物件,通過test物件可以找到a陣列。

如果此時,我們將a屬性指向了另一個物件:

test.a = {}

那麼此時的記憶體會變成這樣:

那麼這個時候堆記憶體中的陣列其實就變成了‘垃圾資料’,因為我們再也存取不到它了,不過我們不必擔心它會一直佔用記憶體,因為V8中的垃圾回收器會幫我們自動清理。

對於 JavaScript 而言,也正是這個「自動」釋放資源的特性帶來了很多困惑,也讓一些 JavaScript 開發者誤以為可以不關心記憶體管理,這是一個很大的誤解。

代際假說與分代收集

代紀假說是垃圾回收領域中的一個重要術語,後續垃圾回收策略都是建立在該假說之上的。

特點

  • 第一個是大部分物件在記憶體中存在的時間很短,簡單來說,就是很多物件一經分配記憶體,很快就變得不可存取

  • 第二個是不死的物件,會活得更久

為了達到最好的回收效果,V8會根據物件的生存週期的不同來應用不同的回收演演算法,所以在 V8 中會把堆分為新生代和老生代兩個區域,新生代中存放的是生存時間短的物件,老生代中存放的生存時間久的物件

新生區通常只支援 1~8M 的容量,而老生區支援的容量就大很多了。對於這兩塊區域,V8 分別使用兩個不同的垃圾回收器,以便更高效地實施垃圾回收

  • 副垃圾回收器,主要負責新生代的垃圾回收

  • 主垃圾回收器,主要負責老生代的垃圾回收

垃圾回收器的工作流程

V8的記憶體結構

  • 新生代(new_space):大多數的物件開始都會被分配在這裡,這個區域相對較小但是垃圾回收特別頻繁,該區域被分為兩半,一半用來分配記憶體,另一半用於在垃圾回收時將需要保留的物件複製過來。

  • 老生代(old_space):新生代中的物件在存活一段時間後就會被轉移到老生代記憶體區,相對於新生代該記憶體區域的垃圾回收頻率較低。老生代又分為老生代指標區老生代資料區,前者包含大多數可能存在指向其他物件的指標的物件,後者只儲存原始資料物件,這些物件沒有指向其他物件的指標。

  • 大物件區(large_object_space):存放體積超越其他區域大小的物件,每個物件都會有自己的記憶體,垃圾回收不會移動大物件區。

  • 程式碼區(code_space):程式碼物件,會被分配在這裡,唯一擁有執行許可權的記憶體區域。

  • map區(map_space):存放Cell和Map,每個區域都是存放相同大小的元素,結構簡單

垃圾回收的過程一般主要出現在新生代老生代

垃圾回收策略

標記清除

標記清除( Mark-Sweep ),目前在 JavaScript引擎 裡這種演演算法是最常用的,到目前為止的大多數瀏覽器的 JavaScript引擎 都在採用標記清除演演算法,只是各大瀏覽器廠商還對此演演算法進行了優化加工,且不同瀏覽器的 JavaScript引擎 在執行垃圾回收的頻率上有所差異。 此演演算法分為 標記 和 清除 兩個階段,標記階段即為所有活動物件做上標記,清除階段則把沒有標記(也就是非活動物件)銷燬。

引擎在執行 GC(使用標記清除演演算法)時,需要從出發點去遍歷記憶體中所有的物件去打標記,而這個出發點有很多,我們稱之為一組根物件,而所謂的根物件,其實在瀏覽器環境中包括又不止於 全域性Window物件、檔案DOM樹等。

整個標記清除演演算法大致過程就像下面這樣:

  • 垃圾收集器在執行時會給記憶體中的所有變數都加上一個標記,假設記憶體中所有物件都是垃圾,全標記為0;
  • 然後從各個根物件開始遍歷,把不是垃圾的節點改成1;
  • 清理所有標記為0的垃圾,銷燬並回收它們所佔用的記憶體空間;
  • 最後,把所有記憶體中物件標記修改為0,等待下一輪垃圾回收;

優點:

實現比較簡單,打標記也無非打與不打兩種情況,這使得一位二進位制位(0和1)就可以為其標記,非常簡單

缺點:

在清除之後,剩餘的物件記憶體位置是不變的,也會導致空閒記憶體空間是不連續的,出現了 記憶體碎片,並且由於剩餘空閒記憶體不是一整塊,它是由不同大小記憶體組成的記憶體列表,這就牽扯出了記憶體分配的問題

參照計數

參照計數( Reference Counting ),這其實是早先的一種垃圾回收演演算法,它把物件是否不再需要簡化定義為物件有沒有其他物件參照到它,如果沒有參照指向該物件(零參照),物件將被垃圾回收機制回收,但因為它的問題很多,目前很少使用這種演演算法了。

它的策略是跟蹤記錄每個變數值被使用的次數

  • 當宣告了一個變數並且將一個參照型別賦值給該變數的時候這個值的參照次數就為 1;
  • 如果同一個值又被賦給另一個變數,那麼參照數加 1;
  • 如果該變數的值被其他的值覆蓋了,則參照次數減 1;
  • 當這個值的參照次數變為 0 的時候,說明沒有變數在使用,這個值沒法被存取了,回收空間,垃圾回收器會在執行的時候清理掉參照次數為 0 的值佔用的記憶體;

優點:

  • 參照計數在參照值為 0 時,也就是在變成垃圾的那一刻就會被回收,所以它可以立即回收垃圾;
  • 標記清除演演算法需要每隔一段時間進行一次,那在應用程式(JS指令碼)執行過程中執行緒就必須要暫停去執行一段時間的 GC,另外,標記清除演演算法需要遍歷堆裡的活動以及非活動物件來清除,而參照計數則只需要在參照時計數就可以了;

缺點:

  • 需要一個計數器,而此計數器需要佔很大的位置,因為我們也不知道被參照數量的上限;
  • 無法解決迴圈參照無法回收的問題;

工作流程

不論什麼型別的垃圾回收器,它們都有一套相同的執行流程

  • 第一步是標記空間中活動物件和非活動物件。所謂活動物件就是還在使用的物件,非活動物件就是可以進行垃圾回收的物件。

  • 第二步是回收非活動物件所佔據的記憶體。其實就是在所有的標記完成之後,統一清理記憶體中所有被標記為可回收的物件。

  • 第三步是做記憶體整理。一般來說,頻繁回收物件後,記憶體中就會存在大量不連續空間,我們把這些不連續的記憶體空間稱為記憶體碎片。當記憶體中出現了大量的記憶體碎片之後,如果需要分配較大連續記憶體的時候,就有可能出現記憶體不足的情況。所以最後一步需要整理這些記憶體碎片,但這步其實是可選的,因為有的垃圾回收器不會產生記憶體碎片,比如接下來我們要介紹的副垃圾回收器。

副垃圾回收器

副垃圾回收器主要負責新生區的垃圾回收。而通常情況下,大多數小的物件都會被分配到新生區,所以說這個區域雖然不大,但是垃圾回收還是比較頻繁的。

新生代中用 Scavenge 演演算法來處理。所謂 Scavenge 演演算法,是把新生代空間對半劃分為兩個區域,一半是物件區域,一半是空閒區域,如下圖所示:

新加入的物件都會存放到物件區域,當物件區域快被寫滿時,就需要執行一次垃圾清理操作。

在垃圾回收過程中,首先要對物件區域中的垃圾做標記;標記完成之後,就進入垃圾清理階段,副垃圾回收器會把這些存活的物件複製到空閒區域中,同時它還會把這些物件有序地排列起來,所以這個複製過程,也就相當於完成了記憶體整理操作,複製後空閒區域就沒有記憶體碎片了。完成複製後,物件區域與空閒區域進行角色翻轉,也就是原來的物件區域變成空閒區域,原來的空閒區域變成了物件區域。這樣就完成了垃圾物件的回收操作,同時這種角色翻轉的操作還能讓新生代中的這兩塊區域無限重複使用下去。

由於新生代中採用的 Scavenge 演演算法,所以每次執行清理操作時,都需要將存活的物件從物件區域複製到空閒區域。但複製操作需要時間成本,如果新生區空間設定得太大了,那麼每次清理的時間就會過久,所以為了執行效率,一般新生區的空間會被設定得比較小。也正是因為新生區的空間不大,所以很容易被存活的物件裝滿整個區域。為了解決這個問題,JavaScript 引擎採用了物件晉升策略,也就是經過兩次垃圾回收依然還存活的物件,會被移動到老生區中。

主垃圾回收器

主垃圾回收器主要負責老生區中的垃圾回收。除了新生區中晉升的物件,一些大的物件會直接被分配到老生區。因此老生區中的物件有兩個特點,一個是物件佔用空間大,另一個是物件存活時間長。

由於老生區的物件比較大,若要在老生區中使用 Scavenge 演演算法進行垃圾回收,複製這些大的物件將會花費比較多的時間,從而導致回收執行效率不高,同時還會浪費一半的空間。因而,主垃圾回收器是採用**標記 - 清除(Mark-Sweep)**的演演算法進行垃圾回收的。

它的原理就是:

  • 首先是標記過程階段。標記階段就是從一組根元素開始,遞迴遍歷這組根元素,在這個遍歷過程中,能到達的元素稱為活動物件,沒有到達的元素就可以判斷為垃圾資料。
  • 接下來就是垃圾的清除過程。它和副垃圾回收器的垃圾清除過程完全不同,對一塊記憶體多次執行標記 - 清除演演算法後,可能會產生大量不連續的記憶體碎片。

  • 而碎片過多會導致大物件無法分配到足夠的連續記憶體,於是又產生了另外一種演演算法——標記 - 整理(Mark-Compact),這個標記過程仍然與標記 - 清除演演算法裡的是一樣的,但後續步驟不是直接對可回收物件進行清理,而是讓所有存活的物件都向一端移動,然後直接清理掉端邊界以外的記憶體。

全停頓

由於 JavaScript 是執行在主執行緒之上的,一旦執行垃圾回收演演算法,都需要將正在執行的 JavaScript 指令碼暫停下來,待垃圾回收完畢後再恢復指令碼執行。我們把這種行為叫做全停頓(Stop-The-World)

在 V8 新生代的垃圾回收中,因其空間較小,且存活物件較少,所以全停頓的影響不大,但老生代就不一樣了。如果在執行垃圾回收的過程中,佔用主執行緒時間過久,將會造成頁面卡頓。

為了降低老生代的垃圾回收而造成的卡頓,V8 將標記過程分為一個個的子標記過程,同時讓垃圾回收標記和 JavaScript 應用邏輯交替進行,直到標記階段完成,我們把這個演演算法稱為增量標記(Incremental Marking) 演演算法。