node記憶體基礎知識

積木村の研究所發表於2016-08-21

開始之前,先來個段子,博君一笑:

雖然go成為了世界上最併發的語言,但是, 這並不妨礙php成為世界上最好的語言,也不妨礙java成為世界上最有模式的語言,更不會妨礙c++成為21 天就能學會了的語言...但是JavaScript終將統治世界,而V8將成為世界的基石!

1. node堆內記憶體分配基礎

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

enter image description here

新生代和老生代的預設記憶體限制在啟動的時候就確定了,沒辦法根據應用使用記憶體情況自動擴充,當應用分配過多記憶體時,就會引起OOM(Out Of Memory,記憶體溢位)程式錯誤。64位系統和32位系統的記憶體限制不同,分別如下:

enter image description here

在node啟動時,通過--max-new-space-size--max-old-space-size可分別設定新生代和老生代的預設記憶體限制。V8為什麼要對記憶體做如此限制呢?最終的原因還是V8的垃圾回收機制所限制的,在較大的記憶體上進行垃圾回收是很耗時地。下面我們就來了解一下V8的垃圾回收機制。

在node啟動時,通過--max-new-space-size--max-old-space-size可分別設定新生代和老生代的預設記憶體限制。V8為什麼要對記憶體做如此限制呢?最終的原因還是V8的垃圾回收機制所限制的,在較大的記憶體上進行垃圾回收是很耗時地。下面我們就來了解一下V8的垃圾回收機制。

2. node垃圾回收原理

2.1 常用垃圾回收基本演算法

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

enter image description here

2.2 V8的分代垃圾回收

上面提到過,V8將記憶體分為新生代和老生代,新生代中物件存活時間較短,老生代中物件存活時間較長。為了最大程度的提升垃圾回收效率,V8使用了一種綜合性的方法,其在新生代和老生代中分別使用上文提到的不同的基本垃圾回收演算法。

1 新生代垃圾回收演算法Scavenge

在新生代中,由於記憶體較小(64位系統為64MB)且存活物件較少,V8採取了一種以空間換時間的方案,即停止-複製演算法 (Stop-Copy)。它將新生代分為兩個半區域(semi-space),分別稱為from空間和to空間。一次垃圾回收分為兩步:

(1) 將from空間中的活物件複製到to空間
(2) 切換from和to空間

V8將新生代中的一次垃圾回收過程,稱為Scavenge。

enter image description here

2 老生代垃圾回收演算法

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

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

enter image description here

從上面的表格可以看出,停止-複製(Stop-Copy)、標記-清除(Mark-Sweep)和標記-壓縮(Mark-Compact)都需要停止應用邏輯,我們將之稱為stop-the-world。但因為新生代記憶體較小且存活物件較少,即便stop-the-world,對應用的效能影響也不大;而老生代的記憶體很大,stop-the-world就不能接受了,為此V8引入了增量標記。增量標記使得應用邏輯和垃圾回收交替執行,減少了垃圾回收對應用邏輯的干擾。

2.3 分代垃圾回收的代價

在討論新生代中的垃圾回收演算法Scavenge時,我們忽略了許多細節。

真的僅僅掃描新生代的記憶體空間,就能確定新生代的活動物件嗎?

當然不是,老生代的物件也可能引用新生代的物件啊。如果每次執行Scavenge演算法時,都要掃描老生代空間的話,這種操作帶來的效能損耗就完全抵消了分代式垃圾回收所帶來的效能提升。為此V8使用寫屏障技術解決了這個問題:

V8使用一個列表(我們稱之為CrossRefList)記錄所有老生代物件指向新生代的情況,當有老生代中的物件出現指向新生代物件的指標時,便記錄下來這樣的跨區指向。由於這種記錄行為總是發生在寫操作時,因此被稱為寫屏障。

enter image description here

每個寫操作都要經歷這樣一關,效能上必然有損失,這是分代垃圾回收的代價之一。通過使用寫屏障技術,我們在對新生代進行垃圾回收時,只需要掃描新生代From空間和CrossRefList列表就可以確定活動物件了。

3.垃圾回收監控

理解了垃圾回收的基本原理以後,我們來看一看如何監控node的垃圾回收情況。檢視垃圾回收方式的最方便的方法是通過在啟動時使用--trace-gc引數:

node --trace-gc app.js
1254 ms: Scavenge 413.1 (460.9) -> 413.1 (460.9) MB, 0.5 
        / 0 ms (+ 3.0 ms in 14 steps since last GC)
1258 ms: Mark-sweep 413.5 (461.9) -> 412.6 (461.9) MB, 1.0 
        / 0 ms (+ 255.0 ms in 2050 steps since start of marking, biggest step 1.0 ms)
         [GC interrupt] [GC in old space requested]

