天天看點

V8垃圾回收V8垃圾回收

V8垃圾回收

自己使用Node.js有差不多4年了,限于環境、個人等因素,對Node.js中V8的垃圾回收一直隻有一個模糊的概念,趁寫部落格這個契機,好好整理一下垃圾回收相關知識,加深認識。

垃圾回收的由來

垃圾回收,簡稱GC(Garbage Collection),指回收記憶體中的不再使用的記憶體。在C/C++等語言裡,需要通過malloc/free申請和釋放記憶體,稍不注意,申請的記憶體忘了釋放,程序運作久了就會導緻記憶體居高不下,即出現了記憶體洩漏,甚至有時過早的釋放記憶體,導緻程式奔潰。

如今絕大部分進階語言,已經不需要手動申請和釋放記憶體,但還是會存在頻繁申請與釋放記憶體的操作,其中釋放記憶體的操作主要由垃圾自動回收算法完成,簡單來說就是通過既定的規則,判斷出某塊記憶體不會再使用,然後釋放掉,标記成可再次配置設定或交還給作業系統。

Node.js中的GC

我們在說Node.js中的GC時,實際上指的是V8的GC,因為Node.js内部使用的是Chrome V8引擎,V8是一個JavaScript的運作時,負責編譯執行JS代碼、記憶體管理、GC等工作。

V8的學習有一定門檻,但要深入了解Node.js原理,成為真大神,V8是一道必須要邁過去的坎。

V8記憶體管理

Node.js在64位系統下,可使用的堆記憶體上限為1432M,之是以有上限限制,主要是因為如果可使用記憶體太大,V8在GC時将要耗費更多的資源和時間,而Stop-the-world方式會導緻程序暫停執行,可能帶來業務損失。

堆内空間記憶體管理,分為新生代、老生代兩大類,同時可細分為以下幾種:

  • New Space
除去部分大對象(大于1MB),大部分的新對象都誕生在新生代
  • Old Space
大部分是從New Space中晉升過來的
  • Large Object Space
大于1MB的記憶體配置設定請求,會直接歸類為Large Object Space,存放在更大的記憶體頁中,GC時不會被移動或複制
  • Map Space

所有在堆上配置設定的對象都帶有指向它的隐藏類的指針,隐藏類儲存在Map Space

隐藏類主要目的是為了優化對象通路速度,因為JS是動态類型語言,編譯後,無法通過記憶體相對偏移快速通路屬性,而借助隐藏類可以優化對象屬性通路速度

  • Code Space
代碼對象,會配置設定在這,唯一擁有執行權限的記憶體

除了New Space,其他均在老生代空間内,在64位系統上老生代空間上限為1400MB。

之是以把堆内空間分為新生代與老生代,是基于一個經驗的總結:“越是剛配置設定記憶體的對象,往往死的早,而長期在記憶體中的對象,更有可能長命百歲”,把堆内空間分為兩類,應用不同的GC算法,觸發頻率也有所不同。

新生代的GC

64位系統,新生代記憶體大小上限預設為32MB,由于隻有一半可以使用,實際能用配置設定給程式的隻有16MB。新生代在較小空間限制下,GC一般在0~3ms内,V8設計的目标在1ms以下,超過1ms的一般是bug或者使用者代碼發生了記憶體問題。

Scavenge算法

新生代空間被分為兩個相等的部分:from與to,分别用來使用和GC複制。在整理GC算法的過程中,發現有兩個版本的Scavenge算法描述,其中一個是alinode官方部落格中的文章,另外一個是大部分其他文章。

alinode的部落格文章中,細節詳細,感覺可信度更高,但不排除是V8版本不同導緻的差異。

通用版:

記憶體配置設定發生在from部分,當from沒有足夠空間可配置設定時,觸發一次GC,檢查from中的對象,将存活的copy到to中,二次存活的對象會晉升到Old Space,當from中對象copy完成,from與to對調角色。

alinode部落格版本:

記憶體配置設定發生在to部分,當to沒有足夠空間可配置設定時,觸發一次GC,先對調from與to,然後檢查from中的對象,将存活的copy到to中,二次存活的對象會晉升到Old Space。

兩個版本的差異是使用中的部分稱為to還是from,以及角色對調的時機是回收前還是回收後,不影響了解算法。

老生代的GC

寫屏障

寫屏障是一種為了新生代GC更快速的技巧,用專門的資料結構記錄老生代對象中指向新生代對象的指針,這種資料結構即稱為寫屏障。之是以需要寫屏障,因為新生代GC時,判斷對象存活的标準是通過周遊其他存活對象判斷目标是否可達,而如果周遊發生在老生代空間,由于其大小是新生代的N倍,需要的時間過長,是以利用寫屏障這種手段可以避免在進行GC時周遊老生代空間。

