聊聊V8引擎的垃圾回收
點(diǎn)上方藍(lán)字關(guān)注公眾號(hào)「前端UpUp」
騰訊題目:
function test() {
var a = 1;
var b = {};
var c = {a: a};
return c;
}
執(zhí)行test后,說(shuō)一說(shuō),堆棧發(fā)生了啥變化,最后c的內(nèi)存會(huì)被回收嗎?
作者:linxuan
來(lái)源:https://segmentfault.com/a/1190000015437592
前言
我們知道,JavaScript之所以能在瀏覽器環(huán)境和NodeJS環(huán)境運(yùn)行,都是因?yàn)橛蠽8引擎在幕后保駕護(hù)航。從編譯、內(nèi)存分配、運(yùn)行以及垃圾回收等整個(gè)過(guò)程,都離不開(kāi)它。
在寫(xiě)這篇文章之前,我也在網(wǎng)上看了很多博客,包括一些英文原版的內(nèi)容,于是想通過(guò)這篇文章來(lái)做一個(gè)歸納整理,文中加入了我自己的思考,以及純手工制作流程圖~~
希望這篇文章能幫到你,同時(shí)本文也會(huì)收錄到我自己的個(gè)人網(wǎng)站。
為什么要有垃圾回收
在C語(yǔ)言和C++語(yǔ)言中,我們?nèi)绻胍_(kāi)辟一塊堆內(nèi)存的話(huà),需要先計(jì)算需要內(nèi)存的大小,然后自己通過(guò)malloc函數(shù)去手動(dòng)分配,在用完之后,還要時(shí)刻記得用free函數(shù)去清理釋放,否則這塊內(nèi)存就會(huì)被永久占用,造成內(nèi)存泄露。
但是我們?cè)趯?xiě)JavaScript的時(shí)候,卻沒(méi)有這個(gè)過(guò)程,因?yàn)槿思乙呀?jīng)替我們封裝好了,V8引擎會(huì)根據(jù)你當(dāng)前定義對(duì)象的大小去自動(dòng)申請(qǐng)分配內(nèi)存。
不需要我們?nèi)ナ謩?dòng)管理內(nèi)存了,所以自然要有垃圾回收,否則的話(huà)只分配不回收,豈不是沒(méi)多長(zhǎng)時(shí)間內(nèi)存就被占滿(mǎn)了嗎,導(dǎo)致應(yīng)用崩潰。
垃圾回收的好處是不需要我們?nèi)ス芾韮?nèi)存,把更多的精力放在實(shí)現(xiàn)復(fù)雜應(yīng)用上,但壞處也來(lái)自于此,不用管理了,就有可能在寫(xiě)代碼的時(shí)候不注意,造成循環(huán)引用等情況,導(dǎo)致內(nèi)存泄露。
內(nèi)存結(jié)構(gòu)分配
由于V8最開(kāi)始就是為JavaScript在瀏覽器執(zhí)行而打造的,不太可能遇到使用大量?jī)?nèi)存的場(chǎng)景,所以它可以申請(qǐng)的最大內(nèi)存就沒(méi)有設(shè)置太大,在64位系統(tǒng)下大約為1.4GB,在32位系統(tǒng)下大約為700MB。
在NodeJS環(huán)境中,我們可以通過(guò)process.memoryUsage()來(lái)查看內(nèi)存分配。

process.memoryUsage返回一個(gè)對(duì)象,包含了 Node 進(jìn)程的內(nèi)存占用信息。該對(duì)象包含四個(gè)字段,含義如下:

rss(resident set size):所有內(nèi)存占用,包括指令區(qū)和堆棧
heapTotal:V8引擎可以分配的最大堆內(nèi)存,包含下面的 heapUsed
heapUsed:V8引擎已經(jīng)分配使用的堆內(nèi)存
external:V8管理C++對(duì)象綁定到JavaScript對(duì)象上的內(nèi)存以上所有內(nèi)存單位均為字節(jié)(Byte)。
如果說(shuō)想要擴(kuò)大Node可用的內(nèi)存空間,可以使用Buffer等堆外內(nèi)存內(nèi)存,這里不詳細(xì)說(shuō)明了,大家有興趣可以去看一些資料。
下面是Node的整體架構(gòu)圖,有助于大家理解上面的內(nèi)容:

Node Standard Library: 是我們每天都在用的標(biāo)準(zhǔn)庫(kù),如Http, Buffer 模塊
Node Bindings: 是溝通JS 和 C++的橋梁,封裝V8和Libuv的細(xì)節(jié),向上層提供基礎(chǔ)API服務(wù)
第三層是支撐 Node.js 運(yùn)行的關(guān)鍵,由 C/C++ 實(shí)現(xiàn):
1. V8 是Google開(kāi)發(fā)的JavaScript引擎,提供JavaScript運(yùn)行環(huán)境,可以說(shuō)它就是 Node.js 的發(fā)動(dòng)機(jī)
2. Libuv 是專(zhuān)門(mén)為Node.js開(kāi)發(fā)的一個(gè)封裝庫(kù),提供跨平臺(tái)的異步I/O能力
3. C-ares:提供了異步處理 DNS 相關(guān)的能力
4. http_parser、OpenSSL、zlib 等:提供包括 http 解析、SSL、數(shù)據(jù)壓縮等其他的能力垃圾回收機(jī)制
如何判斷是否可以回收
1.1 標(biāo)記清除
當(dāng)變量進(jìn)入環(huán)境(例如,在函數(shù)中聲明一個(gè)變量)時(shí),就將這個(gè)變量標(biāo)記為“進(jìn)入環(huán)境”。從邏輯上講,永遠(yuǎn)不能釋放進(jìn)入環(huán)境的變量所占用的內(nèi)存,因?yàn)橹灰獔?zhí)行流進(jìn)入相應(yīng)的環(huán)境,就可能會(huì)用到它們。而當(dāng)變量離開(kāi)環(huán)境時(shí),則將其標(biāo)記為“離開(kāi)環(huán)境”。
可以使用任何方式來(lái)標(biāo)記變量。比如,可以通過(guò)翻轉(zhuǎn)某個(gè)特殊的位來(lái)記錄一個(gè)變量何時(shí)進(jìn)入環(huán)境,或者使用一個(gè)“進(jìn)入環(huán)境的”變量列表及一個(gè)“離開(kāi)環(huán)境的”變量列表來(lái)跟蹤哪個(gè)變量發(fā)生了變化。如何標(biāo)記變量并不重要,關(guān)鍵在于采取什么策略。
(1)垃圾收集器在運(yùn)行的時(shí)候會(huì)給存儲(chǔ)在內(nèi)存中的所有變量都加上標(biāo)記(當(dāng)然,可以使用任何標(biāo)記方式)。
(2)然后,它會(huì)去掉運(yùn)行環(huán)境中的變量以及被環(huán)境中變量所引用的變量的標(biāo)記
(3)此后,依然有標(biāo)記的變量就被視為準(zhǔn)備刪除的變量,原因是在運(yùn)行環(huán)境中已經(jīng)無(wú)法訪(fǎng)問(wèn)到這些變量了。
(4)最后,垃圾收集器完成內(nèi)存清除工作,銷(xiāo)毀那些帶標(biāo)記的值并回收它們所占用的內(nèi)存空間。
目前,IE、Firefox、Opera、Chrome和Safari的JavaScript實(shí)現(xiàn)使用的都是標(biāo)記清除式的垃圾回收策略(或類(lèi)似的策略),只不過(guò)垃圾收集的時(shí)間間隔互有不同。