從控制檯日誌中可以輕易的看出node的垃圾回收動作,包括新生代垃圾回收(Scavenge)和老生代垃圾回收(Mark-sweep)。

而一種更加程式化的方式是使用memwatch-next模組,該模組在node每一次進行全量垃圾(full-gc,包括標記-清除和標記-壓縮)回收時觸發相應的事件:

var memwatch = require('memwatch-next');
memwatch.on('stats', function(stats) { 
    console.log(stats);
});

上述程式碼監控每一次全量垃圾回收動作,並列印出相應垃圾回收統計資訊:

{
  "num_full_gc": 8,            //目前為止進行全量GC的次數
  "num_inc_gc": 18,                //目前為止進行增量GC的次數
  "heap_compactions": 8,        //目前為止進行的記憶體壓縮的次數
  "usage_trend": 0,                //記憶體增長趨勢,如果一直大於0,則可能有記憶體洩露
  "estimated_base": 2592568,    
  "current_base": 2592568,
  "min": 2499912,
  "max": 2592568
}           

4.記憶體洩露定位

使用上文提到的垃圾回收監控方法,我們可以知道程式是否有記憶體洩露,那麼具體在什麼地方有記憶體洩露呢?我們需要藉助於新的工具。node-heapdump提供了v8的堆記憶體快照抓取工具。

4.1 抓取對記憶體映象

我們可以在程式中直接通過它提供的函式抓取記憶體快照:

var heapdump = require('heapdump');
heapdump.writeSnapshot('/tmp/' + Date.now() + '.heapsnapshot');

在linux下,我們還可以通過向node程式傳送訊號來抓取記憶體快照:

kill -USR2 pid

有了記憶體快照後,我們就可以藉助chrome的Profile工具,具體的分析記憶體洩露發生在什麼地方了。

4.2 三次快照法

利用chrome的Profile工具分析記憶體洩露的經典方法是三次快照法,我們需要首選準備3個記憶體快照檔案:

(1) 第一次獲取正常情況下記憶體快照
(2) 第二次獲取發生記憶體洩露時的記憶體快照
(3) 第三次獲取繼續發生記憶體洩露時的記憶體快照

三次快照要求第一次必須在沒有出現記憶體洩露時,是為了過濾一些無用的資訊,使得分析結果可讀性更強。

5.常見的記憶體洩露case

瞭解了node記憶體的基本原理後,我們一起來看一看常見的記憶體洩露case。

5.1 使用物件作為快取

使用javascript鍵值對作為快取,我們幾乎必然要承擔記憶體洩露的風險,因為嚴格意義的快取有完善的過期策略,而普通的javascript物件顯然不具備這個功能:

var cache = {};
function getFromCache(key){
    if(cache[key]){
        return cache[key];
    }else{
        cache[key] = new CacheItem();
        return cache[key];
    }
}

上述cache裡快取的item永遠得不到釋放,儘管沒有任何引用。那麼如果我們確實需要使用快取呢?有兩種方案:

(1) 使用外部快取服務,比如memcahe
(2) 使用es6的WeakMap作為程式內快取資料結構

WeakMap是ES6標準中新引入的一種類似字典的資料結構,和普通字典資料結構不同的是,當WeakMap中key沒有其他引用,並且value也沒有除key之外的引用時,value可以被垃圾回收,這是一種程式內快取的理想選擇。

6.堆外記憶體

在node中,我們不可避免的需要操作大記憶體,而堆內記憶體大小的限制顯然無法滿足我們的要求。為此,node通過內建的全域性Buffer模組提供堆外的記憶體使用方法。Buffer是一個C++與Javascript結合的模組,其記憶體分配策略,也非常值得我們研究。我們知道大部分Javascript物件所使用的記憶體都是很小的,如果每一次都向作業系統申請,就必須頻繁地進行系統呼叫;為了解決這個問題,node使用C++層面申請一大塊記憶體,然後按需分配給Javascript的策略;也就是*nix系統常用的slab記憶體分配策略,這是一種典型的對時間和空間的折衷演算法(time/space trade-off)。

slab是一塊提前申請好地固定大小的記憶體,一個slab有三種狀態:full、partial、empty。node層面提供了一個SlowBuffer類,封裝C++的api,用於申請真實的實體記憶體,可以簡單地將一個slab理解為一個SlowBuffer物件。node中維護著一個名為pool的指標,它指向當前slab(SlowBuffer物件)。向系統申請slab的過程可用如下虛擬碼表示:

var pool;
function allocateSlab(){
    pool = new SlowBuffer();
    pool.used = 0;
}

6.1 小記憶體(<4kB)的分配

