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異常退出。

博客原文

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章