NodeJS中被忽略的記憶體

weixin_33918357發表於2016-04-16

原文連結:BlueSun | NodeJS中被忽略的記憶體

如樸靈說過,Node對記憶體洩露十分敏感,一旦線上應用有成千上萬的流量,那怕是一個位元組的記憶體洩漏也會造成堆積,垃圾回收過程中將會耗費更多時間進行物件掃描,應用響應緩慢,直到程式記憶體溢位,應用奔潰。

雖然從很久以前就知道記憶體問題是不容忽視的,但是日常開發的時候並沒有碰到效能上的瓶頸,直到最近做了一個百萬PV級的營銷專案,由於訪問量,併發量都達到了一個量級。一些細小的、平時沒注意到的問題被放大,這才映入眼簾,開始注意到了記憶體問題。殊不知Node對記憶體的洩露是如此的敏感。
為此,趕緊去補習了一下V8中的記憶體處理機制。
那麼,V8中的記憶體機制是怎麼樣的?

V8的記憶體機制

記憶體的限制

Node中並不像其他後端語言中,對記憶體的使用沒有多少限制。在Node中使用記憶體,只能使用到系統的一部分記憶體,64位系統下約為1.4GB,32位系統下約為0.7GB。這歸咎於Node使用了本來執行在瀏覽器的V8引擎。

V8引擎的設計之初只是執行在瀏覽器中,而在瀏覽器的一般應用場景下使用起來綽綽有餘,足以勝任前端頁面中的所有需求。

雖然服務端操作大記憶體也不是常見的需求,但是萬一有這樣的需求,還是可以解除限制的。
在啟動node程式的時候,可以傳遞兩個引數來調整記憶體限制的大小。

node --max-nex-space-size=1024 app.js // 單位為KB
node --max-old-space-size=2000 app.js // 單位為MB

這兩條命令分別對應Node記憶體堆中的「新生代」和「老生代」

不受記憶體限制的特例

在Node中,使用Buffer可以讀取超過V8記憶體限制的大檔案。原因是Buffer物件不同於其他物件,它不經過V8的記憶體分配機制。這在於Node並不同於瀏覽器的應用場景。在瀏覽器中,JavaScript直接處理字串即可滿足絕大多數的業務需求,而Node則需要處理網路流和檔案I/O流,操作字串遠遠不能滿足傳輸的效能需求。

記憶體的分配

一切JavaScript物件都用堆來儲存

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

79702-f591953d212faa56.png
V8的堆示意圖

V8的垃圾回收機制

分代式垃圾回收

V8的垃圾回收策略主要基於「分代式垃圾回收機制」,基於這個機制,V8把記憶體分為「新生代(New Space)」和 「老生代 (Old Space)」。
新生代中的物件為存活時間較短的物件,老生代中的物件為存活時間較長或常駐記憶體的物件。
前面提及到的--max-old-space-size命令就是設定老生代記憶體空間的最大值,而--max-new-space-size命令則可以設定新生代記憶體空間的大小。

79702-cf3164ff2974d295.png
V8的分代示意圖

為什麼要分成新老兩代?

垃圾回收演算法有很多種,但是並沒有一種是勝任所有的場景,在實際的應用中,需要根據物件的生存週期長短不一,而使用不同的演算法,已達到最好的效果。在V8中,按物件的存活時間將記憶體的垃圾回收進行不同的分代,然後分別對不同的記憶體施以更高效的演算法。

新生代中的垃圾回收

在新生代中,主要通過Scavenge演算法進行垃圾回收。

Scavenge

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

79702-01dadcbf723eae83.png
V8的堆記憶體示意圖
在新生代中的物件怎樣才能到老生代中?

在新生代存活週期長的物件會被移動到老生代中,主要符合兩個條件中的一個:

1. 物件是否經歷過Scavenge回收。
物件從From空間中複製到To空間時,會檢查它的記憶體地址來判斷這個物件是否已經經歷過一次Scavenge回收,如果已經經歷過了,則將該物件從From空間中複製到老生代空間中。