一個slab的大小為8KB(Buffer.poolSize),node中通過Buffer.poolSize定義。當我們需要建立一個長度小於4kB的Buffer物件時,會首先判斷當前slab的剩餘空間是否足夠,如果剩餘空間足夠,則在當前slab上為Buffer物件分配記憶體,否則建立一個新的slab塊,並在新的slab上為Buffer物件分配記憶體。

if(!pool || pool.lengh - pool.used < this.length){
    allocateSlab(); //向系統申請新的slab
    allocateBuffer(); //給buffer物件分配記憶體
}else{
    allocateBuffer(); //給buffer物件分配記憶體
}

從當前slab上為Buffer分配記憶體的演算法也很容易理解,只需要將Buffer指向slab的某段記憶體,並調整pool的length和used等屬性:

function allocateBuffer(){
    this.parent = pool; //buffer的parent屬性指向當前slab
    this.offset = pool.used; //buffer的offset屬性指向當前slab可用記憶體段的開始位置
    pool.used += this.length; //調整buffer的已使用空間
}

由此可見,一個slab(SlowBuffer物件)可供多個小記憶體的Buffer共用:

enter image description here

在寫本文時,node官方已經不建議使用new Buffer()建立buffer物件了,官方提供了更新的Buffer.alloc()Buffer.allocUnsafe()。其中Buffer.alloc()在建立Buffer物件時會對記憶體進行初始化,並且不會使用slab策略;而Buffer.allocUnsafe()則是使用slab演算法分配一塊未初始化的記憶體,因此其效能要比Buffer.alloc()高很多。因此我們應該使用Buffer.allocUnsafe()替換來的new Buffer()

6.2 大記憶體(>4kB)的分配

對於大於4KB的Buffer物件,其大小甚至可能超過一個slab的大小,系統就無法使用固定大小的slab分配演算法了。值得注意的是,node對單個Buffer大小是有上限的(buffer.kMaxLength),在32系統上其上限接近1GB(2^30-1),而在64位系統上其上限則接近2GB(2^31-1)。

6.3 slab演算法的代價

魚與熊掌不可兼得,上文中提到slab演算法一種時間和空間的折衷演算法。為了提高記憶體的分配速度,該演算法可能導致記憶體碎片:當一個slab上的剩餘空間不足於容納新申請的Buffer的大小,或者新申請Buffer大於等於4kb(Buffer.poolSize)時,就需要建立新的slab,原來slab上剩餘的空間就浪費了。我們寫個小程式證明一下我們的猜想:

function testBufferSlab(size){
    var itt = 10000;
    var store = [];
    var rss = process.memoryUsage().rss;
    var tmpMem;
    for(var i =0 ;i < itt; i++){
        store.push(new Buffer(1));
        tmp = Buffer(size);
        if(i/1000){
            global.gc();
        }
    }
    var nr = process.memoryUsage().rss
    console.log((((nr - rss) / 1024 / 1024).toFixed(2)));
}

上述程式,在一萬次迴圈中申請了一個1位元組的全域性快取,並申請了size大小的臨時快取(其引用會在迴圈中被垃圾回收)。我們分別給testBufferSlab傳遞兩個特殊的引數:Buffer.poolSizeBuffer.poolSize/2-1,並觀察結果時,奇怪的現象發生了(申請較大的Buffer時竟然消耗更少的記憶體):

testBufferSlab(Buffer.poolSize);
node --expose-gc test.js
output: 54.78

testBufferSlab(Buffer.poolSize/2-1);
node --expose-gc test.js
output: 5.2

究其原因就是:當新申請的Buffer的小於4Kb時(Buffer.poolSize/2),會使用slab演算法,即便當前Buffer塊已經沒有引用了,只要其對應slab上還有其他Buffer指向時,整個slab記憶體就無法釋放,這樣就會造成記憶體碎片。

本文同時發表在我的部落格積木村の研究所http://foio.github.io/node-memory/


參考文章:

https://www.ibm.com/developerworks/cn/java/j-lo-JVMGarbageCollection/ https://www.zhihu.com/question/20018826 https://github.com/caoxudong/oracle_jrockit_the_definitive_guide/blob/master/chap3/3.3.md http://newhtml.net/v8-garbage-collection/ http://taobaofed.org/blog/2016/04/15/how-to-find-memory-leak/ https://developer.mozilla.org/en/docs/Web/JavaScript/Reference/Global_Objects/WeakMap https://github.com/promises-aplus/promises-spec/issues/179 https://www.ibm.com/developerworks/cn/linux/l-linux-slab-allocator/ https://nodejs.org/api/buffer.html http://stackoverflow.com/questions/14009048/what-makes-node-js-slowbuffers-slow https://github.com/nodejs/node-v0.x-archive/issues/4525

相關文章