Node.js內存管理和V8垃圾回收機制

對於 Node.js 服務端研發的同窗來講,關於垃圾回收、內存釋放這塊不須要向 C/C++ 的同窗那樣在建立一個對象以後還須要手動建立一個 delete/free 這樣的一個操做進行 GC(垃圾回收), Node.js 與 Java 同樣,由虛擬機進行內存自動管理,可是這樣並不表示就此能夠高枕無憂了,在開發中可能因爲疏忽或者程序錯誤致使的內存泄漏也是一個很嚴重的問題,因此作爲一名合格的服務端研發工程師,仍是有必要的去了解下虛擬機是怎樣使用內存的,遇到問題才能從容應對。javascript

快速導航

  • Nodejs中的GC
  • Nodejs垃圾回收內存管理實踐
    • 內存泄漏識別
    • 內存泄漏例子
    • 手動執行垃圾回收內存釋放
  • V8垃圾回收機制
    • V8堆內存限制
    • 新生代與老生代
    • 新生代空間 & Scavenge 算法
    • 老生代空間 & Mark-Sweep Mark-Compact 算法
    • V8垃圾回收總結
  • 內存泄漏
    • 全局變量
    • 閉包
    • 慎將內存作爲緩存
    • 模塊私有變量內存永駐
    • 事件重複監聽
    • 其它注意事項
  • 內存檢測工具

Nodejs中的GC

Node.js 是一個基於 Chrome V8 引擎的 JavaScript 運行環境,這是來自 Node.js 官網的一段話,因此 V8 就是 Node.js 中使用的虛擬機,在以後講解的 Node.js 中的 GC 其實就是在講 V8 的 GC。html

Node.js 與 V8 的關係也比如 Java 之於 JVM 的關係,另外 Node.js 之父 Ryan Dahl 在選擇 V8 作爲 Node.js 的虛擬機時 V8 的性能在當時已經領先了其它全部的 JavaScript 虛擬機,至今仍然是性能最好的,所以咱們在作 Node.js 優化時,只要版本升級性能也會伴隨着被提高。java

Nodejs垃圾回收內存管理實踐

先經過一個 Demo 來看看在 Node.js 中進行垃圾回收的過程是怎樣的?node

內存泄漏識別

在 Node.js 環境裏提供了 process.memoryUsage 方法用來查看當前進程內存使用狀況,單位爲字節git

  • rss(resident set size):RAM 中保存的進程佔用的內存部分,包括代碼自己、棧、堆。
  • heapTotal:堆中總共申請到的內存量。
  • heapUsed:堆中目前用到的內存量,判斷內存泄漏咱們主要以這個字段爲準。
  • external: V8 引擎內部的 C++ 對象佔用的內存。
/** * 單位爲字節格式爲 MB 輸出 */
const format = function (bytes) {
    return (bytes / 1024 / 1024).toFixed(2) + ' MB';
};

/** * 封裝 print 方法輸出內存佔用信息 */
const print = function() {
    const memoryUsage = process.memoryUsage();

    console.log(JSON.stringify({
        rss: format(memoryUsage.rss),
        heapTotal: format(memoryUsage.heapTotal),
        heapUsed: format(memoryUsage.heapUsed),
        external: format(memoryUsage.external),
    }));
}
複製代碼

內存泄漏例子

堆用來存放對象引用類型,例如字符串、對象。在如下代碼中建立一個 Fruit 存放於堆中。github

// example.js
function Quantity(num) {
    if (num) {
        return new Array(num * 1024 * 1024);
    }

    return num;
}

function Fruit(name, quantity) {
    this.name = name
    this.quantity = new Quantity(quantity)
}

let apple = new Fruit('apple');
print();
let banana = new Fruit('banana', 20);
print();
複製代碼

執行以上代碼,內存向下面所展現的,apple 對象 heapUsed 的使用僅有 4.21 MB,而 banana 咱們對它的 quantity 屬性建立了一個很大的數組空間致使 heapUsed 飆升到 164.24 MB。算法

$ node example.js

{"rss":"19.94 MB","heapTotal":"6.83 MB","heapUsed":"4.21 MB","external":"0.01 MB"}
{"rss":"180.04 MB","heapTotal":"166.84 MB","heapUsed":"164.24 MB","external":"0.01 MB"}
複製代碼

咱們在來看下內存的使用狀況,根節點對每一個對象都持有引用,則沒法釋聽任何內容致使沒法 GC,正以下圖所展現的數組

圖片描述

手動執行垃圾回收內存釋放

假設 banana 對象咱們不在使用了,對它從新賦予一些新的值,例如 banana = null,看下此刻會發生什麼?緩存

