Node - 記憶體管理和垃圾回收

菜的黑人牙膏發表於2019-05-05

前言

從前端思維轉變到後端, 有一個很重要的點就是記憶體管理。以前寫前端因為只是在瀏覽器上執行, 所以對於記憶體管理一般不怎麼需要上心, 但是在伺服器端, 則需要斤斤計較記憶體。

V8的記憶體限制和垃圾回收機制

記憶體限制

記憶體限制 一般的後端語言開發中, 在基本的記憶體使用是沒有限制的。 但由於Node是基於V8構建的, 而V8對於記憶體的使用有一定的限制。 在預設情況下, 64位的機器大概可以使用1.4G, 而32則為0.7G的大小。關於為什麼要限制記憶體大小, 有兩個方面。一個是V8一開始是為瀏覽器服務的, 而在瀏覽器端這樣的記憶體大小是綽綽有餘的。另一個則是待會提到的垃圾回收機制, 垃圾回收會暫停Js的執行, 如果記憶體過大, 就會導致垃圾回收的時間變長, 從而導致Js暫停的時間過長

當然, 我們可以在啟動Node服務的時候, 手動設定記憶體的大小 如下:

node --max-old-space-size=768 // 設定老生代, 單位為MB  
node --max-semi-space-size=64 // 設定新生代, 單位為MB
複製程式碼

檢視記憶體
在Node環境中, 可以通過process.memoryUsage()來檢視記憶體分配

Node - 記憶體管理和垃圾回收

rss(resident set size):所有記憶體佔用,包括指令區和堆疊

heapTotal:V8引擎可以分配的最大堆記憶體,包含下面的 heapUsed

heapUsed:V8引擎已經分配使用的堆記憶體

external: V8管理C++物件繫結到JavaScript物件上的記憶體

複製程式碼

事實上, 對於大檔案的操作通常會使用Buffer, 究其原因就是因為Node中記憶體小的原因, 而使用Buffer是不受這個限制, 它是堆外記憶體, 也就是上面提到的external

v8的記憶體分代

目前沒有一種垃圾自動回收演算法適用於所有場景, 所以v8的內部採用的其實是兩種垃圾回收演算法。他們回收的物件分別是生存週期較短和生存週期較長的兩種物件。關於具體的演算法, 參考下文。 這裡先介紹v8是怎麼做記憶體分代的。

新生代
v8中的新生代主要存放的是生存週期較短的物件, 它具有兩個空間semispace, 分別為From和To, 在分配記憶體的時候將記憶體分配給From空間, 當垃圾回收的時候, 會檢查From空間存活的物件(廣度優先演算法)並複製到To空間, 然後清空From空間, 再互相交換From和To空間的位置, 使得To空間變為From空間

該演算法缺陷很明顯就是有一半的空間一直閒置著並且需要複製物件, 但是由於新生代本身具有的記憶體比較小加上其分配的物件都是生存週期比較短的物件, 所以浪費的空間以及複製使用的開銷會比較小。

在64位系統中一個semisapce為16MB, 而32位則為8MB, 所以新生代記憶體大小分別為32MB和16MB

老生代
老生代主要存放的是生存週期比較長的物件。記憶體按照1MB分頁,並且都按照1MB對齊。新生代的記憶體頁是連續的,而老生代的記憶體頁是分散的,以連結串列的形式串聯起來。 它的內部有4種型別。

Old Space
Old Space 儲存的是老生代裡的普通物件(在 V8 中指的是 Old Object Space,與儲存物件結構的 Map Space 和儲存編譯出的程式碼的 Code Space 相對),這些物件大部分是從新生代(即 New Space)晉升而來。

Large Object Space
當 V8 需要分配一個 1MB 的頁(減去 header)無法直接容納的物件時,就會直接在 Large Object Space 而不是 New Space 分配。在垃圾回收時,Large Object Space 裡的物件不會被移動或者複製(因為成本太高)。Large Object Space 屬於老生代,使用 Mark-Sweep-Compact 回收記憶體。

Map Space
所有在堆上分配的物件都帶有指向它的“隱藏類”的指標,這些“隱藏類”是 V8 根據執行時的狀態記錄下的物件佈局結構,用於快速訪問物件成員,而這些“隱藏類”(Map)就儲存在 Map Space。

Code Space
編譯器針對執行平臺架構編譯出的機器碼(儲存在可執行記憶體中)本身也是資料,連同一些其它的後設資料(比如由哪個編譯器編譯,原始碼的位置等),放置在 Code Space 中。

關於Map Space和Code Space推薦大家看這兩篇文章, 因為和本文關係不大, 所以不在這裡贅述。 文章1文章2

v8的記憶體分配如下圖, 圖出處:

Node - 記憶體管理和垃圾回收

V8的垃圾回收機制

