天天看點

V8引擎的垃圾回收

前言

我們知道,JavaScript之是以能在浏覽器環境和NodeJS環境運作,都是因為有V8引擎在幕後保駕護航。從編譯、記憶體配置設定、運作以及垃圾回收等整個過程,都離不開它。

在寫這篇文章之前,我也在網上看了很多部落格,包括一些英文原版的内容,于是想通過這篇文章來做一個歸納整理,文中加入了我自己的思考,以及純手工制作流程圖~~

為什麼要有垃圾回收

在C語言和C++語言中,我們如果想要開辟一塊堆記憶體的話,需要先計算需要記憶體的大小,然後自己通過malloc函數去手動配置設定,在用完之後,還要時刻記得用free函數去清理釋放,否則這塊記憶體就會被永久占用,造成記憶體洩露。

但是我們在寫JavaScript的時候,卻沒有這個過程,因為人家已經替我們封裝好了,V8引擎會根據你目前定義對象的大小去自動申請配置設定記憶體。

不需要我們去手動管理記憶體了,是以自然要有垃圾回收,否則的話隻配置設定不回收,豈不是沒多長時間記憶體就被占滿了嗎,導緻應用崩潰。

垃圾回收的好處是不需要我們去管理記憶體,把更多的精力放在實作複雜應用上,但壞處也來自于此,不用管理了,就有可能在寫代碼的時候不注意,造成循環引用等情況,導緻記憶體洩露。

記憶體結構配置設定

由于V8最開始就是為JavaScript在浏覽器執行而打造的,不太可能遇到使用大量記憶體的場景,是以它可以申請的最大記憶體就沒有設定太大,在64位系統下大約為1.4GB,在32位系統下大約為700MB。

在NodeJS環境中,我們可以通過process.memoryUsage()來檢視記憶體配置設定。

V8引擎的垃圾回收

process.memoryUsage傳回一個對象,包含了 Node 程序的記憶體占用資訊。該對象包含四個字段,含義如下:

V8引擎的垃圾回收
rss(resident set size):所有記憶體占用,包括指令區和堆棧

heapTotal:V8引擎可以配置設定的最大堆記憶體,包含下面的 heapUsed

heapUsed:V8引擎已經配置設定使用的堆記憶體

external: V8管理C++對象綁定到JavaScript對象上的記憶體      

以上所有記憶體機關均為位元組(Byte)。

如果說想要擴大Node可用的記憶體空間,可以使用Buffer等堆外記憶體記憶體,這裡不詳細說明了,大家有興趣可以去看一些資料。

下面是Node的整體架構圖,有助于大家了解上面的内容:

V8引擎的垃圾回收
Node Standard Library: 是我們每天都在用的标準庫,如Http, Buffer 子產品

Node Bindings:Node.js 運作的關鍵,由 C/C++ 實作:
1. V8 是Google開發的JavaScript引擎,提供JavaScript運作環境,可以說它就是 Node.js 的發動機
2. Libuv 是專門為Node.js開發的一個封裝庫,提供跨平台的異步I/O能力
3. C-ares:提供了異步處理 DNS 相關的能力
4. http_parser、OpenSSL、zlib 等:提供包括 http 解析、SSL、資料壓縮等其他的能力      

垃圾回收機制

如何判斷是否可以回收

1.1 标記清除

當變量進入環境(例如,在函數中聲明一個變量)時,就将這個變量标記為“進入環境”。從邏輯上講,永遠不能釋放進入環境的變量所占用的記憶體,因為隻要執行流進入相應的環境,就可能會用到它們。而當變量離開環境時,則将其标記為“離開環境”。