活動(dòng)對(duì)象就是上面的root,如果不清楚活動(dòng)對(duì)象的可以先查一下資料,當(dāng)一個(gè)對(duì)象和其關(guān)聯(lián)對(duì)象不再通過(guò)引用關(guān)系被當(dāng)前root引用了,這個(gè)對(duì)象就會(huì)被垃圾回收。
1.2 引用計(jì)數(shù)
引用計(jì)數(shù)的垃圾收集策略不太常見(jiàn)。含義是跟蹤記錄每個(gè)值被引用的次數(shù)。當(dāng)聲明了一個(gè)變量并將一個(gè)引用類(lèi)型值賦給該變量時(shí),則這個(gè)值的引用次數(shù)就是1。
如果同一個(gè)值又被賦給另一個(gè)變量,則該值的引用次數(shù)加1。相反,如果包含對(duì)這個(gè)值引用的變量改變了引用對(duì)象,則該值引用次數(shù)減1。
當(dāng)這個(gè)值的引用次數(shù)變成0時(shí),則說(shuō)明沒(méi)有辦法再訪(fǎng)問(wèn)這個(gè)值了,因而就可以將其占用的內(nèi)存空間回收回來(lái)。
這樣,當(dāng)垃圾收集器下次再運(yùn)行時(shí),它就會(huì)釋放那些引用次數(shù)為0的值所占用的內(nèi)存。
Netscape Navigator 3.0是最早使用引用計(jì)數(shù)策略的瀏覽器,但很快它就遇到了一個(gè)嚴(yán)重的問(wèn)題:循環(huán)引用。
循環(huán)引用是指對(duì)象A中包含一個(gè)指向?qū)ο驜的指針,而對(duì)象B中也包含一個(gè)指向?qū)ο驛的引用,看個(gè)例子:
function foo () {
var objA = new Object();
var objB = new Object();
objA.otherObj = objB;
objB.anotherObj = objA;
}這個(gè)例子中,objA和objB通過(guò)各自的屬性相互引用,也就是說(shuō),這兩個(gè)對(duì)象的引用次數(shù)都是2。
在采用標(biāo)記清除策略的實(shí)現(xiàn)中,由于函數(shù)執(zhí)行后,這兩個(gè)對(duì)象都離開(kāi)了作用域,因此這種相互引用不是問(wèn)題。
但在采用引用次數(shù)策略的實(shí)現(xiàn)中,當(dāng)函數(shù)執(zhí)行完畢后,objA和objB還將繼續(xù)存在,因?yàn)樗鼈兊囊么螖?shù)永遠(yuǎn)不會(huì)是0。
加入這個(gè)函數(shù)被重復(fù)多次調(diào)用,就會(huì)導(dǎo)致大量?jī)?nèi)存無(wú)法回收。為此,Netscape在Navigator 4.0中也放棄了引用計(jì)數(shù)方式,轉(zhuǎn)而采用標(biāo)記清除來(lái)實(shí)現(xiàn)其垃圾回收機(jī)制。
還要注意的是,我們大部分人時(shí)刻都在寫(xiě)著循環(huán)引用的代碼,看下面這個(gè)例子,相信大家都這樣寫(xiě)過(guò):
var el = document.getElementById('#el');
el.onclick = function (event) {
console.log('element was clicked');
}我們?yōu)橐粋€(gè)元素的點(diǎn)擊事件綁定了一個(gè)匿名函數(shù),我們通過(guò)event參數(shù)是可以拿到相應(yīng)元素el的信息的。
大家想想,這是不是就是一個(gè)循環(huán)引用呢?
el有一個(gè)屬性onclick引用了一個(gè)函數(shù)(其實(shí)也是個(gè)對(duì)象),函數(shù)里面的參數(shù)又引用了el,這樣el的引用次數(shù)一直是2,即使當(dāng)前這個(gè)頁(yè)面關(guān)閉了,也無(wú)法進(jìn)行垃圾回收。
如果這樣的寫(xiě)法很多很多,就會(huì)造成內(nèi)存泄露。我們可以通過(guò)在頁(yè)面卸載時(shí)清除事件引用,這樣就可以被回收了:
var el = document.getElementById('#el');
el.onclick = function (event) {
console.log('element was clicked');
}
// ...
// ...
// 頁(yè)面卸載時(shí)將綁定的事件清空
window.onbeforeunload = function(){
el.onclick = null;
}V8垃圾回收策略
自動(dòng)垃圾回收有很多算法,由于不同對(duì)象的生存周期不同,所以無(wú)法只用一種回收策略來(lái)解決問(wèn)題,這樣效率會(huì)很低。
所以,V8采用了一種代回收的策略,將內(nèi)存分為兩個(gè)生代:新生代(new generation)和老生代(old generation)。
新生代中的對(duì)象為存活時(shí)間較短的對(duì)象,老生代中的對(duì)象為存活時(shí)間較長(zhǎng)或常駐內(nèi)存的對(duì)象,分別對(duì)新老生代采用不同的垃圾回收算法來(lái)提高效率,對(duì)象最開(kāi)始都會(huì)先被分配到新生代(如果新生代內(nèi)存空間不夠,直接分配到老生代),新生代中的對(duì)象會(huì)在滿(mǎn)足某些條件后,被移動(dòng)到老生代,這個(gè)過(guò)程也叫晉升,后面我會(huì)詳細(xì)說(shuō)明。
分代內(nèi)存
默認(rèn)情況下,32位系統(tǒng)新生代內(nèi)存大小為16MB,老生代內(nèi)存大小為700MB,64位系統(tǒng)下,新生代內(nèi)存大小為32MB,老生代內(nèi)存大小為1.4GB。
新生代平均分成兩塊相等的內(nèi)存空間,叫做semispace,每塊內(nèi)存大小8MB(32位)或16MB(64位)。
新生代
1. 分配方式
新生代存的都是生存周期短的對(duì)象,分配內(nèi)存也很容易,只保存一個(gè)指向內(nèi)存空間的指針,根據(jù)分配對(duì)象的大小遞增指針就可以了,當(dāng)存儲(chǔ)空間快要滿(mǎn)時(shí),就進(jìn)行一次垃圾回收。
2. 算法
新生代采用Scavenge垃圾回收算法,在算法實(shí)現(xiàn)時(shí)主要采用Cheney算法。
Cheney算法將內(nèi)存一分為二,叫做semispace,一塊處于使用狀態(tài),一塊處于閑置狀態(tài)。

