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發現,且鄰接對象已處理完
初始狀態,內存頁中所有對象都是白色,標記採用深度優先搜索算法,步驟如下:
- 根可達對象標記爲灰,並push進棧
- pop出棧一個對象,標記爲黑
- 將對象的鄰接對象標記爲灰,並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異常退出。