可以使用任何方式來标記變量。比如,可以通過翻轉某個特殊的位來記錄一個變量何時進入環境,或者使用一個“進入環境的”變量清單及一個“離開環境的”變量清單來跟蹤哪個變量發生了變化。如何标記變量并不重要,關鍵在于采取什麼政策。

  • (1)垃圾收集器在運作的時候會給存儲在記憶體中的所有變量都加上标記(當然,可以使用任何标記方式)。
  • (2)然後,它會去掉運作環境中的變量以及被環境中變量所引用的變量的标記
  • (3)此後,依然有标記的變量就被視為準備删除的變量,原因是在運作環境中已經無法通路到這些變量了。
  • (4)最後,垃圾收集器完成記憶體清除工作,銷毀那些帶标記的值并回收它們所占用的記憶體空間。

目前,IE、Firefox、Opera、Chrome和Safari的JavaScript實作使用的都是标記清除式的垃圾回收政策(或類似的政策),隻不過垃圾收集的時間間隔互有不同。

V8引擎的垃圾回收

活動對象就是上面的root,如果不清楚活動對象的可以先查一下資料,當一個對象和其關聯對象不再通過引用關系被目前root引用了,這個對象就會被垃圾回收。

1.2 引用計數

引用計數的垃圾收集政策不太常見。含義是跟蹤記錄每個值被引用的次數。當聲明了一個變量并将一個引用類型值賦給該變量時,則這個值的引用次數就是1。

如果同一個值又被賦給另一個變量,則該值的引用次數加1。相反,如果包含對這個值引用的變量改變了引用對象,則該值引用次數減1。

當這個值的引用次數變成0時,則說明沒有辦法再通路這個值了,因而就可以将其占用的記憶體空間回收回來。

這樣,當垃圾收集器下次再運作時,它就會釋放那些引用次數為0的值所占用的記憶體。

Netscape Navigator 3.0是最早使用引用計數政策的浏覽器,但很快它就遇到了一個嚴重的問題:循環引用。

循環引用是指對象A中包含一個指向對象B的指針,而對象B中也包含一個指向對象A的引用,看個例子:

function foo () {
    var objA = new Object();
    var objB = new Object();
    
    objA.otherObj = objB;
    objB.anotherObj = objA;
}      

這個例子中,objA和objB通過各自的屬性互相引用,也就是說,這兩個對象的引用次數都是2。

在采用标記清除政策的實作中,由于函數執行後,這兩個對象都離開了作用域,是以這種互相引用不是問題。

但在采用引用次數政策的實作中,當函數執行完畢後,objA和objB還将繼續存在,因為它們的引用次數永遠不會是0。

加入這個函數被重複多次調用,就會導緻大量記憶體無法回收。為此,Netscape在Navigator 4.0中也放棄了引用計數方式,轉而采用标記清除來實作其垃圾回收機制。

還要注意的是,我們大部分人時刻都在寫着循環引用的代碼,看下面這個例子,相信大家都這樣寫過:

var el = document.getElementById('#el');
el.onclick = function (event) {
    console.log('element was clicked');
}      

我們為一個元素的點選事件綁定了一個匿名函數,我們通過event參數是可以拿到相應元素el的資訊的。

大家想想,這是不是就是一個循環引用呢?

el有一個屬性onclick引用了一個函數(其實也是個對象),函數裡面的參數又引用了el,這樣el的引用次數一直是2,即使目前這個頁面關閉了,也無法進行垃圾回收。

如果這樣的寫法很多很多,就會造成記憶體洩露。我們可以通過在頁面解除安裝時清除事件引用,這樣就可以被回收了:

var el = document.getElementById('#el');
el.onclick = function (event) {
    console.log('element was clicked');
}

// ...
// ...

// 頁面解除安裝時将綁定的事件清空
window.onbeforeunload = function(){
    el.onclick = null;
}      

V8垃圾回收政策

自動垃圾回收有很多算法,由于不同對象的生存周期不同,是以無法隻用一種回收政策來解決問題,這樣效率會很低。

是以,V8采用了一種代回收的政策,将記憶體分為兩個生代:新生代(new generation)和老生代(old generation)。

新生代中的對象為存活時間較短的對象,老生代中的對象為存活時間較長或常駐記憶體的對象,分别對新老生代采用不同的垃圾回收算法來提高效率,對象最開始都會先被配置設定到新生代(如果新生代記憶體空間不夠,直接配置設定到老生代),新生代中的對象會在滿足某些條件後,被移動到老生代,這個過程也叫晉升,後面我會詳細說明。