2. To空間的記憶體佔比超過25%限制。
當物件從From空間複製到To空間時,如果To空間已經使用超過25%,則這個物件直接複製到老生代中。這麼做的原因在於這次Scavenge回收完成後,這個To空間會變成From空間,接下來的記憶體分配將在這個空間中進行。如果佔比過高,會影響後續的記憶體分配。

老生代中的垃圾回收

對於老生代的物件,由於存活物件佔比較大比重,使用Scavenge演算法顯然不科學。一來複制的物件太多會導致效率問題,二來需要浪費多一倍的空間。所以,V8在老生代中主要採用「Mark-Sweep」演算法與「Mark-Compact」演算法相結合的方式進行垃圾回收。

Mark-Sweep

Mark-Sweep是標記清除的意思,分為標記和清除兩個階段。在標記階段遍歷堆中的所有物件,並標記存活的物件,在隨後的清除階段中,只清除標記之外的物件。

79702-8b8fb73c8a3cc924.png
Mark-Sweep在老生代空間中標記後的示意圖

但是Mark-Sweep有一個很嚴重的問題,就是進行一次標記清除回收之後,記憶體會變得碎片化。如果需要分配一個大物件,這時候就無法完成分配了。這時候就該Mark-Compact出場了。

Mark-Compact

Mark-Compact是標記整理的意思,是在Mark-Sweep基礎上演變而來。Mark-Compact在標記存活物件之後,在整理過程中,將活著的物件往一端移動,移動完成後,直接清理掉邊界外的記憶體。

79702-4d0070f029651b5f.png
Mark-Compact完成標記並移動存活物件後的示意圖

Incremental Marking

鑑於Node單執行緒的特性,V8每次垃圾回收的時候,都需要將應用邏輯暫停下來,待執行完垃圾回收後再恢復應用邏輯,被稱為「全停頓」。在分代垃圾回收中,一次小垃圾回收只收集新生代,且存活物件也相對較少,即使全停頓也沒有多大的影響。但是在老生代中,存活物件較多,垃圾回收的標記、清理、整理都需要長時間的停頓,這樣會嚴重影響到系統的效能。
所以「增量標記 (Incrememtal Marking)」被提出來。它從標記階段入手,將原本要一口氣停頓完成的動作改為增量標記,拆分為許多小「步進」,每做完一「步進」就讓JavaScript應用邏輯執行一小會,垃圾回收與應用邏輯這樣交替執行直到標記階段完成。

記憶體洩露排查的工具

node-heapdump

它允許對V8堆記憶體抓取快照,用於事後分析。
在程式中引入

var heapdump = require("node-heapdump");

之後可以通過向伺服器傳送SIGUSR2訊號,讓node-heapdump抓拍一份堆記憶體的快照:

$ kill -USR2 <pid>

這份抓拍的快照會預設存放在檔案目錄下,這是一份大JSON檔案,可以通過Chrome的開發者工具開啟檢視。

![Chrome Profile][8]

79702-13f67d076945e30f.jpg
Chrome Profile

node-memwatch

需要注意,node-memwatch只是支援到node v0.12.x為止,當使用更高的版本的時候,就會安裝不上,這時候可以使用node-watch-next 替代,一摸一樣的API。

不同於node-heapdump,它提供了兩個事件監聽器,用來提供記憶體洩露的以及垃圾回收的資訊:

  1. stats事件:每次進行全堆回收時,會觸發改時間,傳遞記憶體的統計資訊
  2. leak事件:經過五次垃圾回收之後,記憶體仍沒有被釋放的物件,會觸發leak事件,傳遞相關的資訊。

node-profiler

node-profiler 是 alinode團隊出品的一個與node-heapdump類似的抓取記憶體堆快照的工具,不同的是,node-profiler的實現不一樣,使用起來更便捷。附上他們的教程:如何使用Node Profiler

alinode

alinode官方如似說:

alinode 是阿里雲出品的 Node.js 應用服務解決方案,是一套基於社群 Node 改進的執行時環境和服務平臺。在社群的基礎上我們內建了強大的支援功能,幫助開發者迅速洞見效能細節,快速定位疑難雜症,直探問題根源。

以上內容參考自

A tour of V8: Garbage Collection
V8 之旅: 垃圾回收器
《深入淺出Node.js》

相關文章