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

前言

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

垃圾從何而來

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

不管使用哪一種語言,我們勢必都會頻繁的操作數據,這些數據一般是存放在棧內存與堆內存中,通常是會在內存中創建一塊空間,使用這塊空間,再不需要的時候回收這塊空間。

比如:

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

當執行這段代碼時,先會爲全局對象(window)添加一個test屬性,並在堆內存中創建一個空對象,並將該對象的地址指向test屬性,隨後又創建了一個長度爲100的數組,並將該數組地址指向了test.a的屬性值。

v8-5.png

從上圖我們可以看出,棧中保存了指向window對象的指針,通過棧中window的地址可以找到window對象,通過window對象可以找到test對象,通過test對象可以找到a數組。

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

test.a = {}

那麼此時的內存會變成這樣:

v8-6.png

那麼這個時候堆內存中的數組其實就變成了‘垃圾數據’,因爲我們再也訪問不到它了,不過我們不必擔心它會一直佔用內存,因爲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 算法,是把新生代空間對半劃分爲兩個區域,一半是對象區域,一半是空閒區域,如下圖所示:

v8-7.png

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

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

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

主垃圾回收器

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

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

它的原理就是:

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

v8-8.png

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

全停頓

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

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

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

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章