超詳細的node垃圾回收機制

神經叨發表於2019-06-03

前言

垃圾回收器是一把十足的雙刃劍。其好處是可以大幅簡化程式的記憶體管理程式碼,因為記憶體管理無需程式設計師來操作,由此也減少了(但沒有根除)長時間運轉的程式的記憶體洩漏。對於某些程式設計師來說,它甚至能夠提升程式碼的效能。

另一方面,選擇垃圾回收器也就意味著程式當中無法完全掌控記憶體,而這正是移動終端開發的癥結。對於JavaScript,程式中沒有任何記憶體管理的可能——ECMAScript標準中沒有暴露任何垃圾回收器的介面。網頁應用既沒有辦法管理記憶體,也沒辦法給垃圾回收器進行提示。

nodeJs檢視垃圾回收日誌的方式主要是在啟動時新增 --trace_gc 引數。

關於垃圾回收

垃圾回收器要解決的最基本問題就是,辨別需要回收的記憶體。一旦辨別完畢,這些記憶體區域即可在未來的分配中重用,或者是返還給作業系統。一個物件當它不是處於活躍狀態的時候它就死了(廢話)。一個物件處於活躍狀態,當且僅當它被一個根物件或另一個活躍物件指向。根物件被定義為處於活躍狀態,是瀏覽器或V8所引用的物件。比如說,被區域性變數所指向的物件屬於根物件,因為它們的棧被視為根物件;全域性物件屬於根物件,因為它們始終可被訪問;瀏覽器物件,如DOM元素,也屬於根物件,儘管在某些場合下它們只是弱引用。

從側面來說,上面的定義非常寬鬆。實際上我們可以說,當一個物件可被程式引用時,它就是活躍的。比如:

function f() {
  var obj = {x: 12}
  g()  // 可能包含一個死迴圈
  return obj.x
}
複製程式碼

這裡的obj.x和obj都是活躍的,儘管對其的再度引用是在死迴圈之後。

很遺憾,我們無法精確地解決這個問題,因為這個問題實際等價於停機問題,無法確定。因此我們做一個等價約定:如果一個物件可經由某個被定義為活躍物件的物件,通過某個指標鏈所訪問,則它就是活躍的。其他的都被視為垃圾。

垃圾回收過程

GC

垃圾回收機制有多種,但最常用的就是以下幾種:

垃圾回收機制

分代回收

在V8中所有的JavaScript物件都是通過堆來分配的。為了提高垃圾回收的效率,V8將堆分為新生代老生代兩個部分,其中新生代為存活時間較短的物件(需要經常進行垃圾回收),而老生代為存活時間較長的物件(垃圾回收的頻率較低),如圖

新生代 and 老生代

新生代

新生代中的物件主要通過 Scavenge 演算法進行垃圾回收。在 Scavenge 的具體實現中,主要採用了 Cheney 演算法。

Cheney 演算法是一種採用複製的方式實現的垃圾回收演算法。它將堆記憶體一分為二,每一部分空間成為 semispace。在這兩個 semispace 空間中,只有一個處於使用中,另一個處於閒置中。處於使用中的 semispace 空間成為 From 空間,處於閒置狀態的空間成為 To 空間。當我們分配物件時,先是在 From 空間中進行分配。當開始進行垃圾回收時,會檢查 From 空間中的存活物件,這些存活物件將被複制到 To 空間中,而非存活物件佔用的空間將被釋放。完成複製後, From 空間和 To 空間的角色發生對換。

Scavenge 的缺點是隻能使用堆記憶體的一半,但 Scavenge 由於只複製存活的物件,並且對於生命週期短的場景存活物件只佔少部分,所以它在時間效率上表現優異。Scavenge 是典型的犧牲空間換取時間的演算法,無法大規模地應用到所有的垃圾回收中,但非常適合應用在新生代中。

Scavenge

物件是如何釋放的呢?

有個叫可達性分析演算法的概念,即通過一系列的稱為“GC ROOT”的物件作為起始點。從這些節點開始向下搜尋。搜尋走過的路徑稱為引用鏈。當一個物件到GC ROOT沒有任何引用鏈時,則證明此物件是不可用的。當然在虛擬機器判斷要被釋放的物件裡面,即使在可達性分析演算法中不可達的物件,也並非是立即釋放的。如果物件在進行可達性分析後發現沒有與GC ROOTS相連線的引用鏈。將會對它進行一次標記,並進行刷選。它會放進一個佇列中依次進行回收。如果這時又有物件引用到它,它就不會被回收。

晉升

物件從新生代中移動到老生代中的過程稱為晉升。

