一文搞懂V8引擎的垃圾回收機制
前言
我們平時在寫代碼的過程中,好像很少需要自己手動進行垃圾回收,那么V8是如何來減少內(nèi)存占用,從而避免內(nèi)存溢出而導(dǎo)致程序崩潰的情況的。為了更高效地回收垃圾,V8引入了兩個垃圾回收器,它們分別針對不同場景進行工作。
垃圾從何而來
我們先來搞清楚這些‘垃圾’是怎么產(chǎn)生的
不管使用哪一種語言,我們勢必都會頻繁的操作數(shù)據(jù),這些數(shù)據(jù)一般是存放在棧內(nèi)存與堆內(nèi)存中,通常是會在內(nèi)存中創(chuàng)建一塊空間,使用這塊空間,再不需要的時候回收這塊空間。
比如:
var test = {}
test.a = new Array(100)
當(dāng)執(zhí)行這段代碼時,先會為全局對象(window)添加一個test屬性,并在堆內(nèi)存中創(chuàng)建一個空對象,并將該對象的地址指向test屬性,隨后又創(chuàng)建了一個長度為100的數(shù)組,并將該數(shù)組地址指向了test.a的屬性值。
從上圖我們可以看出,棧中保存了指向window對象的指針,通過棧中window的地址可以找到window對象,通過window對象可以找到test對象,通過test對象可以找到a數(shù)組。
如果此時,我們將a屬性指向了另一個對象:
test.a = {}
那么此時的內(nèi)存會變成這樣:
那么這個時候堆內(nèi)存中的數(shù)組其實就變成了‘垃圾數(shù)據(jù)’,因為我們再也訪問不到它了,不過我們不必?fù)?dān)心它會一直占用內(nèi)存,因為V8中的垃圾回收器會幫我們自動清理。
對于 JavaScript 而言,也正是這個“自動”釋放資源的特性帶來了很多困惑,也讓一些 JavaScript 開發(fā)者誤以為可以不關(guān)心內(nèi)存管理,這是一個很大的誤解。
代際假說與分代收集
代際假說是垃圾回收領(lǐng)域中的一個重要術(shù)語,后續(xù)垃圾回收策略都是建立在該假說之上的。
特點
- 第一個是大部分對象在內(nèi)存中存在的時間很短,簡單來說,就是很多對象一經(jīng)分配內(nèi)存,很快就變得不可訪問
- 第二個是不死的對象,會活得更久
為了達到最好的回收效果,V8會根據(jù)對象的生存周期的不同來應(yīng)用不同的回收算法,所以在 V8 中會把堆分為新生代和老生代兩個區(qū)域,新生代中存放的是生存時間短的對象,老生代中存放的生存時間久的對象。
支持 1~8M 的容量,而老生區(qū)支持的容量就大很多了。對于這兩塊區(qū)域,V8 分別使用兩個不同的垃圾回收器,以便更高效地實施垃圾回收
- 副垃圾回收器,主要負(fù)責(zé)新生代的垃圾回收
- 主垃圾回收器,主要負(fù)責(zé)老生代的垃圾回收
垃圾回收器的工作流程
V8的內(nèi)存結(jié)構(gòu)
- 新生代(new_space):大多數(shù)的對象開始都會被分配在這里,這個區(qū)域相對較小但是垃圾回收特別頻繁,該區(qū)域被分為兩半,一半用來分配內(nèi)存,另一半用于在垃圾回收時將需要保留的對象復(fù)制過來。
- 老生代(old_space):新生代中的對象在存活一段時間后就會被轉(zhuǎn)移到老生代內(nèi)存區(qū),相對于新生代該內(nèi)存區(qū)域的垃圾回收頻率較低。老生代又分為老生代指針區(qū)和老生代數(shù)據(jù)區(qū),前者包含大多數(shù)可能存在指向其他對象的指針的對象,后者只保存原始數(shù)據(jù)對象,這些對象沒有指向其他對象的指針。
- 大對象區(qū)(large_object_space):存放體積超越其他區(qū)域大小的對象,每個對象都會有自己的內(nèi)存,垃圾回收不會移動大對象區(qū)。
- 代碼區(qū)(code_space):代碼對象,會被分配在這里,唯一擁有執(zhí)行權(quán)限的內(nèi)存區(qū)域。
- map區(qū)(map_space):存放Cell和Map,每個區(qū)域都是存放相同大小的元素,結(jié)構(gòu)簡單
垃圾回收的過程一般主要出現(xiàn)在「新生代」與「老生代」。
垃圾回收策略
標(biāo)記清除
標(biāo)記清除( Mark-Sweep ),目前在 JavaScript引擎 里這種算法是最常用的,到目前為止的大多數(shù)瀏覽器的 JavaScript引擎 都在采用標(biāo)記清除算法,只是各大瀏覽器廠商還對此算法進行了優(yōu)化加工,且不同瀏覽器的 JavaScript引擎 在運行垃圾回收的頻率上有所差異。此算法分為 標(biāo)記 和 清除 兩個階段,標(biāo)記階段即為所有活動對象做上標(biāo)記,清除階段則把沒有標(biāo)記(也就是非活動對象)銷毀。
引擎在執(zhí)行 GC(使用標(biāo)記清除算法)時,需要從出發(fā)點去遍歷內(nèi)存中所有的對象去打標(biāo)記,而這個出發(fā)點有很多,我們稱之為一組根對象,而所謂的根對象,其實在瀏覽器環(huán)境中包括又不止于 全局Window對象、文檔DOM樹等。
整個標(biāo)記清除算法大致過程就像下面這樣:
- 垃圾收集器在運行時會給內(nèi)存中的所有變量都加上一個標(biāo)記,假設(shè)內(nèi)存中所有對象都是垃圾,全標(biāo)記為0;
- 然后從各個根對象開始遍歷,把不是垃圾的節(jié)點改成1;
- 清理所有標(biāo)記為0的垃圾,銷毀并回收它們所占用的內(nèi)存空間;
- 最后,把所有內(nèi)存中對象標(biāo)記修改為0,等待下一輪垃圾回收;
優(yōu)點:
實現(xiàn)比較簡單,打標(biāo)記也無非打與不打兩種情況,這使得一位二進制位(0和1)就可以為其標(biāo)記,非常簡單
缺點:
在清除之后,剩余的對象內(nèi)存位置是不變的,也會導(dǎo)致空閑內(nèi)存空間是不連續(xù)的,出現(xiàn)了 內(nèi)存碎片,并且由于剩余空閑內(nèi)存不是一整塊,它是由不同大小內(nèi)存組成的內(nèi)存列表,這就牽扯出了內(nèi)存分配的問題
引用計數(shù)
引用計數(shù)( Reference Counting ),這其實是早先的一種垃圾回收算法,它把對象是否不再需要簡化定義為對象有沒有其他對象引用到它,如果沒有引用指向該對象(零引用),對象將被垃圾回收機制回收,但因為它的問題很多,目前很少使用這種算法了。
它的策略是跟蹤記錄每個變量值被使用的次數(shù)
- 當(dāng)聲明了一個變量并且將一個引用類型賦值給該變量的時候這個值的引用次數(shù)就為 1;
- 如果同一個值又被賦給另一個變量,那么引用數(shù)加 1;
- 如果該變量的值被其他的值覆蓋了,則引用次數(shù)減 1;
- 當(dāng)這個值的引用次數(shù)變?yōu)?0 的時候,說明沒有變量在使用,這個值沒法被訪問了,回收空間,垃圾回收器會在運行的時候清理掉引用次數(shù)為 0 的值占用的內(nèi)存;
優(yōu)點:
- 引用計數(shù)在引用值為 0 時,也就是在變成垃圾的那一刻就會被回收,所以它可以立即回收垃圾;
- 標(biāo)記清除算法需要每隔一段時間進行一次,那在應(yīng)用程序(JS腳本)運行過程中線程就必須要暫停去執(zhí)行一段時間的 GC,另外,標(biāo)記清除算法需要遍歷堆里的活動以及非活動對象來清除,而引用計數(shù)則只需要在引用時計數(shù)就可以了;
缺點:
- 需要一個計數(shù)器,而此計數(shù)器需要占很大的位置,因為我們也不知道被引用數(shù)量的上限;
- 無法解決循環(huán)引用無法回收的問題;
工作流程
不論什么類型的垃圾回收器,它們都有一套相同的執(zhí)行流程。
- 第一步是「標(biāo)記空間中活動對象和非活動對象」。所謂活動對象就是還在使用的對象,非活動對象就是可以進行垃圾回收的對象。
- 第二步是「回收非活動對象所占據(jù)的內(nèi)存」。其實就是在所有的標(biāo)記完成之后,統(tǒng)一清理內(nèi)存中所有被標(biāo)記為可回收的對象。
- 第三步是做「內(nèi)存整理」。一般來說,頻繁回收對象后,內(nèi)存中就會存在大量不連續(xù)空間,我們把這些不連續(xù)的內(nèi)存空間稱為內(nèi)存碎片。當(dāng)內(nèi)存中出現(xiàn)了大量的內(nèi)存碎片之后,如果需要分配較大連續(xù)內(nèi)存的時候,就有可能出現(xiàn)內(nèi)存不足的情況。所以最后一步需要整理這些內(nèi)存碎片,但這步其實是可選的,因為有的垃圾回收器不會產(chǎn)生內(nèi)存碎片,比如接下來我們要介紹的副垃圾回收器。
副垃圾回收器
副垃圾回收器主要負(fù)責(zé)新生區(qū)的垃圾回收。而通常情況下,大多數(shù)小的對象都會被分配到新生區(qū),所以說這個區(qū)域雖然不大,但是垃圾回收還是比較頻繁的。
新生代中用 Scavenge 算法來處理。所謂 Scavenge 算法,是把新生代空間對半劃分為兩個區(qū)域,一半是對象區(qū)域,一半是空閑區(qū)域,如下圖所示:
新加入的對象都會存放到對象區(qū)域,當(dāng)對象區(qū)域快被寫滿時,就需要執(zhí)行一次垃圾清理操作。
在垃圾回收過程中,首先要對對象區(qū)域中的垃圾做標(biāo)記;標(biāo)記完成之后,就進入垃圾清理階段,副垃圾回收器會把這些存活的對象復(fù)制到空閑區(qū)域中,同時它還會把這些對象有序地排列起來,所以這個復(fù)制過程,也就相當(dāng)于完成了內(nèi)存整理操作,復(fù)制后空閑區(qū)域就沒有內(nèi)存碎片了。完成復(fù)制后,對象區(qū)域與空閑區(qū)域進行角色翻轉(zhuǎn),也就是原來的對象區(qū)域變成空閑區(qū)域,原來的空閑區(qū)域變成了對象區(qū)域。這樣就完成了垃圾對象的回收操作,同時這種角色翻轉(zhuǎn)的操作還能讓新生代中的這兩塊區(qū)域無限重復(fù)使用下去。
由于新生代中采用的 Scavenge 算法,所以每次執(zhí)行清理操作時,都需要將存活的對象從對象區(qū)域復(fù)制到空閑區(qū)域。但復(fù)制操作需要時間成本,如果新生區(qū)空間設(shè)置得太大了,那么每次清理的時間就會過久,所以為了執(zhí)行效率,一般新生區(qū)的空間會被設(shè)置得比較小。也正是因為新生區(qū)的空間不大,所以很容易被存活的對象裝滿整個區(qū)域。為了解決這個問題,JavaScript 引擎采用了「對象晉升策略」,也就是經(jīng)過兩次垃圾回收依然還存活的對象,會被移動到老生區(qū)中。
主垃圾回收器
主垃圾回收器主要負(fù)責(zé)老生區(qū)中的垃圾回收。除了新生區(qū)中晉升的對象,一些大的對象會直接被分配到老生區(qū)。因此老生區(qū)中的對象有兩個特點,一個是對象占用空間大,另一個是對象存活時間長。
由于老生區(qū)的對象比較大,若要在老生區(qū)中使用 Scavenge 算法進行垃圾回收,復(fù)制這些大的對象將會花費比較多的時間,從而導(dǎo)致回收執(zhí)行效率不高,同時還會浪費一半的空間。因而,主垃圾回收器是采用「標(biāo)記 - 清除(Mark-Sweep)」的算法進行垃圾回收的。
它的原理就是:
- 首先是標(biāo)記過程階段。標(biāo)記階段就是從一組根元素開始,遞歸遍歷這組根元素,在這個遍歷過程中,能到達的元素稱為活動對象,沒有到達的元素就可以判斷為垃圾數(shù)據(jù)。
- 接下來就是垃圾的清除過程。它和副垃圾回收器的垃圾清除過程完全不同,對一塊內(nèi)存多次執(zhí)行「標(biāo)記 - 清除」算法后,可能會產(chǎn)生大量不連續(xù)的內(nèi)存碎片。
- 而碎片過多會導(dǎo)致大對象無法分配到足夠的連續(xù)內(nèi)存,于是又產(chǎn)生了另外一種算法——「標(biāo)記 - 整理(Mark-Compact)」,這個標(biāo)記過程仍然與標(biāo)記 - 清除算法里的是一樣的,但后續(xù)步驟不是直接對可回收對象進行清理,而是讓所有存活的對象都向一端移動,然后直接清理掉端邊界以外的內(nèi)存。
全停頓
由于 JavaScript 是運行在主線程之上的,一旦執(zhí)行垃圾回收算法,都需要將正在執(zhí)行的 JavaScript 腳本暫停下來,待垃圾回收完畢后再恢復(fù)腳本執(zhí)行。我們把這種行為叫做「全停頓(Stop-The-World)。
在 V8 新生代的垃圾回收中,因其空間較小,且存活對象較少,所以全停頓的影響不大,但老生代就不一樣了。如果在執(zhí)行垃圾回收的過程中,占用主線程時間過久,將會造成頁面卡頓。
為了降低老生代的垃圾回收而造成的卡頓,V8 將標(biāo)記過程分為一個個的子標(biāo)記過程,同時讓垃圾回收標(biāo)記和 JavaScript 應(yīng)用邏輯交替進行,直到標(biāo)記階段完成,我們把這個算法稱為增量標(biāo)記(Incremental Marking)算法。