圖片描述

結果如上圖所示,沒法從根對象在到達到 Banana 對象,那麼在下一個垃圾回收器運行時 Banana 將會被釋放。安全

讓咱們模擬一下垃圾回收,看下實際狀況是什麼樣的?

// example.js
let apple = new Fruit('apple');
print();
let banana = new Fruit('banana', 20);
print();
banana = null;
global.gc();
print();
複製代碼

如下代碼中 --expose-gc 參數表示容許手動執行垃圾回收機制,將 banana 對象賦爲 null 後進行 GC,在第三個 print 打印出的結果能夠看到 heapUsed 的使用已經從 164.24 MB 降到了 3.97 MB

$ node --expose-gc example.js
{"rss":"19.95 MB","heapTotal":"6.83 MB","heapUsed":"4.21 MB","external":"0.01 MB"}
{"rss":"180.05 MB","heapTotal":"166.84 MB","heapUsed":"164.24 MB","external":"0.01 MB"}
{"rss":"52.48 MB","heapTotal":"9.33 MB","heapUsed":"3.97 MB","external":"0.01 MB"}
複製代碼

下圖所示,右側的 banana 節點沒有了任何內容,通過 GC 以後所佔用的內存已經被釋放了。

圖片描述

V8垃圾回收機制

垃圾回收是指回收那些在應用程序中不在引用的對象,當一個對象沒法從根節點訪問這個對象就會作爲垃圾回收的候選對象。這裏的根對象能夠爲全局對象、局部變量,沒法從根節點訪問指的也就是不會在被任何其它活動對象所引用。

V8堆內存限制

內存在服務端原本就是一個寸土寸金的東西,在 V8 中限制 64 位的機器大約 1.4GB,32 位機器大約爲 0.7GB。所以,對於一些大內存的操做需謹慎不然超出 V8 內存限制將會形成進程退出。

一個內存溢出超出邊界限制的例子

// overflow.js
const format = function (bytes) {
    return (bytes / 1024 / 1024).toFixed(2) + ' MB';
};

const print = function() {
    const memoryUsage = process.memoryUsage();
    console.log(`heapTotal: ${format(memoryUsage.heapTotal)}, heapUsed: ${format(memoryUsage.heapUsed)}`);
}

const total = [];
setInterval(function() {
    total.push(new Array(20 * 1024 * 1024)); // 大內存佔用
    print();
}, 1000)
複製代碼

以上例子中 total 爲全局變量每次大約增加 160 MB 左右且不會被回收,在接近 V8 邊界時沒法在分配內存致使進程內存溢出。

$ node overflow.js
heapTotal: 166.84 MB, heapUsed: 164.23 MB
heapTotal: 326.85 MB, heapUsed: 324.26 MB
heapTotal: 487.36 MB, heapUsed: 484.27 MB
heapTotal: 649.38 MB, heapUsed: 643.98 MB
heapTotal: 809.39 MB, heapUsed: 803.98 MB
heapTotal: 969.40 MB, heapUsed: 963.98 MB
heapTotal: 1129.41 MB, heapUsed: 1123.96 MB
heapTotal: 1289.42 MB, heapUsed: 1283.96 MB

<--- Last few GCs --->

[87581:0x103800000]    11257 ms: Mark-sweep 1283.9 (1290.9) -> 1283.9 (1290.9) MB, 512.1 / 0.0 ms  allocation failure GC in old space requested
[87581:0x103800000]    11768 ms: Mark-sweep 1283.9 (1290.9) -> 1283.9 (1287.9) MB, 510.7 / 0.0 ms  last resort GC in old space requested
[87581:0x103800000]    12263 ms: Mark-sweep 1283.9 (1287.9) -> 1283.9 (1287.9) MB, 495.3 / 0.0 ms  last resort GC in old space requested


<--- JS stacktrace --->
複製代碼

在 V8 中也提供了兩個參數僅在啓動階段調整內存限制大小

分別爲調整老生代、新生代空間,關於老生代、新生代稍後會作介紹。

  • --max-old-space-size=2048
  • --max-new-space-size=2048

固然內存也並不是越大越好,一方面服務器資源是昂貴的,另外一方面聽說 V8 以 1.5GB 的堆內存進行一次小的垃圾回收大約須要 50 毫秒以上時間,這將會致使 JavaScript 線程暫停,這也是最主要的一方面。

新生代與老生代

絕對大多數的應用程序對象的存活週期都會很短,而少數對象的存活週期將會很長爲了利用這種狀況,V8 將堆分爲兩類新生代和老生代,新空間中的對象都很是小大約爲 1-8MB,這裏的垃圾回收也很快。新生代空間中垃圾回收過程當中倖存下來的對象會被提高到老生代空間。