寫屏障發生在往對象寫入指針的過程中,會檢查被寫入的指針是否由老生代對象指向新生代對象,其判斷依據是檢查指針兩端的記憶體頁所屬新、老生代空間。

由于V8的記憶體頁按照1MB對齊,通過位運算将指針的後20位置0,得到的就是其所指向位址的記憶體頁位址,然後再擷取記憶體頁的頭資訊,可快速判斷位址所屬空間是新生代還是老生代。

标記

存在兩個标記位圖:

  • 已配置設定标記位圖,針對記憶體頁中每一個可配置設定的字,使用1bit表示其是否已配置設定出去,可用于快速掃描活躍記憶體
  • 狀态标記位圖,V8中對象大小以2個字長對齊,狀态标記位圖以2bit為一個單元,共可表示4種狀态

GC标記階段采用三色标記法,将顔色資訊記錄在狀态标記位圖中:

  • 白色,尚未被GC發現
  • 灰色,已被GC發現,但鄰接對象還未處理完
  • 黑色,已被GC發現,且鄰接對象已處理完

初始狀态,記憶體頁中所有對象都是白色,标記采用深度優先搜尋算法,步驟如下:

  1. 根可達對象标記為灰,并push進棧
  2. pop出棧一個對象,标記為黑
  3. 将對象的鄰接對象标記為灰,并push進棧,回到步驟二直至棧為空

上述步驟遇到大對象可能導緻棧溢出,做法是當出現溢出時隻标記為灰但不push進棧,棧為空後GC會再次掃描,将之前的灰色對象push進棧繼續處理。是以若程式建立過多的大對象,就會觸發多次堆掃描,影響GC效率。

最終狀态,記憶體頁中的對象全部被标記,不存在灰色,白色為可回收,黑色為不可回收。

Sweeping回收

掃描記憶體頁的對象标記位圖,将白色-死亡對象對應的的記憶體位址添加到空閑記憶體連結清單中,同時将對應的已配置設定位圖示志更新為未配置設定狀态。

Compacting回收

将頁中的所有黑色-存活對象全部轉移到另外一個記憶體頁中,原先的記憶體頁可以交還給作業系統。

增量标記與惰性清理

V8之前的GC,會停止程式的執行,然後掃描整個堆,回收完記憶體後才能重新運作程式,每次暫停時間可以到幾百甚至上千毫秒。2012年,Google引入了兩項改進措施:增量标記和惰性清理。

當堆大小達到一定門檻值後啟用,啟用之後每當配置設定一定量的記憶體時,程式暫停執行幾十毫秒并進行一次增量标記,采用與普通标記一樣的三色标記算法。由于增量标記并不會完整标記堆中所有對象的狀态,在程式恢複執行後,對象狀态可能發生變化。

在普通标記中,黑色對象不會出現白色鄰接對象,而在增量标記中有可能出現這樣的情況,導緻回收存活對象。為避免這種情問題,增加了與寫屏障一樣的機制,在黑對象中有指針指向白對象時,把黑對象重新設定回灰色。

增量标記完成後,就開始惰性清理,也就是将記憶體頁中死亡對象逐漸清理,而不是一次清理全部堆空間。

其他優化

  • black allocation
V8 5.x引入了black allocation,将所有新出現在Old Space的對象直接标記為黑色,放在特殊的記憶體頁中,可躲過一次GC的标記,因為根據經驗,新出現在Old Space的對象繼續存活可能性極大
  • concurrent sweeping
其他線程負責清理,不影響住主線程的執行
  • parallel sweeping
多個其他線程負責清理,提高機關時間GC吞吐量

指針識别

計算機記憶體中都是0與1的組合,任意取連續N位,其字面含義都是一個數,但對計算機而言有兩種含義:

  • 數值
數本身,其含義就是一個二進制形式的數
  • 指針
指向記憶體中另一塊位址,目标位址内容也是數值或指針

如果沒有額外資訊,單單給出記憶體中N位的内容,無法判斷其是數值還是指針,V8通過其它方式可實作無需其它資訊,判斷内容是數字還是指針,稱為指針精準識别。

V8按字對齊的方式在記憶體中存儲對象,64位系統,一個字長是8個位元組,按字對齊可以保證指針的後三位必定為0。對于整數,64位系統下,字的前32位用于表示有符号整數,後32位置0。32位系統下,字的前31位用于存放整數,最後一位置0。

基于上述前提,V8按字對齊并且讓每個字的最後一位空了出來,這空出來的一位用做于數值與指針的标志位,0表示字的内容為整數,1表示字的内容為指針。這對于GC來說是一個非常大的幫助,堆掃描時可以快速識别指針與數值。

V8GC觸發

當程式觸發記憶體申請,發現記憶體不夠時會觸發一次GC,然後再次嘗試申請,最多重試3次,若最後一次申請失敗,程式OOM異常退出。

部落格原文

繼續閱讀