分代記憶體

預設情況下,32位系統新生代記憶體大小為16MB,老生代記憶體大小為700MB,64位系統下,新生代記憶體大小為32MB,老生代記憶體大小為1.4GB。

新生代平均分成兩塊相等的記憶體空間,叫做semispace,每塊記憶體大小8MB(32位)或16MB(64位)。

新生代

1. 配置設定方式

新生代存的都是生存周期短的對象,配置設定記憶體也很容易,隻儲存一個指向記憶體空間的指針,根據配置設定對象的大小遞增指針就可以了,當存儲空間快要滿時,就進行一次垃圾回收。

2. 算法

新生代采用Scavenge垃圾回收算法,在算法實作時主要采用Cheney算法。

Cheney算法将記憶體一分為二,叫做semispace,一塊處于使用狀态,一塊處于閑置狀态。

V8引擎的垃圾回收

處于使用狀态的semispace稱為From空間,處于閑置狀态的semispace稱為To空間。

我畫了一套詳細的流程圖,接下來我會結合流程圖來詳細說明Cheney算法是怎麼工作的。

垃圾回收在下面我統稱為 GC(Garbage Collection)。

step1. 在From空間中配置設定了3個對象A、B、C

step2. GC進來判斷對象B沒有其他引用,可以回收,對象A和C依然為活躍對象

step3. 将活躍對象A、C從From空間複制到To空間

step4. 清空From空間的全部記憶體

step5. 交換From空間和To空間

step6. 在From空間中又新增了2個對象D、E

step7. 下一輪GC進來發現對象D沒有引用了,做标記

step8. 将活躍對象A、C、E從From空間複制到To空間

step9. 清空From空間全部記憶體

step10. 繼續交換From空間和To空間,開始下一輪

通過上面的流程圖,我們可以很清楚的看到,進行From和To交換,就是為了讓活躍對象始終保持在一塊semispace中,另一塊semispace始終保持空閑的狀态。

Scavenge由于隻複制存活的對象,并且對于生命周期短的場景存活對象隻占少部分,是以它在時間效率上有優異的展現。Scavenge的缺點是隻能使用堆記憶體的一半,這是由劃分空間和複制機制所決定的。

由于Scavenge是典型的犧牲空間換取時間的算法,是以無法大規模的應用到所有的垃圾回收中。但我們可以看到,Scavenge非常适合應用在新生代中,因為新生代中對象的生命周期較短,恰恰适合這個算法。

3. 晉升

當一個對象經過多次複制仍然存活時,它就會被認為是生命周期較長的對象。這種較長生命周期的對象随後會被移動到老生代中,采用新的算法進行管理。

對象從新生代移動到老生代的過程叫作晉升。

對象晉升的條件主要有兩個:

  1. 對象從From空間複制到To空間時,會檢查它的記憶體位址來判斷這個對象是否已經經曆過一次Scavenge回收。如果已經經曆過了,會将該對象從From空間移動到老生代空間中,如果沒有,則複制到To空間。總結來說,如果一個對象是第二次經曆從From空間複制到To空間,那麼這個對象會被移動到老生代中。
  2. 當要從From空間複制一個對象到To空間時,如果To空間已經使用了超過25%,則這個對象直接晉升到老生代中。設定25%這個門檻值的原因是當這次Scavenge回收完成後,這個To空間會變為From空間,接下來的記憶體配置設定将在這個空間中進行。如果占比過高,會影響後續的記憶體配置設定。

老生代

1. 介紹

在老生代中,存活對象占較大比重,如果繼續采用Scavenge算法進行管理,就會存在兩個問題:

  1. 由于存活對象較多,複制存活對象的效率會很低。
  2. 采用Scavenge算法會浪費一半記憶體,由于老生代所占堆記憶體遠大于新生代,是以浪費會很嚴重。