新生代空間

因爲新空間中的垃圾回收很頻繁,所以它的處理方式必須很是的快,採用的 Scavenge 算法,該算法由 C.J. Cheney 在 1970 年在論文 A nonrecursive list compacting algorithm 提出。

Scavenge 是一種複製算法,新生代空間會被一分爲二劃分紅兩個相等大小的 from-space 和 to-space。它的工做方式是將 from space 中存活的對象複製出來,而後移動它們到 to space 中或者被提高到老生代空間中,對於 from space 中沒有存活的對象將會被釋放。完成這些複製後在將 from space 和 to space 進行互換。

圖片描述

Scavenge 算法很是快適合少許內存的垃圾回收,可是它有很大的空間開銷,對於新生代少許內存是能夠接受的。

老生代空間

新生代空間在垃圾回收知足必定條件(是否經歷過 Scavenge 回收、to space 的內存佔比)會被晉升到老生代空間中,在老生代空間中的對象都已經至少經歷過一次或者屢次的回收因此它們的存活機率會更大。在使用 Scavenge 算法則會有兩大缺點一是將會重複的複製存活對象使得效率低下,二是對於空間資源的浪費,因此在老生代空間中採用了 Mark-Sweep(標記清除) 和 Mark-Compact(標記整理) 算法。

Mark-Sweep

Mark-Sweep 處理時分爲標記、清除兩個步驟,與 Scavenge 算法只複製活對象相反的是在老生代空間中因爲活對象佔多數 Mark-Sweep 在標記階段遍歷堆中的全部對象僅標記活對象把未標記的死對象清除,這時一次標記清除就已經完成了。

圖片描述

看似一切 perfect 可是還遺留一個問題,被清除的對象遍及於各內存地址,產生不少內存碎片。

Mark-Compact

在老生代空間中爲了解決 Mark-Sweep 算法的內存碎片問題,引入了 Mark-Compact(標記整理算法),其在工做過程當中將活着的對象往一端移動,這時內存空間是緊湊的,移動完成以後,直接清理邊界以外的內存。

圖片描述

V8垃圾回收總結

爲什麼垃圾回收是昂貴的?V8 使用了不一樣的垃圾回收算法 Scavenge、Mark-Sweep、Mark-Compact。這三種垃圾回收算法都避免不了在進行垃圾回收時須要將應用程序暫停,待垃圾回收完成以後在恢復應用邏輯,對於新生代空間來講因爲很快因此影響不大,可是對於老生代空間因爲存活對象較多,停頓仍是會形成影響的,所以,V8 又新增長了增量標記的方式減小停頓時間。

關於 V8 垃圾回收這塊筆者講的很淺只是本身在學習過程當中作的總結,若是你想了解更多原理,深刻淺出 Node.js 這本書是一個不錯的選擇,還可參考這兩篇文章 A tour of V8: Garbage CollectionMemory Management Reference.

內存泄漏

內存泄漏(Memory Leak)是指程序中己動態分配的堆內存因爲某種緣由程序未釋放或沒法釋放,形成系統內存的浪費,致使程序運行速度減慢甚至系統崩潰等嚴重後果。

全局變量

未聲明的變量或掛在全局 global 下的變量不會自動回收,將會常駐內存直到進程退出纔會被釋放,除非經過 delete 或 從新賦值爲 undefined/null 解決之間的引用關係,纔會被回收。關於全局變量上面舉的幾個例子中也有說明。

閉包

這個也是一個常見的內存泄漏狀況,閉包會引用父級函數中的變量,若是閉包得不到釋放,閉包引用的父級變量也不會釋放從而致使內存泄漏。

一個真實的案例 — The Meteor Case-Study,2013年,Meteor 的建立者宣佈了他們遇到的內存泄漏的調查結果。有問題的代碼段以下

var theThing = null
var replaceThing = function () {
  var originalThing = theThing
  var unused = function () {
    if (originalThing)
      console.log("hi")
  }
  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {
      console.log(someMessage)
    }
  };
};
setInterval(replaceThing, 1000)
複製代碼

以上代碼運行時每次執行 replaceThing 方法都會生成一個新的對象,可是以前的對象沒有釋放致使的內存泄漏。這塊涉及到一個閉包的概念 「同一個做用域生成的閉包對象是被該做用域中全部下一級做用域共同持有的」 由於定義的 unused 使用了做用域的 originalThing 變量,所以 replaceThing 這一級的函數做用域中的閉包(someMethod)對象也持有了 originalThing 變量(重點:someMethod 的閉包做用域和 unused 的做用域是共享的),之間的引用關係就是 theThing 引用了 longStr 和 someMethodsomeMethod 引用了 originalThingoriginalThing 又引用了上次的 theThing,所以造成了鏈式引用。

