Node記憶體限制和垃圾回收

JavaDog發表於2019-01-25

Node 記憶體使用問題

一般後端開發語言中,在記憶體使用上沒有什麼限制。然而在 node 中使用的話會發現只能使用部分。

v8 在 64 位系統下只能使用 1.4GB 記憶體,在 32 位系統下只能使用 0.7GB 記憶體。複製程式碼

導致的問題:Node 無法直接操作大檔案物件。

例如我想讀取一個 4g 的檔案來處理,即使實體記憶體有 32GB,在單個 Node 程式中也是不能完全的使用的。

記憶體限制的主要原因在於 Node 基於 v8 構建

v8 物件分配

所有的 JS 物件都是通過堆來進行分配的。
使用 process.memoryUsage() 檢視使用情況複製程式碼
> node
> process.memoryUsage()

{
    rss: 21917696, // rss (resident set size) 程式的常駐記憶體
    heapTotal: 7684096, // 已申請到的堆記憶體
    heapUsed: 5147296, // 當前使用的堆記憶體
    external: 8655
}

// 以上單位 位元組

複製程式碼

參考 Node.js v6.11.3 文件
heapTotal and heapUsed refer to V8's memory usage. external refers to the memory usage of C++ objects bound to JavaScript objects managed by V8.

v8 垃圾回收機制

Node 記憶體限制主要原因是 v8 的垃圾回收制度。
以 1.5GB 的垃圾回收堆記憶體為例,做一次小的回收需要 50MS,做一次非增量性回收需要 1S 以上,
並且這會使 JS 執行緒暫停。
因此限制記憶體。複製程式碼

Node 常駐記憶體組成

Node記憶體限制和垃圾回收

除了堆外記憶體,其餘都由 v8 管理

V8 的堆組成

V8 的堆由一系列區域組成:

新生代區:大多數物件的建立被分配在這裡,這個區域很小,但垃圾回收非常頻繁,獨立於其它區。這裡面的物件存活時間很短。

老生代指標區:包含大部分可能含有指向其它物件指標的物件。大多數從新生代晉升(存活一段時間)的物件會被移動到這裡。

老生代資料區:包含原始資料物件(沒有指標指向其它物件)。Strings、boxed numbers 以及雙精度 unboxed 陣列從新生代中晉升後被移到這裡。

大物件區:這裡存放大小超過其它區的大物件。每個物件都有自己 mmap 記憶體。大物件不會被回收。

程式碼區:程式碼物件(即包含被 JIT 處理後的指令物件)存放在此。唯一的有執行許可權的區域(程式碼過大也可能存放在大物件區,此時它們也可被執行,但不代表大物件區都有執行許可權)。

Cell 區、屬性 Cell 區以及 map 區:包含 cell、屬性 cell 以及 map。每個區都存放他們指向的相同大小以及相同結構的物件。

如何解除記憶體限制?

在啟動 node 時,傳遞 --max-old-space-size=4096 (調整老生代記憶體限制,單位為 mb。--max-new-space-size 已經不可用了)

利用堆外記憶體: 使用 Buffer 類。Buffer 效能相關部分由 C++ 實現。Buffer 所佔用的記憶體不是通過 v8 分配的,屬於堆外記憶體。這樣記憶體的分配回收的問題就丟給 c++ 來管了。

使用 stream 處理大檔案

官方建議:it is recommended that you split your single process into several workers if you are hitting memory limits. (拆分程式)

垃圾回收機制

Node記憶體限制和垃圾回收

V8 的垃圾回收有如下幾個特點
  1. 當處理一個垃圾回收週期時,暫停所有程式的執行。(stop-the-world 全停頓)

  2. 在大多數垃圾回收週期,每次僅處理部分堆中的物件,使暫停程式所帶來的影響降至最低。(增量標記等演算法)

  3. 準確知道在記憶體中所有的物件及指標,避免錯誤地把物件當成指標所帶來的記憶體洩露。(標記指標法:在每個指標的末位預留一位來標記這個字代表的是指標或資料。)

  4. 在V8中,物件堆被分為兩個部分:新建立的物件所在的新生代,以及在一個垃圾回收週期後存活的物件被提升到的老生代。

  5. 如果一個物件在一個垃圾回收週期中被移動,那麼V8將會更新所有指向此物件的指標。

沒有一種演算法能夠勝任所有場景,因此現代垃圾回收演算法中按物件的存活時間將記憶體的垃圾回收進行不同的分代,然後分別對不同分代實行不同演算法

v8 將記憶體分為新生代和老生代