From 空間中的存活物件在複製到 To 空間之前需要進行檢查,在一定條件下,需要將存活週期長的物件移動到老生代中,也就是完成物件的晉升。

晉升條件主要有兩個:

  • 物件是否經歷過一次 Scavenge 回收,是的話,則移動到老生代
  • To 空間已經使用超過 25%,To 空間物件移動到老生代

設定 25% 這個限制值得原因是當這次 Scavenge 回收完成後,這個 To 空間將變成 From 空間,接下來的記憶體分配將在這個空間中進行,如果佔比過高,會影響後續的記憶體分配。

寫屏障

上面有一個細節被忽略了:如果新生區中某個物件,只有一個指向它的指標,而這個指標恰好是在老生區的物件當中,我們如何才能知道新生區中那個物件是活躍的呢?顯然我們並不希望將老生區再遍歷一次,因為老生區中的物件很多,這樣做一次消耗太大。

為了解決這個問題,實際上在寫緩衝區中有一個列表(我們稱之為CrossRefList),列表中記錄了所有老生區物件指向新生區的情況。新物件誕生的時候,並不會有指向它的指標,而當有老生區中的物件出現指向新生區物件的指標時,我們便記錄下來這樣的跨區指向。由於這種記錄行為總是發生在寫操作時,它被稱為寫屏障——因為每個寫操作都要經歷這樣一關。

寫屏障

老生代

老生代的記憶體空間較大且存活物件較多,因此其垃圾回收演算法也就沒有新生代那麼簡單了。為此V8使用了標記-清除演算法 (Mark-Sweep)進行垃圾回收,並使用標記-壓縮演算法 (Mark-Compact)整理記憶體碎片,提高記憶體的利用率。老生代的垃圾回收演算法步驟如下:

1. 對老生代進行第一遍掃描,標記存活的物件
2. 對老生代進行第二次掃描,清除未被標記的物件
3. 將存活物件往記憶體的一端移動
4. 清除掉存活物件邊界外的記憶體
複製程式碼

Mark-Sweep

Mark-Sweep 是標記清除的意思,它分為兩個階段,標記清理。Mark-Sweep 在標記階段遍歷堆中的所有物件,並標記活著的物件,在隨後的清除階段中,只清除未被標記的物件。

MarkSweep

演算法機制

標記階段,所有堆上的活躍物件都會被標記。每個頁(注意,V8的記憶體頁是1MB的連續記憶體塊,與虛擬記憶體頁不同)都會包含一個用來標記的點陣圖,點陣圖中的每一位對應頁中的一字。這個標記非常有必要,因為指標可能會在任何字對齊的地方出現。顯然,這樣的點陣圖要佔據一定的空間(32位系統上佔據3.1%,64位系統上佔據1.6%),但所有的記憶體管理機制都需要這樣佔用,因此這種做法並不過分。除此之外,另有2位來表示標記物件的狀態。由於物件至少有2字長,因此這些位不會重疊。

狀態一共有三種:如果一個物件的狀態為,那麼它尚未被垃圾回收器發現;如果一個物件的狀態為,那麼它已被垃圾回收器發現,但它的鄰接物件仍未全部處理完畢;如果一個物件的狀態為,則它不僅被垃圾回收器發現,而且其所有鄰接物件也都處理完畢。

如果將堆中的物件看作由指標相互聯絡的有向圖,標記演算法的核心實際是深度優先搜尋。在標記的初期,點陣圖是空的,所有物件也都是白的。從根可達的物件會被染色為灰色,並被放入標記用的一個單獨分配的雙端佇列。標記階段的每次迴圈,GC會將一個物件從雙端佇列中取出,染色為黑,然後將它的鄰接物件染色為灰,並把鄰接物件放入雙端佇列。這一過程在雙端佇列為空且所有物件都變黑時結束。

特別大的物件,如長陣列,可能會在處理時分片,以防溢位雙端佇列。如果雙端佇列溢位了,則物件仍然會被染為灰色,但不會再被放入佇列(這樣他們的鄰接物件就沒有機會再染色了)。因此當雙端佇列為空時,GC仍然需要掃描一次,確保所有的灰物件都成為了黑物件。對於未被染黑的灰物件,GC會將其再次放入佇列,再度處理。

標記演算法結束時,所有的活躍物件都被染為了黑色,而所有的死物件則仍是白的。這一結果正是清理和緊縮兩個階段所期望的。

類似三色標記法大致如圖:

第一步

三色標記1

第二步

三色標記2

第三步

三色標記3

