一文搞懂V8引擎的垃圾回收機制
前言
我們平時在寫代碼的過程中,好像很少需要自己手動進行垃圾回收,那么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)算法。