淺談V8引擎中的垃圾回收機制

天鑲發表於2014-03-19

這篇文章的所有內容均來自 樸靈的《深入淺出Node.js》及A tour of V8:Garbage Collection,後者還有中文翻譯版V8 之旅: 垃圾回收器,我在這裡只是做了個記錄和結合

垃圾回收器

JavaScript的垃圾回收器

JavaScript使用垃圾回收機制來自動管理記憶體。垃圾回收是一把雙刃劍,其好處是可以大幅簡化程式的記憶體管理程式碼,降低程式設計師的負擔,減少因長時間運轉而帶來的記憶體洩露問題。但使用了垃圾回收即意味著程式設計師將無法掌控記憶體。ECMAScript沒有暴露任何垃圾回收器的介面。我們無法強迫其進行垃圾回收,更無法干預記憶體管理

Node的記憶體管理問題

在瀏覽器中,V8引擎例項的生命週期不會很長(誰沒事一個頁面開著幾天幾個月不關),而且執行在使用者的機器上。如果不幸發生記憶體洩露等問題,僅僅會影響到一個終端使用者。且無論這個V8例項佔用了多少記憶體,最終在關閉頁面時記憶體都會被釋放,幾乎沒有太多管理的必要(當然並不代表一些大型Web應用不需要管理記憶體)。但如果使用Node作為伺服器,就需要關注記憶體問題了,一旦記憶體發生洩漏,久而久之整個服務將會癱瘓(伺服器不會頻繁的重啟)

V8的記憶體限制

存在限制

Node與其他語言不同的一個地方,就是其限制了JavaScript所能使用的記憶體(64位為1.4GB,32位為0.7GB),這也就意味著將無法直接操作一些大記憶體物件。這很令人匪夷所思,因為很少有其他語言會限制記憶體的使用

為何限制

V8之所以限制了記憶體的大小,表面上的原因是V8最初是作為瀏覽器的JavaScript引擎而設計,不太可能遇到大量記憶體的場景,而深層次的原因則是由於V8的垃圾回收機制的限制。由於V8需要保證JavaScript應用邏輯與垃圾回收器所看到的不一樣,V8在執行垃圾回收時會阻塞JavaScript應用邏輯,直到垃圾回收結束再重新執行JavaScript應用邏輯,這種行為被稱為“全停頓”(stop-the-world)。若V8的堆記憶體為1.5GB,V8做一次小的垃圾回收需要50ms以上,做一次非增量式的垃圾回收甚至要1秒以上。這樣瀏覽器將在1s內失去對使用者的響應,造成假死現象。如果有動畫效果的話,動畫的展現也將顯著受到影響

突破限制

當然這個限制是可以開啟的,類似於JVM,我們通過在啟動node時可以傳遞--max-old-space-size或--max-new-space-size來調整記憶體限制的大小,前者確定老生代的大小,單位為MB,後者確定新生代的大小,單位為KB。這些配置只在V8初始化時生效,一旦生效不能再改變

V8的堆構成

V8的堆其實並不只是由老生代和新生代兩部分構成,可以將堆分為幾個不同的區域:
* 新生代記憶體區:大多數的物件被分配在這裡,這個區域很小但是垃圾回特別頻繁
* 老生代指標區:屬於老生代,這裡包含了大多數可能存在指向其他物件的指標的物件,大多數從新生代晉升的物件會被移動到這裡
* 老生代資料區:屬於老生代,這裡只儲存原始資料物件,這些物件沒有指向其他物件的指標
* 大物件區:這裡存放體積超越其他區大小的物件,每個物件有自己的記憶體,垃圾回收其不會移動大物件
* 程式碼區:程式碼物件,也就是包含JIT之後指令的物件,會被分配在這裡。唯一擁有執行許可權的記憶體區
* Cell區、屬性Cell區、Map區:存放Cell、屬性Cell和Map,每個區域都是存放相同大小的元素,結構簡單