清理階段,清理演算法掃描連續存放的死物件,將其變為空閒空間,並將其新增到空閒記憶體連結串列中。每一頁都包含數個空閒記憶體連結串列,其分別代表小記憶體區(<256字)、中記憶體區(<2048字)、大記憶體區(<16384字)和超大記憶體區(其它更大的記憶體)。

清理演算法非常簡單,只需遍歷頁的點陣圖,搜尋連續的白物件。空閒記憶體連結串列大量被scavenge演算法用於分配存活下來的活躍物件,但也被緊縮演算法用於移動物件。有些型別的物件只能被分配在老生區,因此空閒記憶體連結串列也被它們使用。

Mark-Compact

Mark-Sweep 最大的問題是在進行一次標記清除回收後,記憶體空間會出現不連續的狀態。這種記憶體碎片會對後續的記憶體分配造成問題,因為很可能出現需要分配一個大物件的情況,這時所有的碎片空間都無法完成此次分配,就會提前觸發垃圾回收,而這次回收是不必要的。

為了解決 Mark-Sweep 的記憶體碎片問題,Mark-Compact被提出來。Mark-Compact是標記整理的意思,是在 Mark-Sweep的基礎上演進而來的。它們的差別在於物件在標記為死亡後,在整理過程中,將活著的物件往一端移動,移動完成後,直接清理掉邊界外的記憶體。

MarkCompact

演算法機制

緊縮演算法會嘗試將物件從碎片頁(包含大量小空閒記憶體的頁)中遷移整合在一起,來釋放記憶體。這些物件會被遷移到另外的頁上,因此也可能會新分配一些頁。而遷出後的碎片頁就可以返還給作業系統了。

遷移整合的過程非常複雜,大概過程是這樣的。對目標碎片頁中的每個活躍物件,在空閒記憶體連結串列中分配一塊其它頁的區域,將該物件複製至新頁,並在碎片頁中的該物件上寫上轉發地址。遷出過程中,物件中的舊地址會被記錄下來,這樣在遷出結束後V8會遍歷它所記錄的地址,將其更新為新的地址。由於標記過程中也記錄了不同頁之間的指標,此時也會更新這些指標的指向。注意,如果一個頁非常“活躍”,比如其中有過多需要記錄的指標,則地址記錄會跳過它,等到下一輪垃圾回收再進行處理。

全停頓

下表為3種主要垃圾回收演算法的簡單比較

垃圾回收演算法比較

在 Mark-Sweep 和 Mark-Compact 之間,由於 Mark-Compact 需要移動物件,所以它的執行速度不可能很快,所以在取捨上,V8 主要使用 Mark-Sweep,在空間不足以從新生代中晉升過來的物件進行分配時才使用 Mark-Compact 。為了避免出現 JavaScript應用邏輯與垃圾回收器看到的不一致的情況,垃圾回收的3種演算法都需要將應用邏輯暫停下來,這種行為稱為“全停頓” (stop-the-world)。

由於新生代配置的空間較小,存活物件較少,全停頓對新生代影響不大。但老生代通常配置的空間較大,且存活物件較多,全堆垃圾回收(full垃圾回收)的標記、清除、整理等動作造成的停頓就會比較可怕。

增量標記與惰性清理

增量標記

為了降低全堆垃圾回收帶來的停頓時間,V8先從標記階段入手,將原本要一口氣停頓完成的動作改成增量標記(Incremental Marking),也就是拆分為許多小“步進”,每做完一“步進”就讓JavaScript應用邏輯執行一小會兒,垃圾回收和應用邏輯交替執行直到標記階段完成。

增量標記允許堆的標記發生在幾次5-10毫秒(移動裝置)的小停頓中。增量標記在堆的大小達到一定的閾值時啟用,啟用之後每當一定量的記憶體分配後,指令碼的執行就會停頓並進行一次增量標記。就像普通的標記一樣,增量標記也是一個深度優先搜尋,並同樣採用白灰黑機制來分類物件。

但增量標記和普通標記不同的是,物件的圖譜關係可能發生變化!我們需要特別注意的是,那些從黑物件指向白物件的新指標。回憶一下,黑物件表示其已完全被垃圾回收器掃描,並不會再進行二次掃描。因此如果有“黑→白”這樣的指標出現,我們就有可能將那個白物件漏掉,錯當死物件處理掉。(標記過程結束後剩餘的白物件都被認為是死物件。)於是我們不得不再度啟用寫屏障。現在寫屏障不僅記錄“老→新”指標,同時還要記錄“黑→白”指標。一旦發現這樣的指標,黑物件會被重新染色為灰物件,重新放回到雙端佇列中。當演算法將該物件取出時,其包含的指標會被重新掃描,這樣活躍的白物件就不會漏掉。

