Node - 內存管理和垃圾回收

前言

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

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()來查看內存分配

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的內存分配如下圖, 圖出處:

V8的垃圾回收機制

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

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

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

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

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

新生代GC圖:

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

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

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

圖中黑色部分爲標記的死亡對象

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

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

白色格子爲存活對象,深色格子爲死亡對象,淺色格子爲存活對象移動後留下的空洞

關於標記的具體算法, 如果將對中的對象看做由指針做邊的有向圖,標記算法的核心就是深度優先搜索。
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);
  }
}

下圖爲增量標記示意圖。

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

Orinoco

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

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

Orinoco 老生代

並行標記 parallel marking

並行標記是標記由主線程和工作線程進行, 程序會阻塞

其數據結構如圖所示:

Marking worklist負責決定分給其他worker thread的工作量,決定了性能與保持本地線程的均衡,V8使用基於內存段的方式去平衡各個線程的工作量,避免線程同步的耗時與儘可能的工作。即將內存分爲一段段給每個線程工作。

併發標記 Concurrent marking

併發標記是由工作線程進行標記, 主線程繼續運行, 程序不會阻塞

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

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

其步驟如下:

  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

--trace_gc

--trace_gc_nvp

--trace_gc_verbose

內存觀察
內存觀察這一塊需要藉助第三方工具, 因爲一些原因個人只是在開發和測試階段開啓了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)

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