每個區域都是由一組記憶體頁構成,記憶體頁是V8申請記憶體的最小單位,除了大物件區的記憶體頁較大以外,其他區的記憶體頁都是1MB大小,而且按照1MB對齊。記憶體頁除了儲存的物件,還有一個包含後設資料和標識資訊的頁頭,以及一個用於標記哪些物件是活躍物件的點陣圖區。另外每個記憶體頁還有一個單獨分配在另外記憶體區的槽緩衝區,裡面放著一組物件,這些物件可能指向其他儲存在該頁的物件。垃圾回收器只會針對新生代記憶體區、老生代指標區以及老生代資料區進行垃圾回收

V8的垃圾回收機制

如何判斷回收內容

如何確定哪些記憶體需要回收,哪些記憶體不需要回收,這是垃圾回收期需要解決的最基本問題。我們可以這樣假定,一個物件為活物件當且僅當它被一個根物件或另一個活物件指向。根物件永遠是活物件,它是被瀏覽器或V8所引用的物件。被區域性變數所指向的物件也屬於根物件,因為它們所在的作用域物件被視為根物件。全域性物件(Node中為global,瀏覽器中為window)自然是根物件。瀏覽器中的DOM元素也屬於根物件

如何識別指標和資料

垃圾回收器需要面臨一個問題,它需要判斷哪些是資料,哪些是指標。由於很多垃圾回收演算法會將物件在記憶體中移動(緊湊,減少記憶體碎片),所以經常需要進行指標的改寫

目前主要有三種方法來識別指標:
1. 保守法:將所有堆上對齊的字都認為是指標,那麼有些資料就會被誤認為是指標。於是某些實際是數字的假指標,會背誤認為指向活躍物件,導致記憶體洩露(假指標指向的物件可能是死物件,但依舊有指標指向——這個假指標指向它)同時我們不能移動任何記憶體區域。
2. 編譯器提示法:如果是靜態語言,編譯器能夠告訴我們每個類當中指標的具體位置,而一旦我們知道物件時哪個類例項化得到的,就能知道物件中所有指標。這是JVM實現垃圾回收的方式,但這種方式並不適合JS這樣的動態語言
3. 標記指標法:這種方法需要在每個字末位預留一位來標記這個欄位是指標還是資料。這種方法需要編譯器支援,但實現簡單,而且效能不錯。V8採用的是這種方式。V8將所有資料以32bit字寬來儲存,其中最低一位保持為0,而指標的最低兩位為01

V8的回收策略

自動垃圾回收演算法的演變過程中出現了很多演算法,但是由於不同物件的生存週期不同,沒有一種演算法適用於所有的情況。所以V8採用了一種分代回收的策略,將記憶體分為兩個生代:新生代和老生代。新生代的物件為存活時間較短的物件,老生代中的物件為存活時間較長或常駐記憶體的物件。分別對新生代和老生代使用不同的垃圾回收演算法來提升垃圾回收的效率。物件起初都會被分配到新生代,當新生代中的物件滿足某些條件(後面會有介紹)時,會被移動到老生代(晉升)

V8的分代記憶體

預設情況下,64位環境下的V8引擎的新生代記憶體大小32MB、老生代記憶體大小為1400MB,而32位則減半,分別為16MB和700MB。V8記憶體的最大保留空間分別為1464MB(64位)和732MB(32位)。具體的計算公式是4*reserved_semispace_space_ + max_old_generation_size_,新生代由兩塊reserved_semispace_space_組成,每塊16MB(64位)或8MB(32位)

新生代

新生代的特點

大多數的物件被分配在這裡,這個區域很小但是垃圾回特別頻繁。在新生代分配記憶體非常容易,我們只需要儲存一個指向記憶體區的指標,不斷根據新物件的大小進行遞增即可。當該指標到達了新生代記憶體區的末尾,就會有一次清理(僅僅是清理新生代)

新生代的垃圾回收演算法

新生代使用Scavenge演算法進行回收。在Scavenge演算法的實現中,主要採用了Cheney演算法。