處于使用狀態(tài)的semispace稱(chēng)為From空間,處于閑置狀態(tài)的semispace稱(chēng)為To空間。
我畫(huà)了一套詳細(xì)的流程圖,接下來(lái)我會(huì)結(jié)合流程圖來(lái)詳細(xì)說(shuō)明Cheney算法是怎么工作的。
垃圾回收在下面我統(tǒng)稱(chēng)為 GC(Garbage Collection)。
step1. 在From空間中分配了3個(gè)對(duì)象A、B、C
step2. GC進(jìn)來(lái)判斷對(duì)象B沒(méi)有其他引用,可以回收,對(duì)象A和C依然為活躍對(duì)象
step3. 將活躍對(duì)象A、C從From空間復(fù)制到To空間
step4. 清空From空間的全部?jī)?nèi)存
step5. 交換From空間和To空間
step6. 在From空間中又新增了2個(gè)對(duì)象D、E
step7. 下一輪GC進(jìn)來(lái)發(fā)現(xiàn)對(duì)象D沒(méi)有引用了,做標(biāo)記
step8. 將活躍對(duì)象A、C、E從From空間復(fù)制到To空間
step9. 清空From空間全部?jī)?nèi)存
step10. 繼續(xù)交換From空間和To空間,開(kāi)始下一輪
通過(guò)上面的流程圖,我們可以很清楚的看到,進(jìn)行From和To交換,就是為了讓活躍對(duì)象始終保持在一塊semispace中,另一塊semispace始終保持空閑的狀態(tài)。
Scavenge由于只復(fù)制存活的對(duì)象,并且對(duì)于生命周期短的場(chǎng)景存活對(duì)象只占少部分,所以它在時(shí)間效率上有優(yōu)異的體現(xiàn)。Scavenge的缺點(diǎn)是只能使用堆內(nèi)存的一半,這是由劃分空間和復(fù)制機(jī)制所決定的。
由于Scavenge是典型的犧牲空間換取時(shí)間的算法,所以無(wú)法大規(guī)模的應(yīng)用到所有的垃圾回收中。但我們可以看到,Scavenge非常適合應(yīng)用在新生代中,因?yàn)樾律袑?duì)象的生命周期較短,恰恰適合這個(gè)算法。
3. 晉升
當(dāng)一個(gè)對(duì)象經(jīng)過(guò)多次復(fù)制仍然存活時(shí),它就會(huì)被認(rèn)為是生命周期較長(zhǎng)的對(duì)象。這種較長(zhǎng)生命周期的對(duì)象隨后會(huì)被移動(dòng)到老生代中,采用新的算法進(jìn)行管理。
對(duì)象從新生代移動(dòng)到老生代的過(guò)程叫作晉升。
對(duì)象晉升的條件主要有兩個(gè):
對(duì)象從From空間復(fù)制到To空間時(shí),會(huì)檢查它的內(nèi)存地址來(lái)判斷這個(gè)對(duì)象是否已經(jīng)經(jīng)歷過(guò)一次Scavenge回收。如果已經(jīng)經(jīng)歷過(guò)了,會(huì)將該對(duì)象從From空間移動(dòng)到老生代空間中,如果沒(méi)有,則復(fù)制到To空間。總結(jié)來(lái)說(shuō),如果一個(gè)對(duì)象是第二次經(jīng)歷從From空間復(fù)制到To空間,那么這個(gè)對(duì)象會(huì)被移動(dòng)到老生代中。
當(dāng)要從From空間復(fù)制一個(gè)對(duì)象到To空間時(shí),如果To空間已經(jīng)使用了超過(guò)25%,則這個(gè)對(duì)象直接晉升到老生代中。設(shè)置25%這個(gè)閾值的原因是當(dāng)這次Scavenge回收完成后,這個(gè)To空間會(huì)變?yōu)镕rom空間,接下來(lái)的內(nèi)存分配將在這個(gè)空間中進(jìn)行。如果占比過(guò)高,會(huì)影響后續(xù)的內(nèi)存分配。
老生代
1. 介紹
在老生代中,存活對(duì)象占較大比重,如果繼續(xù)采用Scavenge算法進(jìn)行管理,就會(huì)存在兩個(gè)問(wèn)題:
由于存活對(duì)象較多,復(fù)制存活對(duì)象的效率會(huì)很低。
采用Scavenge算法會(huì)浪費(fèi)一半內(nèi)存,由于老生代所占堆內(nèi)存遠(yuǎn)大于新生代,所以浪費(fèi)會(huì)很?chē)?yán)重。
所以,V8在老生代中主要采用了Mark-Sweep和Mark-Compact相結(jié)合的方式進(jìn)行垃圾回收。
2. Mark-Sweep
Mark-Sweep是標(biāo)記清除的意思,它分為標(biāo)記和清除兩個(gè)階段。
與Scavenge不同,Mark-Sweep并不會(huì)將內(nèi)存分為兩份,所以不存在浪費(fèi)一半空間的行為。Mark-Sweep在標(biāo)記階段遍歷堆內(nèi)存中的所有對(duì)象,并標(biāo)記活著的對(duì)象,在隨后的清除階段,只清除沒(méi)有被標(biāo)記的對(duì)象。
也就是說(shuō),Scavenge只復(fù)制活著的對(duì)象,而Mark-Sweep只清除死了的對(duì)象?;顚?duì)象在新生代中只占較少部分,死對(duì)象在老生代中只占較少部分,這就是兩種回收方式都能高效處理的原因。
我們還是通過(guò)流程圖來(lái)看一下:
step1. 老生代中有對(duì)象A、B、C、D、E、F