惰性清理

增量標記完成後,惰性清理就開始了。所有的物件已被處理,因此非死即活,堆上多少空間可以變為空閒已經成為定局。此時我們可以不急著釋放那些空間,而將清理的過程延遲一下也並無大礙。因此無需一次清理所有的頁,垃圾回收器會視需要逐一進行清理,直到所有的頁都清理完畢。這時增量標記又蓄勢待發了。

在兩段的GC程式之間,引用關係可能發生了變化。所以,這種GC演算法也要寫屏障,來記錄引用關係的變化。雖然這種方式控制了中斷最高時間,但是由於中斷次數增加,GC總時間是增加的

增量標記

併發標記

併發式 GC(concurrent),即在垃圾回收的同時不需要停止程式的執行,兩者可以同時進行,只有在個別時候需要短暫停下來讓垃圾回收器做一些特殊的操作。但是這種方式也要面對增量回收的問題,所以也要進行寫屏障操作。

一般能在垃圾回收的過程中修改物件的存在,不管是垃圾回收器本身還是執行時,或者是正在執行的程式,都統稱為 mutator(翻譯不詳)。

增量標記和併發標記對程式執行更寬鬆的GC,都需要執行時從整體設計上保證mutator不會在垃圾回收的過程中與垃圾回收器同時修改物件,造成無法預料的後果。比如清潔阿姨打掃一個房間的時候可以把房間的門先關上,這樣熊孩子就進不來了,但熊孩子們依然可以在屋子裡的其他地方活動。在程式執行的同時進行垃圾回收雖然可能導致垃圾回收的週期變長(即降低了垃圾回收單位時間內的吞吐量),但是可以降低每次暫停的時間,進而提高程式的響應效率。

然而這種方式也並未做到完全不暫停原程式的執行,在某些特定的GC階段還是要暫停原程式。

標記

準確式 GC (Accurate GC)

雖然 ECMA 中沒有規定整數型別,Number 都是 IEEE 浮點數,但是由於在 CPU 上浮點數相關的操作通常比整型操作要慢,大多數的 Javascript 引擎都在底層實現中引入了整型,用於提升for迴圈和陣列索引等場景的效能,並配以一定的技巧來將指標和整數(可能還有浮點數)“壓縮”到同一種資料結構中節省空間。

在 V8 中,物件都按照 4 位元組(32 位機器)或者 8 位元組(64 位機器)對齊,因此物件的地址都能被 4 或者 8 整除,這意味著地址的二進位制表示最後 2 位或者 3 位都會是 0,也就是說所有指標的這幾位是可以空出來使用的。如果將另一種型別的資料的最後一位也保留出來另作他用,就可以通過判斷最後一位是 0 還是 1,來直接分辨兩種型別。那麼,這另一種型別的資料就可以直接塞在前面幾位,而不需要沿著一個指標去讀取它的實際內容。在 V8 的語境內這種結構叫做小整數(SMI, small integer),這是語言實現中歷史悠久的常用技巧 tagging 的一種。V8 預留所有的字(word,32位機器是 4 位元組,64 位機器是8位元組)的最後一位用於標記(tag)這個字中的內容的型別,1 表示指標,0 表示整數,這樣給定一個記憶體中的字,它能通過檢視最後一位快速地判斷它包含的指標還是整數,並且可以將整數直接儲存在字中,無需先通過一個指標間接引用過來,節省空間。

由於 V8 能夠通過檢視字的最後一位,快速地分辨指標和整數,在GC的時候,V8能夠跳過所有的整數,更快地沿著指標掃描堆中的物件。由於在 GC 的過程中,V8 能夠準確地分辨它所遍歷到的每一塊記憶體的內容屬於什麼型別,因此V8的垃圾回收器是準確式的。與此相對的是保守式 GC,即垃圾回收器因為某些設計導致無法確定記憶體中內容的型別,只能保守地先假設它們都是指標然後再加以驗證,以免誤回收不該回收的記憶體,因此可能誤將資料當作指標,進而誤以為一些物件仍然被引用,無法回收而浪費記憶體。同時因為保守式的垃圾回收器沒有十足的把握區分指標和資料,也就不能確保自己能安全地修改指標,無法使用那些需要移動物件,更新指標的演算法。

準確式GC

準確式的 GC 避免了保守式 GC 帶來的弊端,能夠儘早無遺漏地回收記憶體,並且能夠在GC過程中移動物件以緩解記憶體碎片問題。

相關文章