Cheney演算法演算法是一種採用複製的方式實現的垃圾回收演算法。它將記憶體一分為二,每一部分空間稱為semispace。在這兩個semispace中,一個處於使用狀態,另一個處於閒置狀態。處於使用狀態的semispace空間稱為From空間,處於閒置狀態的空間稱為To空間,當我們分配物件時,先是在From空間中進行分配。當開始進行垃圾回收演算法時,會檢查From空間中的存活物件,這些存活物件將會被複制到To空間中(複製完成後會進行緊縮),而非活躍物件佔用的空間將會被釋放。完成複製後,From空間和To空間的角色發生對換。也就是說,在垃圾回收的過程中,就是通過將存活物件在兩個semispace之間進行復制。可以很容易看出來,使用Cheney演算法時,總有一半的記憶體是空的。但是由於新生代很小,所以浪費的記憶體空間並不大。而且由於新生代中的物件絕大部分都是非活躍物件,需要複製的活躍物件比例很小,所以其時間效率十分理想。複製的過程採用的是BFS(廣度優先遍歷)的思想,從根物件出發,廣度優先遍歷所有能到達的物件

具體的執行過程大致是這樣:

首先將From空間中所有能從根物件到達的物件複製到To區,然後維護兩個To區的指標scanPtr和allocationPtr,分別指向即將掃描的活躍物件和即將為新物件分配記憶體的地方,開始迴圈。迴圈的每一輪會查詢當前scanPtr所指向的物件,確定物件內部的每個指標指向哪裡。如果指向老生代我們就不必考慮它了。如果指向From區,我們就需要把這個所指向的物件從From區複製到To區,具體複製的位置就是allocationPtr所指向的位置。複製完成後將scanPtr所指物件內的指標修改為新複製物件存放的地址,並移動allocationPtr。如果一個物件內部的所有指標都被處理完,scanPtr就會向前移動,進入下一個迴圈。若scanPtr和allocationPtr相遇,則說明所有的物件都已被複制完,From區剩下的都可以被視為垃圾,可以進行清理了

舉個栗子(以及湊篇幅),如果有類似如下的引用情況:

          +----- A物件
          |
根物件----+----- B物件 ------ E物件
          |
          +----- C物件 ----+---- F物件 
                           |
                           +---- G物件 ----- H物件

    D物件

在執行Scavenge之前,From區長這幅模樣

+---+---+---+---+---+---+---+---+--------+
| A | B | C | D | E | F | G | H |        |
+---+---+---+---+---+---+---+---+--------+

那麼首先將根物件能到達的ABC物件複製到To區,於是乎To區就變成了這個樣子:

          allocationPtr
             ↓ 
+---+---+---+----------------------------+
| A | B | C |                            |
+---+---+---+----------------------------+
 ↑
scanPtr  

接下來進入迴圈,掃描scanPtr所指的A物件,發現其沒有指標,於是乎scanPtr移動,變成如下這樣

          allocationPtr
             ↓ 
+---+---+---+----------------------------+
| A | B | C |                            |
+---+---+---+----------------------------+
     ↑
  scanPtr  

接下來掃描B物件,發現其有指向E物件的指標,且E物件在From區,那麼我們需要將E物件複製到allocationPtr所指的地方並移動allocationPtr指標:

            allocationPtr
                 ↓ 
+---+---+---+---+------------------------+
| A | B | C | E |                        |
+---+---+---+---+------------------------+
     ↑
  scanPtr  

B物件裡所有指標都已被複制完,所以移動scanPtr:

            allocationPtr
                 ↓ 
+---+---+---+---+------------------------+
| A | B | C | E |                        |
+---+---+---+---+------------------------+
         ↑
      scanPtr  

接下來掃描C物件,C物件中有兩個指標,分別指向F物件和G物件,且都在From區,先複製F物件到To區:

                allocationPtr
                     ↓ 
+---+---+---+---+---+--------------------+
| A | B | C | E | F |                    |
+---+---+---+---+---+--------------------+
         ↑
      scanPtr  

然後複製G物件到To區

                    allocationPtr
                         ↓ 
+---+---+---+---+---+---+----------------+
| A | B | C | E | F | G |                |
+---+---+---+---+---+---+----------------+
         ↑
      scanPtr  

