Node的垃圾回收機制與記憶體溢位捕獲(上)

DIVI發表於2019-01-30

Node的垃圾回收機制與記憶體溢位捕獲

一、什麼是Node的記憶體?

  想必大家在用JavaScript開發的過程中,不太關心記憶體的管理,因為對於前端來說,瀏覽器的記憶體幾乎不會出現用完的情況,因為所接觸的是那些短時間執行的場景,比如網頁的應用、命令工具等。這類場景由於是執行短時間,且執行在使用者的機器上,即使記憶體被消耗過多或者記憶體發生了洩漏,已只會影響到終端使用者,並不會大面積的擴散。因此執行時間短,隨著程式的退出,記憶體會自動釋放,幾乎沒有記憶體管理的必要。

1.Node的記憶體需要管理嗎?

  答案是必須的。為啥呢?

  因為Node作為後端服務,操作複雜,並且長期執行在伺服器端不重啟。如果不關注記憶體管理,將會導致記憶體洩漏,就算是1TB,也會很快會被耗盡。

2.Node的記憶體究竟是什麼樣的呢?

2.1 Node是在什麼環境下執行的呢?

   回溯歷史可以發現,Node在發展的歷程中離不開Chrome V8 (ps:下面會提到什麼是V8),所以在官方的主頁大家可以看到Node是一個構建在Chrome的 JavaScript執行上的平臺(++Node.js® is a JavaScript runtime built on Chrome's V8 JavaScript engine.++)。換句話說,其實Node.js就是一個由JavaScript V8引擎控制的C++程式。

   Google V8是一個由Google開發的JavaScript引擎,但它也可以脫離瀏覽器被單獨使用。 這使得它能夠完美的契合Node.js,實際上V8也是Node.js平臺中唯一能夠理解JavaScript的部分。 V8會將JavaScript程式碼向下編譯為原生程式碼(native code),然後執行它。在執行期間,V8會按需進行記憶體的分配和釋放。 這意味著,如果我們在談論Node.js的記憶體管理問題,也就是在說V8的記憶體管理問題。

2.2 V8的記憶體管理模式
2.2.1 V8的記憶體設計

   一個執行的程式通常是通過在記憶體中分配一部分空間來表示的。這部分空間被稱為常駐記憶體(Resident Set)。

   V8的記憶體管理模式有點類似於Java虛擬機器(JVM),它會將記憶體進行分段:

  • 程式碼區(Code Segment):存放即將執行的程式碼片段
  • 棧 Stack:包括所有的攜帶指標引用堆上物件的值型別(原始型別,例如整型和布林),以及定義程式控制流的指標。
  • 堆 Heap:用於儲存引用型別(包括物件、字串和閉包)的記憶體段。
  • 堆外記憶體:不通過V8分配,也不受V8管理。Buffer物件的資料就存放於此。

1214547-f76a4eba8d3b0487
dce0143d9ff120114b87c63df9066ed5

2.2.2 V8記憶體模型

   除堆外記憶體,其餘部分均由V8管理。

  • 棧(Stack)的分配與回收非常直接,當程式離開某作用域後,其棧指標下移(回退),整個作用域的區域性變數都會出棧,記憶體收回。
  • 最複雜的部分是堆(Heap)的管理,V8使用垃圾回收機制進行堆的記憶體管理,也是開發中可能造成記憶體洩漏的部分,是我們需要關注的重點。

在Node.js中,當前的記憶體使用情況可以輕鬆的使用process.memoryUsage()進行查詢, 例項程式如下:

$ node
$ process.memoryUsage()
複製程式碼

這是公司內部的一個專案的Node程式的記憶體使用狀況:

image

  • rss是Resident Set Size的縮寫,為常駐記憶體的總大小(單位:bytes),大約21M。

  • heapTotal是V8為堆分配的總大小(單位:bytes),大約9.23M。

  • heapUsed是已使用的堆大小(單位:bytes),大約5.29M。

可以看到,rss是大於heapTotal的,因為rss包括且不限於堆。

  • external是堆外記憶體大小(單位:bytes),0.0085M。
    image

當我們在程式碼中宣告變數並賦值的時候,所使用物件的記憶體就分配在堆中。如果已申請的堆空間記憶體不夠分配新的物件,將繼續申請記憶體,直到堆的大小超過V8的限制為止。

2.2.3 V8記憶體限制

   V8記憶體為何要限制大小呢?V8不就是為了瀏覽器設計的麼,瀏覽器中不太可能遇到太大的記憶體場景,對於一般正常瀏覽網頁來說,停留的時間不會太長,也不太會進行很多複雜的工作,照理說V8記憶體的限制已經綽綽有餘了。但是遇到大記憶體的時候,比如讀取大的檔案進記憶體,那要怎麼辦呢?

   其實引起V8記憶體限制的深層次原因是其垃圾回收機制的限制。舉個栗子,官方說法是,以1.5GB的垃圾回收堆記憶體為例,V8做一次小的垃圾回收需要50毫秒以上,做一次非增量式的垃圾回收甚至要1秒以上。這是垃圾回收中引起JavaScript執行緒暫停執行的時間,在這樣的花銷下,應用效能和相應時間能力都會直線下降。這樣的情況不僅僅後端服務無法接受,前端瀏覽器也無法接受。因此,是時候需要考慮一下是否改變記憶體的閥值了。

   在啟動node程式的時候,可以調整記憶體大小。

node --max-old-space-size=1700 test.js // 單位為MB
node --max-new-space-size=1024 test.js // 單位為KB
複製程式碼

   上述引數在初始化程式的時候就生效,一旦生效就不能動態擴容,一般用來擴充記憶體,以免稍微多一些記憶體就崩潰。

2.2.4 V8的記憶體分代

   V8垃圾回收策略主要基於分代垃圾回收機制。在實際應用過程中發現,物件的生存週期長短不一,因此只能按照物件的存活時間將記憶體的垃圾回收進行不同的分代。

   在V8中,主要將記憶體分為 新生代老生代。新生代中的物件為存活時間較短的物件,老生代中的物件為存活時間較長的或常駐記憶體的物件。

image
V8堆的整體大小就是新生代所用記憶體空間加上老生代的記憶體空間。就是上面所提到的用--max-old-space-size來設定老生代記憶體空間的最大值,--max-new-space-size來設定新生代記憶體空間的最大值。

   v8原始碼中,我們可以看到這個說明,在程式碼Page::kPageSize=1下:

// semispace_size_ should be a power of 2 and old_generation_size_ should be
// a multiple of Page::kPageSize
#if defined(V8_TARGET_ARCH_X64)
#define LUMP_OF_MEMORY(2 * MB)
    code_range_size_(512 * MB),
#else
#define LUMP_OF_MEMORY MB
    code_range_size_(0),
#endif
#if defined(ANDROID)
    reserved_semispace_size_(4 * Max(LUMP_OF_MEMORY, Page:: kPageSize)),
    max_semispace_size_(4 * Max(LUMP_OF_MEMORY, Page:: kPageSize)),
    initial_semispace_size_(Page:: kPageSize),
    max_old_generation_size_(192 * MB),
    max_executable_size_(max_old_generation_size_),
#else
    reserved_semispace_size_(8 * Max(LUMP_OF_MEMORY, Page:: kPageSize)),
    max_semispace_size_(8 * Max(LUMP_OF_MEMORY, Page:: kPageSize)),
    initial_semispace_size_(Page:: kPageSize),
    max_old_generation_size_(700ul * LUMP_OF_MEMORY),
    max_executable_size_(256l * LUMP_OF_MEMORY),
#endif
複製程式碼

   依照上面的程式碼,我們可以看到如果V8標記是64位系統的需要*2,32位的不需要。

   對於新生代來說,它是由兩個reserved_semispace_size_所構成的,這個後面會講到。單個reserved_semispace_size_在32位上reserved_semispace_size_(8 * Max(LUMP_OF_MEMORY, Page:: kPageSize)),由於Page:: kPageSize為1,所以為8MB,推算出64位的為16MB。因此,新生代記憶體的最大值在64位和32位上分別是32MB和16MB。

   對於老生代來說,max_old_generation_size_(700ul * LUMP_OF_MEMORY),32位為700MB,推算出64位的為1400MB。

   那堆記憶體的最大值是多少呢?    v8堆記憶體的最大保留空間可以從這個程式碼中看出,其公式為:

// Returns the maximum amount of memory reserved for the heap. For
// the young generation, we reserve 4 times the amount needed for a
// semi space. The young generation consists of two semi spaces and
// we reserve twice the amount needed for those in order to ensure
// that new space can be aligned to its size
intptr_t MaxReserved() {
    return 4 * reserved_semispace_size_ + max_old_generation_size_;
}
複製程式碼

   因此,在預設配置下V8堆記憶體最大值:

  • 32位:4*8+700=732MB;
  • 64位:4*16+1400=1464MB

01uncx3JkBTi
微信截圖_20190110004041
微信截圖_20190110004200

2.2.5 V8記憶體演算法

   在上面的提到了在記憶體分配的時候分為新生代和老生代。那新老生代之間有什麼區別?如何分配的呢?

   接下來我們先講新生代的那些事兒:

2.2.5.1 新生代(Scavenge演算法)

   新生代主要是存放存活時間較短的物件,這些物件主要是用Scavenge演算法進行垃圾回收,在Scavenge的具體 實現中,主要採用了Cheney演算法。

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

   Scavenge演算法只能使用堆記憶體的一半。但是由於只複製存活的物件,並且對於生命週期短的場景存活物件只佔少部分,所以它具有極高的時間效率。相當於可以理解為犧牲空間換取時間的演算法。

  其實From空間和To空間進行角色交換的時候是需要進行判斷檢查的,在一定條件下,需要將存活週期長的物件移動到老生代中,完成物件的晉升。

  物件晉升的主要條件有兩個:

  • 物件是否經歷過Scavenge回收。
  • To空間是否超過25%的限制。

下圖是判斷流程:

微信截圖_20190110004123

2.2.5.2 老生代(Mark-Sweep & Mark-Compact)

   由於在老生代中存放物件佔較大比重,若再繼續使用新生代的Scavenge演算法會產生兩個問題:

  • 由於存活物件比較多,複製存活物件的效率將會降低。
  • 浪費一半的空間。

   因此在老生代中採用了Mark-Sweep和Mark-Compact相結合的方式進行垃圾的回收。

  • Mark-Sweep是標記清除的意思,它分為標記和清除兩個階段。Mark-Sweep 在標記階段遍歷堆中的所有物件,並標記活著的物件,在隨後的清除階段中,只清除沒有被標記的物件。可以看出,Scavenge 中只複製活著的物件,而 Mark-Sweep 只清理死亡物件。
  • Mark-Compact是物件在標記為死亡後,在整理的過程中,將活著的物件往一端移動,移動完成後,直接清理掉邊界外的記憶體。這是由於Mark-Sweep 在進行一次標記清除回收後,記憶體空間會出現不連續的狀態引起的,因為這種記憶體碎片會對後續的記憶體分配造成問題,很可能出現需要分配一個大物件的情況,這時所有的碎片空間都無法完成此次分配,就會提前觸發垃圾回收,而這次回收是不必要的。

微信截圖_20190110004139

   接下來我們看看3種垃圾回收演算法的簡單對比:

回收演算法 Mark-Sweep Mark-Compact Scavenge
速度 中等 最慢 最快
空間開銷 少(碎片) 少(碎片) 雙倍空間(無碎片)
是否移動物件

   V8主要使用Mark-Sweep,在空間不足的情況下對從新生代中晉升過來的物件進行分配才使用Mark-Compact。

2.2.5.3 增量標記(Incremental Marking)

   在執行上述三種演算法的時候,垃圾回收機制會先把應用邏輯暫停下來,待執行垃圾回收完後再恢復執行應用邏輯。“停頓”現在新老生代中都會發生,新生代由於存活物件時間短,全停頓對全域性影響不大,但是在老生代中配置較大,且存活物件較多,全停頓的話影響比較大,因此需要改善。

   這時候就需要引入“增量標記”的方式,也就是拆分為許多小的“進步”,每做完一“進步”就讓JavaScript應用邏輯執行一會兒,垃圾回收與應用邏輯交替執行直到標記階段完成。

   例如:一次執行標記可能需要幾百毫秒才能完成一個大的堆。

2ea90e4181e1c262671ec21bfeea5275deb

  在增量標記期間,垃圾收集器將標記工作分解為更小的塊,並且允許應用程式在塊之間執行:

a26e1c69e9a6eaba679233085b4e37cfb6d
  垃圾收集器選擇在每個塊中執行多少增量標記來匹配應用程式的分配速率。一般情況下,這極大地提高了應用程式的相應速度。對記憶體壓力較大的堆,收集器仍然可能出現長時間的暫停來維持分配。

  總的來說,V8經過增量標記後的,垃圾回收機制最大停頓時間可以減少到原本的1/6左右。同時還引入了延遲清理和增量式整理,讓清理與整理也變成增量式。

2.2.5.4 並行標記

  並行標記發生在主執行緒和工作執行緒上。應用程式在整個並行標記階段暫停。它是 stop-the-world 標記的多執行緒版本。

986cccc9bb8f4d242ef854d1241fc444eaa

  併發標記主要發生在工作執行緒上。當併發標記正在進行時,應用程式可以繼續執行。

112cb5dab6fd6a59317d54a86f6a2351a9d

  在並行標記的時候,我們可以假定應用都不會同時執行。這大大的簡化了實現,是因為我們可以假定物件圖是靜態的,而且不會改變。為了並行標記物件圖,我們需要讓垃圾收集資料結構的執行緒是安全的,而且尋找一個可以線上程間執行的高效共享標記的方法。下面的示意圖展示了並行標記包含的資料結構。箭頭代表資料流的方向。簡單來說,示意圖省略了堆碎片處理所需的資料結構。

77712bc8a0a04b591b4c35b34f7edb0d864
  注意,這些執行緒只能讀取物件圖,而不能修改它。物件的標記位和標記列表必須支援讀寫訪問。

2.2.5.4 併發標記

  併發標記允許 JavaScript 在主執行緒上執行,而工作執行緒正在訪問堆上的物件。這為潛在的競態資料開啟大門。舉個例子:當工作者執行緒正在讀取欄位時,JavaScript 可能正在寫入物件欄位。競態資料會混淆垃圾回收器釋放活動物件或者將原始值和指標混合在一起。

主執行緒的每個改變物件圖表的操作將會是競態資料的潛在來源。由於 V8 是具有多種物件佈局優化功能的高效能引擎,潛在競態資料來源目錄相當長。以下是高層次故障:

  • 物件分配

  • 寫物件

  • 物件佈局變化

  • 快照反序列化

  • 功能脫優化實現

  • 年輕代垃圾回收期間的疏散

  • 程式碼修補

  在以上這些操作上,主執行緒需要與工作執行緒同步。同步代價和複雜度是操作而定。大部分操作允許輕量級的同步和院子操作之間的訪問,但是少部分操作需獨佔訪問物件。

  總的來說,併發標記就是為解決資料競爭的問題。

image
  有了平行標記與併發標記後,對比上面講的流程,GC的流程變為: 從root物件開始掃描,填充物件到marking worklist 分佈併發標記任務到worker threads worker threads幫助main thread去更快地消費marking worklist中的物件 main thread 偶爾會通過執行bailout worklist 和 marking worklist來marking 一旦marking worklists為空,main thread 就完成GC行為 在結束之前,main thread重新掃描roots,可能會發現其他的白色節點,這些白色節點會在worker threads的幫助下,被平行標記。

課外學習

image
01uncx4kNcCX

《深入簡出nodeJS》很不錯哦~

相關文章