是以,V8在老生代中主要采用了Mark-Sweep和Mark-Compact相結合的方式進行垃圾回收。

2. Mark-Sweep

Mark-Sweep是标記清除的意思,它分為标記和清除兩個階段。

與Scavenge不同,Mark-Sweep并不會将記憶體分為兩份,是以不存在浪費一半空間的行為。Mark-Sweep在标記階段周遊堆記憶體中的所有對象,并标記活着的對象,在随後的清除階段,隻清除沒有被标記的對象。

也就是說,Scavenge隻複制活着的對象,而Mark-Sweep隻清除死了的對象。活對象在新生代中隻占較少部分,死對象在老生代中隻占較少部分,這就是兩種回收方式都能高效處理的原因。

我們還是通過流程圖來看一下:

step1. 老生代中有對象A、B、C、D、E、F

V8引擎的垃圾回收

step2. GC進入标記階段,将A、C、E标記為存活對象

V8引擎的垃圾回收

step3. GC進入清除階段,回收掉死亡的B、D、F對象所占用的記憶體空間

V8引擎的垃圾回收

可以看到,Mark-Sweep最大的問題就是,在進行一次清除回收以後,記憶體空間會出現不連續的狀态。這種記憶體碎片會對後續的記憶體配置設定造成問題。

如果出現需要配置設定一個大記憶體的情況,由于剩餘的碎片空間不足以完成此次配置設定,就會提前觸發垃圾回收,而這次回收是不必要的。

2. Mark-Compact

為了解決Mark-Sweep的記憶體碎片問題,Mark-Compact就被提出來了。

Mark-Compact是标記整理的意思,是在Mark-Sweep的基礎上演變而來的。Mark-Compact在标記完存活對象以後,會将活着的對象向記憶體空間的一端移動,移動完成後,直接清理掉邊界外的所有記憶體。如下圖所示:

step1. 老生代中有對象A、B、C、D、E、F(和Mark—Sweep一樣)

V8引擎的垃圾回收

step2. GC進入标記階段,将A、C、E标記為存活對象(和Mark—Sweep一樣)

V8引擎的垃圾回收

step3. GC進入整理階段,将所有存活對象向記憶體空間的一側移動,灰色部分為移動後空出來的空間

V8引擎的垃圾回收

step4. GC進入清除階段,将邊界另一側的記憶體一次性全部回收

V8引擎的垃圾回收

3. 兩者結合

在V8的回收政策中,Mark-Sweep和Mark-Conpact兩者是結合使用的。

由于Mark-Conpact需要移動對象,是以它的執行速度不可能很快,在取舍上,V8主要使用Mark-Sweep,在空間不足以對從新生代中晉升過來的對象進行配置設定時,才使用Mark-Compact。

總結

V8的垃圾回收機制分為新生代和老生代。

新生代主要使用Scavenge進行管理,主要實作是Cheney算法,将記憶體平均分為兩塊,使用空間叫From,閑置空間叫To,新對象都先配置設定到From空間中,在空間快要占滿時将存活對象複制到To空間中,然後清空From的記憶體空間,此時,調換From空間和To空間,繼續進行記憶體配置設定,當滿足那兩個條件時對象會從新生代晉升到老生代。

老生代主要采用Mark-Sweep和Mark-Compact算法,一個是标記清除,一個是标記整理。兩者不同的地方是,Mark-Sweep在垃圾回收後會産生碎片記憶體,而Mark-Compact在清除前會進行一步整理,将存活對象向一側移動,随後清空邊界的另一側記憶體,這樣空閑的記憶體都是連續的,但是帶來的問題就是速度會慢一些。在V8中,老生代是Mark-Sweep和Mark-Compact兩者共同進行管理的。

以上就是本文的全部内容,書寫過程中參考了很多中外文章,參考書籍包括樸大大的《深入淺出NodeJS》以及《JavaScript進階程式設計》等。我們這裡并沒有對具體的算法實作進行探讨,感興趣的朋友可以繼續深入研究一下。

繼續閱讀