這樣C物件內部的指標已經複製完成了,移動scanPtr:

                    allocationPtr
                         ↓ 
+---+---+---+---+---+---+----------------+
| A | B | C | E | F | G |                |
+---+---+---+---+---+---+----------------+
             ↑
          scanPtr  

逐個掃描E,F物件,發現其中都沒有指標,移動scanPtr:

                    allocationPtr
                         ↓ 
+---+---+---+---+---+---+----------------+
| A | B | C | E | F | G |                |
+---+---+---+---+---+---+----------------+
                     ↑
                  scanPtr  

掃描G物件,發現其中有一個指向H物件的指標,且H物件在From區,複製H物件到To區,並移動allocationPtr:

                        allocationPtr
                             ↓ 
+---+---+---+---+---+---+---+------------+
| A | B | C | E | F | G | H |            |
+---+---+---+---+---+---+---+------------+
                     ↑
                  scanPtr  

完成後由於G物件沒有其他指標,且H物件沒有指標移動scanPtr:

                        allocationPtr
                             ↓ 
+---+---+---+---+---+---+---+------------+
| A | B | C | E | F | G | H |            |
+---+---+---+---+---+---+---+------------+
                             ↑
                           scanPtr  

此時scanPtr和allocationPtr重合,說明覆制結束

可以對比一下From區和To區在複製完成後的結果:

//From區
+---+---+---+---+---+---+---+---+--------+
| A | B | C | D | E | F | G | H |        |
+---+---+---+---+---+---+---+---+--------+
//To區
+---+---+---+---+---+---+---+------------+
| A | B | C | E | F | G | H |            |
+---+---+---+---+---+---+---+------------+

D物件沒有被複制,它將被作為垃圾進行回收

寫屏障

如果新生代中的一個物件只有一個指向它的指標,而這個指標在老生代中,我們如何判斷這個新生代的物件是否存活?為了解決這個問題,需要建立一個列表用來記錄所有老生代物件指向新生代物件的情況。每當有老生代物件指向新生代物件的時候,我們就記錄下來

物件的晉升

當一個物件經過多次新生代的清理依舊倖存,這說明它的生存週期較長,也就會被移動到老生代,這稱為物件的晉升。具體移動的標準有兩種:
1. 物件從From空間複製到To空間時,會檢查它的記憶體地址來判斷這個物件是否已經經歷過一個新生代的清理,如果是,則複製到老生代中,否則複製到To空間中
2. 物件從From空間複製到To空間時,如果To空間已經被使用了超過25%,那麼這個物件直接被複制到老生代

老生代

老生代的特點

老生代所儲存的物件大多數是生存週期很長的甚至是常駐記憶體的物件,而且老生代佔用的記憶體較多

老生代的垃圾回收演算法

老生代佔用記憶體較多(64位為1.4GB,32位為700MB),如果使用Scavenge演算法,浪費一半空間不說,複製如此大塊的記憶體消耗時間將會相當長。所以Scavenge演算法顯然不適合。V8在老生代中的垃圾回收策略採用Mark-Sweep和Mark-Compact相結合

Mark-Sweep(標記清除)

標記清除分為標記和清除兩個階段。在標記階段需要遍歷堆中的所有物件,並標記那些活著的物件,然後進入清除階段。在清除階段總,只清除沒有被標記的物件。由於標記清除只清除死亡物件,而死亡物件在老生代中佔用的比例很小,所以效率較高

標記清除有一個問題就是進行一次標記清楚後,記憶體空間往往是不連續的,會出現很多的記憶體碎片。如果後續需要分配一個需要記憶體空間較多的物件時,如果所有的記憶體碎片都不夠用,將會使得V8無法完成這次分配,提前觸發垃圾回收。

Mark-Compact(標記整理)

標記整理正是為了解決標記清除所帶來的記憶體碎片的問題。標記整理在標記清除的基礎進行修改,將其的清除階段變為緊縮極端。在整理的過程中,將活著的物件向記憶體區的一段移動,移動完成後直接清理掉邊界外的記憶體。緊縮過程涉及物件的移動,所以效率並不是太好,但是能保證不會生成記憶體碎片