step2. GC進(jìn)入標(biāo)記階段,將A、C、E標(biāo)記為存活對(duì)象

step3. GC進(jìn)入清除階段,回收掉死亡的B、D、F對(duì)象所占用的內(nèi)存空間

可以看到,Mark-Sweep最大的問(wèn)題就是,在進(jìn)行一次清除回收以后,內(nèi)存空間會(huì)出現(xiàn)不連續(xù)的狀態(tài)。這種內(nèi)存碎片會(huì)對(duì)后續(xù)的內(nèi)存分配造成問(wèn)題。
如果出現(xiàn)需要分配一個(gè)大內(nèi)存的情況,由于剩余的碎片空間不足以完成此次分配,就會(huì)提前觸發(fā)垃圾回收,而這次回收是不必要的。
2. Mark-Compact
為了解決Mark-Sweep的內(nèi)存碎片問(wèn)題,Mark-Compact就被提出來(lái)了。
Mark-Compact是標(biāo)記整理的意思,是在Mark-Sweep的基礎(chǔ)上演變而來(lái)的。Mark-Compact在標(biāo)記完存活對(duì)象以后,會(huì)將活著的對(duì)象向內(nèi)存空間的一端移動(dòng),移動(dòng)完成后,直接清理掉邊界外的所有內(nèi)存。如下圖所示:
step1. 老生代中有對(duì)象A、B、C、D、E、F(和Mark—Sweep一樣)

