Node記憶體限制與垃圾回收

小師太發表於2018-01-05

物件分配

所有的JS物件都是通過堆來進行分配的。

使用process.memoryUsage()檢視使用情況 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.

> process.memoryUsage()
{ rss: 27541504, 
  heapTotal: 9437184, 
  heapUsed: 5897048,
  external: 8935 }
  // 單位 位元組
  // rss (resident set size) 常駐程式記憶體 所有記憶體佔用
  // heapTotal 已申請堆記憶體
  // heapUsed 已使用堆記憶體
  // external c++物件繫結到js的記憶體
複製程式碼

記憶體限制

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

img

V8的堆組成

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

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

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

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

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

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

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

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

如何解除記憶體限制?

利用堆外記憶體: 使用Buffer類。Buffer 效能相關部分由C++實現。Buffer教程

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

使用stream處理大檔案 stream教程

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

垃圾回收機制

img

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

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

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

在V8中,物件堆被分為兩個部分:新建立的物件所在的新生代,以及在一個垃圾回收週期後存活的物件被提升到的老生代。
如果一個物件在一個垃圾回收週期中被移動,那麼V8將會更新所有指向此物件的指標。
複製程式碼

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

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

主要演算法
  1. Scavenge演算法 (打掃)

    特點:犧牲空間換時間

    將堆記憶體一分為二,一個處於使用中,一個處於閒置。
    檢查使用中空間的存活物件,將存活物件複製到閒置空間中。
    完成複製後閒置空間和使用中空間角色互換。
    當一個物件經過多次複製依然存活時,它會被認為是生命週期較長的物件,會被晉升到老生代中。
    晉升的條件有兩個,一個是物件是否經歷過Scavenge回收,一個是空閒空間的記憶體佔用比超過25%。(為何不是50%呢?)

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

  2. 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;
}
複製程式碼

case2 : 無限增長陣列

var arr = [];
function x (value){
    arr.push(value);
}
複製程式碼

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 相當於在這個物件結構中對應的 type 跟著的陣列增加一個回撥處理函式。

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

case4: 小測試

洩漏了嗎?
var run = function () {
  var str = new Array(1000000).join('*');
  var doSomethingWithStr = function () {
    if (str === 'something')
      console.log("str was something");
  };
  doSomethingWithStr();
};
setInterval(run, 1000);
複製程式碼
洩漏了嗎?
var run = function () {
  var str = new Array(1000000).join('*');
  var logIt = function () {
    console.log('interval');
  };
  setInterval(logIt, 100);
};
setInterval(run, 1000);
複製程式碼
洩漏了嗎?
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);
複製程式碼

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


相關文章