新生代
新生代採用Scavenge垃圾回收演算法,在演算法實現時主要採用Cheney演算法。關於演算法的實現在上面中已經大致說明了, 但新生代的物件是怎麼晉升到老生代裡面呢?

在預設情況下,V8的物件分配主要集中在From空間中。物件從From空間中複製到To空間時,會檢查它的記憶體地址來判斷這個物件是否已經經歷過一次Scavenge回收。如果已經經歷過了,會將該物件從From空間複製到老生代空間中,如果沒有,則複製到To空間中。這個晉升流程如下圖所示

Node - 記憶體管理和垃圾回收

另一個判斷條件是To空間的記憶體佔用比。當要從From空間複製一個物件到To空間時,如果To空間已經使用了超過25%,則這個物件直接晉升到老生代空間中,這個晉升的判斷示意圖如下圖所示。

Node - 記憶體管理和垃圾回收

寫屏障
關於新生代掃描的問題, 由於我們想回收的是新生代的物件, 那麼只需檢查指向新生代的引用, 那麼在跟隨根物件->新生代或者新生代->新生代的引用時, 那麼掃描會很快。 但是還可能出現的一種情況是老生代指向了新生代或者指向了根物件, 如果選擇跟隨, 掃描整個堆, 就會花費太多時間。

對於這個問題,V8 選擇的解決方案是使用寫屏障(write barrier),即每次往一個物件寫入一個指標(新增引用)的時候,都執行一段程式碼,這段程式碼會檢查這個被寫入的指標是否是由老生代物件指向新生代物件的,這樣我們就能明確地記錄下所有從老生代指向新生代的指標了。這個用於記錄的資料結構叫做store buffer,每個堆維護一個,為了防止它無限增長下去,會定期地進行清理、去重和更新。這樣,我們可以通過掃描,得知根物件->新生代和新生代->新生代的引用,通過檢查 store buffer,得知老生代->新生代的引用,就沒有漏網之魚,可以安心地對新生代進行回收了。

新生代GC圖:

Node - 記憶體管理和垃圾回收

老生代
老生代在64位和32位下具有的記憶體分別是1400MB和700MB, 如果還使用新生代的Scavenge演算法, 不止浪費一半空間, 還需要複製大塊記憶體。所以, V8在老生代中的垃圾回收策略採用Mark-Sweep和Mark-Compact相結合。

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

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

圖中黑色部分為標記的死亡物件

Node - 記憶體管理和垃圾回收

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

由於標記整理需要移動物件, 所以它的速度相對較慢。 V8在主要使用標記清除演算法, 在空間不足以分配新生代晉升的物件時才使用標記整理演算法。

白色格子為存活物件,深色格子為死亡物件,淺色格子為存活物件移動後留下的空洞

Node - 記憶體管理和垃圾回收

關於標記的具體演算法, 如果將對中的物件看做由指標做邊的有向圖,標記演算法的核心就是深度優先搜尋。
V8使用每個物件的兩個mark-bits和一個標記工作棧來實現標記,兩個mark-bits編碼三種顏色:白色(00),灰色(10)和黑色(11)。

  • 白色: 表示物件可以回收
  • 黑色: 表示物件不可以回收,並且他的所有引用都被便利完畢了
  • 灰色: 表示物件不可回收,他的引用物件沒有掃描完畢。

當老生代GC啟動時, V8會掃描老生代的物件, 並對其進行標記。 大致的流程如下:

  1. 將所有非根物件標記為白色。
  2. 將根的所有直接引用物件入棧,並標記為灰色(marking worklist)
  3. 從這些物件開始做深度優先搜尋,每訪問一個物件,就將它 pop 出來,標記為黑色,然後將它引用的所有白色物件標記為灰色,push 到棧上
  4. 棧空的時候,回收白色的物件

但這裡需要留意一下, 當物件太大無法 push 進空間有限的棧的時候,V8 會先把這個物件保留灰色放棄掉,然後將整個棧標記為溢位狀態(overflowed)。在溢位狀態下,V8 會繼續從棧上 pop 物件,標記為黑色,再將引用的白色物件標記為灰色和溢位,但不會將這些灰色的物件 push 到棧上去。這樣沒多久,棧上的所有物件都被標黑清空了。此時 V8 開始遍歷整個堆,把那些同時標記為灰色和溢位物件按照老方法標記完。由於溢位後需要額外掃描一遍堆(如果發生多次溢位還可能掃描多遍),當程式建立了太多大物件的時候,就會顯著影響 GC 的效率。 引用自文章
增量標記與惰性清理
事實上, v8為了降低全堆垃圾回收帶來的停頓時間, 使用了增量標記和惰性清理兩種方式。

增量標記
將原本要一口氣停頓完成的動作改為增量標記(incremental marking),也就是拆分為許多小“步進”,每做完一“步進”就讓JavaScript應用邏輯執行一小會兒,垃圾回收與應用邏輯交替執行直到標記階段完成。