主要演算法
  1. 新生代採用 Scavenge 演算法 (打掃)

    特點:犧牲空間換時間

    將新生代堆記憶體一分為二,一個處於使用中 (from 空間),一個處於閒置(to 空間。

    分配物件時先從 from 空間分配,垃圾回收時檢查 from 空間中的存活物件,將存活物件複製到閒置空間中, 將非存活物件佔用的空間釋放。
    完成複製後閒置空間和使用中空間角色互換。

    當一個物件經過多次複製依然存活時,它會被認為是生命週期較長的物件,會被晉升到老生代中。

晉升的條件有兩個,一個是物件是否經歷過 Scavenge 回收,一個是空閒空間的記憶體佔用比超過 25%。

為什麼是 25%

從使用空間中複製到空閒空間中,如果複製過來的已經佔了空閒空間的一半,那麼到時候交換成使用空間的時候,能用來分配的使用空間只剩 25%,這就有點少了,會有影響。

這種演算法缺點:只能使用一半的堆空間,適合應用在新生代中。

  1. Mark-Sweep & Mark-Compact (標記清除 & 標記整理)

    Mark-Sweep 遍歷堆中的所有物件,標記活著的物件。
    在清除階段清除所有沒有被標記的物件。
    缺點在於記憶體空間會出現不連續的狀態。

    Mark-Compact 與 Mark-Sweep 的差別在於,在整理過程中,將活著的物件往一端移動,然後清理掉邊界外的記憶體。

速度: Scavenge>Mark-Sweep>Mark-Compact

  1. Incremental Marking (增量標記)

    前三種演算法都需要將應用邏輯暫停,待垃圾回收完成後再恢復,即 “全停頓”。

    新生代較的打掃演算法主要是複製存活物件,而這個數量是比較小的,所以全停頓影響不大。但老生代很大,標記清除整理過一遍造成全停頓影響很大。

    為降低影響,將標記階段改為增量標記,也就是將標記拆分為很多小標記,每做完一步就讓 js 邏輯執行一會兒,垃圾回收與 JS 邏輯交替執行。

  2. lazy sweeping & incremental compaction 等

記憶體洩漏

常見原因
1.快取
2.佇列消費不及時
3.作用域未釋放複製程式碼

case1 : 快取

var cache = {};

function set(key, value) {
    cache[key] = value;
}複製程式碼

這樣快取物件會無限增大,也不能釋放就會導致記憶體洩漏的問題。

可以對快取的 key 值的個數加以限制。最好是使用程式外的快取,如 memcahed 和 redis。

case2 : 無限增長的陣列

var leakArr = [];

function xx () {
    leakArr.push(Math.random());
}複製程式碼

每一次呼叫 xx() , 導致 leakArr 不斷的增加記憶體的佔用。

如果非要這麼設計,一定到增加清空佇列相應的介面,以供呼叫者釋放記憶體。

case3: 無限重連導致的記憶體洩漏

const net = require('net');
let client = new net.Socket();

function connect () {
    client.connect(26665, '127.0.0.1', function callbackListener() {
        console.log('connected!');
    });
}
//第一次連線
connect();
client.on('error', function (error) {
    // console.error(error.message);
});
client.on('close', function () {
    //console.error('closed!');
    //洩漏程式碼
    client.destroy();
    setTimeout(connect, 1);
});複製程式碼

洩漏產生的原因其實也很簡單:event.js 核心模組實現的事件釋出 / 訂閱本質上是一個 js 物件結構(在 v6 版本中為了效能採用了 new EventHandles(),並且把 EventHandles 的原型置為 null 來節省原型鏈查詢的消耗),因此我們每一次呼叫 event.on 或者 event.once 相當於在這個物件結構中對應的 陣列增加一個回撥處理函式。

那麼這個例子裡面的洩漏屬於非常隱蔽的一種:net 模組的重連每一次都會給 client 增加一個 connect 事件 的偵聽器,如果一直重連不上,偵聽器會無限增加,從而導致洩漏。

小測試,是否有記憶體洩漏

  • test1

var run = function () {

    var str = new Array(1000000).join('*');

    var doSomethingWithStr = function () {

        if (str ==='something')
        	console.log("str was something");

    };
    doSomethingWithStr();
};

setInterval(run, 1000);複製程式碼

  • test2

var run = function() {
    var str = new Array(1000000).join('*');
    var logIt = function () {
        console.log('interval');
    }

    setInterval(logIt, 100);

};

setInterval(run, 1000);複製程式碼

  • test3

var run = function() {
    var str = new Array(1000000).join('*');
    var doSomethingWithStr = function () {
        if (str === 'something')
        console.log("str was something");
    };

    doSomethingWithStr();
 
    var logIt = function () {
        console.log('interval');
    }

    setInterval(logIt, 100);

};

setInterval(run, 1000);複製程式碼


一旦一個變數被任一個閉包使用了,它會在所有的閉包詞法環境結束之後才被釋放,這會導致記憶體洩漏。

參考:

  1. 《深入淺出 Node.js》樸靈

  2. suprising-javascript-memory-leak

  3. v8 垃圾回收

  4. 典型的記憶體洩漏

  5. 三水清部落格Node學習

Node記憶體限制和垃圾回收


相關文章