贈你13張圖,助你20分鐘打敗了「V8垃圾回收機制」?。?!
前言
大家好,我是林三心。前兩天,無意中看到了B站上一個講V8垃圾回收 機制的視頻,感興趣的我看了一下,感覺有點難懂,于是我就在想,大家是不是跟我一樣對V8垃圾回收機制這方面的知識都比較懵,或者說看過這方面的知識,但是看不懂。所以,我思考了三天,想了一下如何才能用最通俗的話,講最難的知識點。
普通理解
我相信大部分同學(xué)在面試中常常被問到:”說一說V8垃圾回收機制吧“。
這個時候,大部分同學(xué)肯定會這么回答:”垃圾回收機制有兩種方式,一種是引用法,一種是標(biāo)記法“。
引用法
就是判斷一個對象的引用數(shù),引用數(shù)為0就回收,引用數(shù)大于0就不回收。請看以下代碼:
let obj1 = { name: '林三心', age: 22 }
let obj2 = obj1
let obj3 = obj1
obj1 = null
obj2 = null
obj3 = null
圖片
引用法是有缺點的,下面代碼執(zhí)行完后,按理說obj1和obj2都會被回收,但是由于他們互相引用,各自引用數(shù)都是1,所以不會被回收,從而造成內(nèi)存泄漏。
function fn () {
const obj1 = {}
const obj2 = {}
obj1.a = obj2
obj2.a = obj1
}
fn()
圖片
標(biāo)記法
標(biāo)記法就是,將可達(dá)的對象標(biāo)記起來,不可達(dá)的對象當(dāng)成垃圾回收。
那問題來了,可不可達(dá),通過什么來判斷呢?(這里的可達(dá),可不是可達(dá)鴨)
言歸正傳,想要判斷可不可達(dá),就不得不說可達(dá)性了,可達(dá)性是什么?就是從初始的根對象(window或者global)的指針開始,向下搜索子節(jié)點,子節(jié)點被搜索到了,說明該子節(jié)點的引用對象可達(dá),并為其進行標(biāo)記,然后接著遞歸搜索,直到所有子節(jié)點被遍歷結(jié)束。那么沒有被遍歷到節(jié)點,也就沒有被標(biāo)記,也就會被當(dāng)成沒有被任何地方引用,就可以證明這是一個需要被釋放內(nèi)存的對象,可以被垃圾回收器回收。
// 可達(dá)
var name = '林三心'
var obj = {
arr: [1, 2, 3]
}
console.log(window.name) // 林三心
console.log(window.obj) // { arr: [1, 2, 3] }
console.log(window.obj.arr) // [1, 2, 3]
console.log(window.obj.arr[1]) // 2
function fn () {
var age = 22
}
// 不可達(dá)
console.log(window.age) // undefined
圖片
普通的理解其實是不夠的,因為垃圾回收機制(GC)其實不止這兩個算法,想要更深入地了解V8垃圾回收機制,就繼續(xù)往下看吧?。?!
JavaScript內(nèi)存管理
其實JavaScript內(nèi)存的流程很簡單,分為3步:
- 1、分配給使用者所需的內(nèi)存
- 2、使用者拿到這些內(nèi)存,并使用內(nèi)存
- 3、使用者不需要這些內(nèi)存了,釋放并歸還給系統(tǒng)
那么這些使用者是誰呢?舉個例子:
var num = ''
var str = '林三心'
var obj = { name: '林三心' }
obj = { name: '林胖子' }
上面這些num,str,obj就是就是使用者,我們都知道,JavaScript數(shù)據(jù)類型分為基礎(chǔ)數(shù)據(jù)類型和引用數(shù)據(jù)類型:
- 基礎(chǔ)數(shù)據(jù)類型:擁有固定的大小,值保存在棧內(nèi)存里,可以通過值直接訪問
- 引用數(shù)據(jù)類型:大小不固定(可以加屬性),棧內(nèi)存中存著指針,指向堆內(nèi)存中的對象空間,通過引用來訪問
圖片
- 由于棧內(nèi)存所存的基礎(chǔ)數(shù)據(jù)類型大小是固定的,所以棧內(nèi)存的內(nèi)存都是操作系統(tǒng)自動分配和釋放回收的
- 由于堆內(nèi)存所存大小不固定,系統(tǒng)無法自動釋放回收,所以需要JS引擎來手動釋放這些內(nèi)存
為啥要垃圾回收
在Chrome中,V8被限制了內(nèi)存的使用(64位約1.4G/1464MB , 32位約0.7G/732MB),為什么要限制呢?
- 表層原因:V8最初為瀏覽器而設(shè)計,不太可能遇到用大量內(nèi)存的場景
- 深層原因:V8的垃圾回收機制的限制(如果清理大量的內(nèi)存垃圾是很耗時間,這樣會引起JavaScript線程暫停執(zhí)行的時間,那么性能和應(yīng)用直線下降)
前面說到棧內(nèi)的內(nèi)存,操作系統(tǒng)會自動進行內(nèi)存分配和內(nèi)存釋放,而堆中的內(nèi)存,由JS引擎(如Chrome的V8)手動進行釋放,當(dāng)我們的代碼沒有按照正確的寫法時,會使得JS引擎的垃圾回收機制無法正確的對內(nèi)存進行釋放(內(nèi)存泄露),從而使得瀏覽器占用的內(nèi)存不斷增加,進而導(dǎo)致JavaScript和應(yīng)用、操作系統(tǒng)性能下降。
V8的垃圾回收算法
1. 分代回收
在JavaScript中,對象存活周期分為兩種情況
- 存活周期很短:經(jīng)過一次垃圾回收后,就被釋放回收掉
- 存活周期很長:經(jīng)過多次垃圾回收后,他還存在,賴著不走
那么問題來了,對于存活周期短的,回收掉就算了,但對于存活周期長的,多次回收都回收不掉,明知回收不掉,卻還不斷地去做回收無用功,那豈不是很消耗性能?
對于這個問題,V8做了分代回收的優(yōu)化方法,通俗點說就是:V8將堆分為兩個空間,一個叫新生代,一個叫老生代,新生代是存放存活周期短對象的地方,老生代是存放存活周期長對象的地方。
圖片
新生代通常只有1-8M的容量,而老生代的容量就大很多了。對于這兩塊區(qū)域,V8分別使用了不同的垃圾回收器和不同的回收算法,以便更高效地實施垃圾回收。
- 副垃圾回收器 + Scavenge算法:主要負(fù)責(zé)新生代的垃圾回收
- 主垃圾回收器 + Mark-Sweep && Mark-Compact算法:主要負(fù)責(zé)老生代的垃圾回收
1.1 新生代
在JavaScript中,任何對象的聲明分配到的內(nèi)存,將會先被放置在新生代中,而因為大部分對象在內(nèi)存中存活的周期很短,所以需要一個效率非常高的算法。在新生代中,主要使用Scavenge算法進行垃圾回收,Scavenge算法是一個典型的犧牲空間換取時間的復(fù)制算法,在占用空間不大的場景上非常適用。
Scavange算法將新生代堆分為兩部分,分別叫from-space和to-space,工作方式也很簡單,就是將from-space中存活的活動對象復(fù)制到to-space中,并將這些對象的內(nèi)存有序的排列起來,然后將from-space中的非活動對象的內(nèi)存進行釋放,完成之后,將from space 和to space進行互換,這樣可以使得新生代中的這兩塊區(qū)域可以重復(fù)利用。
圖片
具體步驟為以下4步:
- 標(biāo)記活動對象和非活動對象
- 復(fù)制from-space的活動對象到to-space中并進行排序
- 清除from-space中的非活動對象
- 將from-space和to-space進行角色互換,以便下一次的Scavenge算法垃圾回收
那么,垃圾回收器是怎么知道哪些對象是活動對象,哪些是非活動對象呢?
這就要不得不提一個東西了——可達(dá)性。什么是可達(dá)性呢?就是從初始的根對象(window或者global)的指針開始,向下搜索子節(jié)點,子節(jié)點被搜索到了,說明該子節(jié)點的引用對象可達(dá),并為其進行標(biāo)記,然后接著遞歸搜索,直到所有子節(jié)點被遍歷結(jié)束。那么沒有被遍歷到節(jié)點,也就沒有被標(biāo)記,也就會被當(dāng)成沒有被任何地方引用,就可以證明這是一個需要被釋放內(nèi)存的對象,可以被垃圾回收器回收。
新生代中的對象什么時候變成老生代的對象?
在新生代中,還進一步進行了細(xì)分。分為nursery子代和intermediate子代兩個區(qū)域,一個對象第一次分配內(nèi)存時會被分配到新生代中的nursery子代,如果經(jīng)過下一次垃圾回收這個對象還存在新生代中,這時候我們將此對象移動到intermediate子代,在經(jīng)過下一次垃圾回收,如果這個對象還在新生代中,副垃圾回收器會將該對象移動到老生代中,這個移動的過程被稱為晉升
1.2 老生代
新生代空間的對象,身經(jīng)百戰(zhàn)之后,留下來的老對象,成功晉升到了老生代空間里,由于這些對象都是經(jīng)過多次回收過程但是沒有被回收走的,都是一群生命力頑強,存活率高的對象,所以老生代里,回收算法不宜使用Scavenge算法,為啥呢,有以下原因:
- Scavenge算法是復(fù)制算法,反復(fù)復(fù)制這些存活率高的對象,沒什么意義,效率極低。
- Scavenge算法是以空間換時間的算法,老生代是內(nèi)存很大的空間,如果使用Scavenge算法,空間資源非常浪費,得不償失啊。
所以老生代里使用了Mark-Sweep算法(標(biāo)記清理)和Mark-Compact算法(標(biāo)記整理)。
Mark-Sweep(標(biāo)記清理)
Mark-Sweep分為兩個階段,標(biāo)記和清理階段,之前的Scavenge算法也有標(biāo)記和清理,但是Mark-Sweep算法跟Scavenge算法的區(qū)別是,后者需要復(fù)制后再清理,前者不需要,Mark-Sweep直接標(biāo)記活動對象和非活動對象之后,就直接執(zhí)行清理了。
- 標(biāo)記階段:對老生代對象進行第一次掃描,對活動對象進行標(biāo)記
- 清理階段:對老生代對象進行第二次掃描,清除未標(biāo)記的對象,即非活動對象
圖片
由上圖,我想大家也發(fā)現(xiàn)了,有一個問題:清除非活動對象之后,留下了很多零零散散的空位。
Mark-Compact(標(biāo)記整理)
Mark-Sweep算法執(zhí)行垃圾回收之后,留下了很多零零散散的空位,這有什么壞處呢?如果此時進來了一個大對象,需要對此對象分配一個大內(nèi)存,先從零零散散的空位中找位置,找了一圈,發(fā)現(xiàn)沒有適合自己大小的空位,只好拼在了最后,這個尋找空位的過程是耗性能的,這也是Mark-Sweep算法的一個缺點。
這個時候Mark-Compact算法出現(xiàn)了,他是Mark-Sweep算法的加強版,在Mark-Sweep算法的基礎(chǔ)上,加上了整理階段,每次清理完非活動對象,就會把剩下的活動對象,整理到內(nèi)存的一側(cè),整理完成后,直接回收掉邊界上的內(nèi)存。
圖片
2. 全停頓(Stop-The-World)
說完V8的分代回收,咱們來聊聊一個問題。JS代碼的運行要用到JS引擎,垃圾回收也要用到JS引擎,那如果這兩者同時進行了,發(fā)生沖突了咋辦呢?答案是,垃圾回收優(yōu)先于代碼執(zhí)行,會先停止代碼的執(zhí)行,等到垃圾回收完畢,再執(zhí)行JS代碼。這個過程,稱為全停頓。
由于新生代空間小,并且存活對象少,再配合Scavenge算法,停頓時間較短。但是老生代就不一樣了,某些情況活動對象比較多的時候,停頓時間就會較長,使得頁面出現(xiàn)了卡頓現(xiàn)象。
3. Orinoco優(yōu)化
orinoco為V8的垃圾回收器的項目代號,為了提升用戶體驗,解決全停頓問題,它提出了增量標(biāo)記、懶性清理、并發(fā)、并行的優(yōu)化方法。
3.1 增量標(biāo)記(Incremental marking)
咱們前面不斷強調(diào)了先標(biāo)記,后清除,而增量標(biāo)記就是在標(biāo)記這個階段進行了優(yōu)化。我舉個生動的例子:路上有很多垃圾,害得路人都走不了路,需要清潔工打掃干凈才能走。前幾天路上的垃圾都比較少,所以路人們都等到清潔工全部清理干凈才通過,但是后幾天垃圾越來越多,清潔工清理的太久了,路人就等不及了,跟清潔工說:“你打掃一段,我就走一段,這樣效率高”。
大家把上面例子里,清潔工清理垃圾的過程——標(biāo)記過程,路人——JS代碼,一一對應(yīng)就懂了。當(dāng)垃圾少量時不會做增量標(biāo)記優(yōu)化,但是當(dāng)垃圾達(dá)到一定數(shù)量時,增量標(biāo)記就會開啟:標(biāo)記一點,JS代碼運行一段,從而提高效率。
圖片
3.2 惰性清理(Lazy sweeping)
上面說了,增量標(biāo)記只是針對標(biāo)記階段,而惰性清理就是針對清除階段了。在增量標(biāo)記之后,要進行清理非活動對象的時候,垃圾回收器發(fā)現(xiàn)了其實就算是不清理,剩余的空間也足以讓JS代碼跑起來,所以就延遲了清理,讓JS代碼先執(zhí)行,或者只清理部分垃圾,而不清理全部。這個優(yōu)化就叫做惰性清理。
整理標(biāo)記和惰性清理的出現(xiàn),大大改善了全停頓現(xiàn)象。但是問題也來了:增量標(biāo)記是標(biāo)記一點,JS運行一段,那如果你前腳剛標(biāo)記一個對象為活動對象,后腳JS代碼就把此對象設(shè)置為非活動對象,或者反過來,前腳沒有標(biāo)記一個對象為活動對象,后腳JS代碼就把此對象設(shè)置為活動對象??偨Y(jié)起來就是:標(biāo)記和代碼執(zhí)行的穿插,有可能造成對象引用改變,標(biāo)記錯誤現(xiàn)象。這就需要使用寫屏障技術(shù)來記錄這些引用關(guān)系的變化。
3.3 并發(fā)(Concurrent)
并發(fā)式GC允許在在垃圾回收的同時不需要將主線程掛起,兩者可以同時進行,只有在個別時候需要短暫停下來讓垃圾回收器做一些特殊的操作。但是這種方式也要面對增量回收的問題,就是在垃圾回收過程中,由于JavaScript代碼在執(zhí)行,堆中的對象的引用關(guān)系隨時可能會變化,所以也要進行寫屏障操作。
圖片
3.4 并行
并行式GC允許主線程和輔助線程同時執(zhí)行同樣的GC工作,這樣可以讓輔助線程來分擔(dān)主線程的GC工作,使得垃圾回收所耗費的時間等于總時間除以參與的線程數(shù)量(加上一些同步開銷)。
圖片
V8當(dāng)前的垃圾回收機制
2011年,V8應(yīng)用了增量標(biāo)記機制。直至2018年,Chrome64和Node.js V10啟動并發(fā)標(biāo)記(Concurrent),同時在并發(fā)的基礎(chǔ)上添加并行(Parallel)技術(shù),使得垃圾回收時間大幅度縮短。
副垃圾回收器
V8在新生代垃圾回收中,使用并行(parallel)機制,在整理排序階段,也就是將活動對象從from-to復(fù)制到space-to的時候,啟用多個輔助線程,并行的進行整理。由于多個線程競爭一個新生代的堆的內(nèi)存資源,可能出現(xiàn)有某個活動對象被多個線程進行復(fù)制操作的問題,為了解決這個問題,V8在第一個線程對活動對象進行復(fù)制并且復(fù)制完成后,都必須去維護復(fù)制這個活動對象后的指針轉(zhuǎn)發(fā)地址,以便于其他協(xié)助線程可以找到該活動對象后可以判斷該活動對象是否已被復(fù)制。
圖片
主垃圾回收器
V8在老生代垃圾回收中,如果堆中的內(nèi)存大小超過某個閾值之后,會啟用并發(fā)(Concurrent)標(biāo)記任務(wù)。每個輔助線程都會去追蹤每個標(biāo)記到的對象的指針以及對這個對象的引用,而在JavaScript代碼執(zhí)行時候,并發(fā)標(biāo)記也在后臺的輔助進程中進行,當(dāng)堆中的某個對象指針被JavaScript代碼修改的時候,寫入屏障(write barriers)技術(shù)會在輔助線程在進行并發(fā)標(biāo)記的時候進行追蹤。
當(dāng)并發(fā)標(biāo)記完成或者動態(tài)分配的內(nèi)存到達(dá)極限的時候,主線程會執(zhí)行最終的快速標(biāo)記步驟,這個時候主線程會掛起,主線程會再一次的掃描根集以確保所有的對象都完成了標(biāo)記,由于輔助線程已經(jīng)標(biāo)記過活動對象,主線程的本次掃描只是進行check操作,確認(rèn)完成之后,某些輔助線程會進行清理內(nèi)存操作,某些輔助進程會進行內(nèi)存整理操作,由于都是并發(fā)的,并不會影響主線程JavaScript代碼的執(zhí)行。
圖片
結(jié)語
讀懂了這篇文章,下次面試官問你的時候,你就可以不用傻乎乎地說:“引用法和標(biāo)記法”。而是可以更全面地,更細(xì)致地征服面試官了。