上述代碼來自 Meteor blog An interesting kind of JavaScript memory leak,更多理解還可參考 Node-Interview issues #7 討論

慎將內存作爲緩存

經過內存來作緩存這多是咱們想到的最快的實現方式,另外業務中緩存仍是很經常使用的,可是瞭解了 Node.js 中的內存模型和垃圾回收機制以後在使用的時候就要謹慎了,爲何呢?緩存中存儲的鍵越多,長期存活的對象也就越多,垃圾回收時將會對這些對對象作無用功。

如下舉一個獲取用戶 Token 的例子,memoryStore 對象會隨着用戶數的增長而持續增加,如下代碼還有一個問題,當你啓動多個進程或部署在多臺機器會形成每一個進程都會保存一份,顯然是資源的浪費,最好是經過 Redis 作共享。

const memoryStore = new Map();

exports.getUserToken = function (key) {
    const token = memoryStore.get(key);

    if (token && Date.now() - token.now > 2 * 60) {
        return token;
    }

    const dbToken = db.get(key);
    memoryStore.set(key, {
        now: Date.now(),
        val: dbToken,
    });
    return token;
}
複製代碼

模塊私有變量內存永駐

在加載一個模塊代碼以前,Node.js 會使用一個以下的函數封裝器將其封裝,保證了頂層的變量(var、const、let)在模塊範圍內,而不是全局對象。

這個時候就會造成一個閉包,在 require 時會被加載一次,將 exports 對象保存於內存中,直到進程退出纔會回收,這個將會致使的是內存常駐,因此避免一些不必的模塊加載,不然也會形成內存增長。

(function(exports, require, module, __filename, __dirname) {
    // 模塊的代碼實際上在這裏
});
複製代碼

一個小的建議,對於一個模塊的引用建議僅在頭部初次加載以後使用 const 緩存起來,而不是在使用時每次都去加載一次(每次 require 都要進行路徑分析、緩存判斷的)

例1:

const a = require('a.js') // 推薦

function test() { 
    a.run()
}
複製代碼

例2:

function test(){ // 不推薦
  require('a.js').run()
}
複製代碼

事件重複監聽

在 Node.js 中對一個事件重複監聽則會報以下錯誤,實際上使用的 EventEmitter 類,該類包含一個 listeners 數組,默認爲 10 個監聽器超出這個數則會報警以下所示,用於發現內存泄漏,也能夠經過 emitter.setMaxListeners() 方法爲指定的 EventEmitter 實例修改限制。

(node:23992) MaxListenersExceededWarning: Possible EventEmitter memory leak detected. 11 connect listeners added. Use emitter.setMaxListeners() to increase limit
複製代碼

Cnode 論欄有篇文章分析了 Socket 重連致使的內存泄漏,參考 原生Socket重連策略不恰當致使的泄漏,還有 Node.js HTTP 模塊 Keep-Alive 產生的內存泄漏,參考 Github Node Issues #714

其它注意事項

在使用定時器 setInterval 時記的使用對應的 clearInterval 進行清除,由於 setInterval 執行完以後會返回一個值且不會自動釋放。另外還有 map、filter 等對數組進行操做,每次操做以後都會建立一個新的數組,將會佔用內存,若是單純的遍歷例如 map 可使用 forEach 代替,這些都是開發中的一些細節,可是每每細節決定成敗,每一次的內存泄漏也都是一次次的不經意間形成的。所以,這些點也是須要咱們注意的。

console.log(setInterval(function(){}, 1000)) // 返回一個 id 值
[1, 2, 3].filter(item => item % 2 === 0) // [2]
[1, 2, 3].map(item => item % 2 === 0) // [false, true, false]
複製代碼

內存檢測工具

node-heapdump

heapdump是一個dumpV8堆信息的工具,node-heapdump

node-profiler

node-profiler 是 alinode 團隊出品的一個 與node-heapdump 相似的抓取內存堆快照的工具,node-profiler

Easy-Monitor

輕量級的 Node.js 項目內核性能監控 + 分析工具,github.com/hyj1991/eas…

Node.js-Troubleshooting-Guide

Node.js 應用線上/線下故障、壓測問題和性能調優指南手冊,Node.js-Troubleshooting-Guide

alinode

Node.js 性能平臺(Node.js Performance Platform)是面向中大型 Node.js 應用提供 性能監控、安全提醒、故障排查、性能優化等服務的總體性解決方案。alinode

閱讀推薦

相關文章
相關標籤/搜索