演算法思路

標記清除和標記整理都分為兩個階段:標記階段、清除或緊縮階段

在標記階段,所有堆上的活躍物件都會被標記。每個記憶體頁有一個用來標記物件的點陣圖,點陣圖中的每一位對應記憶體頁中的一個字。這個點陣圖需要佔據一定的空間(32位下為3.1%,64位為1.6%)。另外有兩位用來標記物件的狀態,這個狀態一共有三種(所以要兩位)——白,灰,黑:
* 如果一個物件為白物件,它還沒未被垃圾回收器發現
* 如果一個物件為灰物件,它已經被垃圾回收器發現,但其鄰接物件尚未全部處理
* 如果一個物件為黑物件,說明他步進被垃圾回收器發現,其鄰接物件也全部被處理完畢了

如果將對中的物件看做由指標做邊的有向圖,標記演算法的核心就是深度優先搜尋。在初始時,點陣圖為空,所有的物件也都是白物件。從根物件到達的物件會背染色為灰色,放入一個單獨的雙端佇列中。標記階段的每次迴圈,垃圾回收器都會從雙端佇列中取出一個物件並將其轉變為黑物件,並將其鄰接的物件轉變為灰,然後把其鄰接物件放入雙端佇列。如果雙端佇列為空或所有物件都變成黑物件,則結束。特別大的物件,可能會在處理時進行分片,防止雙端佇列溢位。如果雙端佇列溢位,則物件仍然會成為灰物件,但不會被放入佇列中,這將導致其鄰接物件無法被轉變為灰物件。所以在雙端佇列為空時,需要掃描所有物件,如果仍有灰物件,將它們重新放入佇列中進行處理。標記結束後,所有的物件都應該非黑即白,白物件將成為垃圾,等待釋放

清除和緊縮階段都是以記憶體頁為單位回收記憶體

清除時垃圾回收器會掃描連續存放的死物件,將其變成空閒空間,並儲存到一個空閒空間的連結串列中。這個連結串列常被scavenge演算法用於分配被晉升物件的記憶體,但也被緊縮演算法用於移動物件

緊縮演算法會嘗試將碎片頁整合到一起來釋放記憶體。由於頁上的物件會被移動到新的頁上,需要重新分配一些頁。大致過程是,對目標碎片頁中的每個活躍物件,在空閒記憶體連結串列中分配一塊記憶體頁,將該物件複製過去,並在碎片頁中的該物件上寫上新的記憶體地址。隨後在遷出過程中,物件的舊地址將會被記錄下來,在遷出結束後,V8會遍歷所有它所記錄的舊物件的地址,將其更新為新地址。由於標記過程中也記錄了不同頁之間的指標,這些指標在此時也會進行更新。如果一個頁非常活躍,如其中有過多需要記錄的指標,那麼地址記錄會跳過它,等到下一輪垃圾回收進行處理

結合使用標記清除和標記整理

V8的老生代使用標記清除和標記整理結合的方式,主要採用標記清除演算法,如果空間不足以分配從新生代晉升過來的物件時,才使用標記整理

V8的優化

Incremental Marking(增量標記)

由於全停頓會造成了瀏覽器一段時間無響應,所以V8使用了一種增量標記的方式,將完整的標記拆分成很多部分,每做完一部分就停下來,讓JS的應用邏輯執行一會,這樣垃圾回收與應用邏輯交替完成。經過增量標記的改進後,垃圾回收的最大停頓時間可以減少到原來的1/6左右

惰性清理

由於標記完成後,所有的物件都已經被標記,不是死物件就是活物件,堆上多少空間格局已經確定。我們可以不必著急釋放那些死物件所佔用的空間,而延遲清理過程的執行。垃圾回收器可以根據需要逐一清理死物件所佔用的記憶體頁

其他

V8後續還引入了增量式整理(incremental compaction),以及並行標記和並行清理,通過並行利用多核CPU來提升垃圾回收的效能

相關文章