step2. GC進(jìn)入標(biāo)記階段,將A、C、E標(biāo)記為存活對(duì)象(和Mark—Sweep一樣)

step3. GC進(jìn)入整理階段,將所有存活對(duì)象向內(nèi)存空間的一側(cè)移動(dòng),灰色部分為移動(dòng)后空出來(lái)的空間

step4. GC進(jìn)入清除階段,將邊界另一側(cè)的內(nèi)存一次性全部回收

3. 兩者結(jié)合
在V8的回收策略中,Mark-Sweep和Mark-Conpact兩者是結(jié)合使用的。
由于Mark-Conpact需要移動(dòng)對(duì)象,所以它的執(zhí)行速度不可能很快,在取舍上,V8主要使用Mark-Sweep,在空間不足以對(duì)從新生代中晉升過(guò)來(lái)的對(duì)象進(jìn)行分配時(shí),才使用Mark-Compact。
總結(jié)
V8的垃圾回收機(jī)制分為新生代和老生代。
新生代主要使用Scavenge進(jìn)行管理,主要實(shí)現(xiàn)是Cheney算法,將內(nèi)存平均分為兩塊,使用空間叫From,閑置空間叫To,新對(duì)象都先分配到From空間中,在空間快要占滿(mǎn)時(shí)將存活對(duì)象復(fù)制到To空間中,然后清空From的內(nèi)存空間,此時(shí),調(diào)換From空間和To空間,繼續(xù)進(jìn)行內(nèi)存分配,當(dāng)滿(mǎn)足那兩個(gè)條件時(shí)對(duì)象會(huì)從新生代晉升到老生代。
老生代主要采用Mark-Sweep和Mark-Compact算法,一個(gè)是標(biāo)記清除,一個(gè)是標(biāo)記整理。兩者不同的地方是,Mark-Sweep在垃圾回收后會(huì)產(chǎn)生碎片內(nèi)存,而Mark-Compact在清除前會(huì)進(jìn)行一步整理,將存活對(duì)象向一側(cè)移動(dòng),隨后清空邊界的另一側(cè)內(nèi)存,這樣空閑的內(nèi)存都是連續(xù)的,但是帶來(lái)的問(wèn)題就是速度會(huì)慢一些。在V8中,老生代是Mark-Sweep和Mark-Compact兩者共同進(jìn)行管理的。
以上就是本文的全部?jī)?nèi)容,書(shū)寫(xiě)過(guò)程中參考了很多中外文章,參考書(shū)籍包括樸大大的《深入淺出NodeJS》以及《JavaScript高級(jí)程序設(shè)計(jì)》等。我們這里并沒(méi)有對(duì)具體的算法實(shí)現(xiàn)進(jìn)行探討,感興趣的朋友可以繼續(xù)深入研究一下。
最后,謝謝大家能夠讀到這里,如果文中有任何不明確或錯(cuò)誤的地方,歡迎給我留言~~
感謝大家
關(guān)注「前端UpUp」,分享精選面試熱點(diǎn)文章。
加我好友,一起討論算法,2021一起UpUp。