因為增量標記的過程中, 很有可能被標記為白色的物件又被重新引用, 所以需要一個寫屏障(write-barrier)來實現通知。

// Called after `object.field = value`.
write_barrier(object, field_offset, value) {
  if (color(object) == black && color(value) == white) {
    set_color(value, grey);
    marking_worklist.push(value);
  }
}
複製程式碼

下圖為增量標記示意圖。

Node - 記憶體管理和垃圾回收

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

Orinoco

V8將新一代的GC稱為Orinoco, 在Orinoco下, GC的演算法更加高效。

Orinoco 新生代
關於Orinoco在新生代中, 其實比較容易理解, 因為它只是增加了幾個worker執行緒來幫助處理, 如圖:

Node - 記憶體管理和垃圾回收

Orinoco 老生代

並行標記 parallel marking

Node - 記憶體管理和垃圾回收

並行標記是標記由主執行緒和工作執行緒進行, 程式會阻塞

其資料結構如圖所示:

Node - 記憶體管理和垃圾回收

Marking worklist負責決定分給其他worker thread的工作量,決定了效能與保持本地執行緒的均衡,V8使用基於記憶體段的方式去平衡各個執行緒的工作量,避免執行緒同步的耗時與儘可能的工作。即將記憶體分為一段段給每個執行緒工作。

Node - 記憶體管理和垃圾回收

併發標記 Concurrent marking

Node - 記憶體管理和垃圾回收

併發標記是由工作執行緒進行標記, 主執行緒繼續執行, 程式不會阻塞

併發標記允許標記行為與應用程式同時進行,很可能發生資料競爭, 所以main thread需要與worker threads在發生資料競爭時進行同步,大多數的資料競爭行為通過輕量級的原子級記憶體訪問就可以同步,但是一些特殊的場景需要獨佔整個物件的訪問。V8是利用一個Bailout worklist來處理被獨佔的整個物件, 並由主執行緒處理, 如圖:

Node - 記憶體管理和垃圾回收

合併
基於並行標記和併發標記, v8最後的垃圾回收機制如圖:

Node - 記憶體管理和垃圾回收

其步驟如下:

  1. 從root物件開始掃描,填充物件到marking worklist
  2. 分佈併發標記任務到worker threads
  3. worker threads 通過合作耗盡marking worklist來幫助main threads 更快地完成標記。
  4. 有時候, main threads也會通過處理bailout worklist和marking worklist參與標記。
  5. 如果marking worklist為空, 則主執行緒完成垃圾回收
  6. 在結束之前,main thread重新掃描roots,可能會發現其他的白色節點,這些白色節點會在worker threads的幫助下,被平行標記

準確式GC

提到GC不得不提一下準確式GC, 這個也是V8引擎效率比較高的原因, 以下引用自文章

雖然 ECMAScript 中沒有規定整數型別,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日誌
範例中的圖片來自:Are your v8 garbage collection logs speaking to you?Joyee Cheung -Alibaba Cloud(Alibaba Group)

option

Node - 記憶體管理和垃圾回收

--trace_gc

Node - 記憶體管理和垃圾回收

--trace_gc_nvp

Node - 記憶體管理和垃圾回收

--trace_gc_verbose

Node - 記憶體管理和垃圾回收

記憶體觀察
記憶體觀察這一塊需要藉助第三方工具, 因為一些原因個人只是在開發和測試階段開啟了easy-monitor觀察是否記憶體洩漏, 再使用heapdump + chrome dev tools來定位具體的洩漏原因。其實業內最好的還是接入alinode, 但是公司接入的困難度比較高, 原因大家都懂的啦~

另外推薦一些這方面不錯的資料:
《Node.js 除錯指南》
關於Nodejs效能監控思考

還有就是一些可能造成記憶體洩漏的程式碼(這裡就不貼程式碼了, 網上例子會更詳細):

  • 全域性變數
  • 閉包(包括commonjs規範, 其實質是一個閉包生成)
  • 快取

總結

關於記憶體和GC, 相應在編碼的時候需要考慮的細節和客戶端不同, 需要比較謹慎的為每一份資源做出安排。

參考

V8 —— 你需要知道的垃圾回收機制
聊聊V8引擎的垃圾回收
淺談V8引擎中的垃圾回收機制
解讀 V8 GC Log(一): Node.js 應用背景與 GC 基礎知識
解讀 V8 GC Log(二): 堆內外記憶體的劃分與 GC 演算法
Orinoco: young generation garbage collection
Concurrent marking in V8
V8 之旅: 垃圾回收器
Are your v8 garbage collection logs speaking to you?Joyee Cheung -Alibaba Cloud(Alibaba Group)

相關文章