NodeJs中內存機制

node對內存泄露十分敏感,因爲一旦我們線上有成千上萬的大流量,即使是一個字節的內存泄露也會造成堆積,垃圾回收過程中會耗費很多時間進行對象掃描,導致我們的應用響應緩慢,直到進程內存溢出,整個應用崩潰。
一般情況下日常開發中我們應該不會遇到上述這種情況,不過一旦遇到的話還是需要引起我們的特別關注。

內存機制

整體上來講,Node的內存應該分爲兩個部分。ChromeV8管理的部分(Javascript使用的部分),系統底層管理的部分(C++/C使用的部分)
二者實際上應該是處於一種包含關係。即ChromeV8的部分應當包含在系統底層管理的部分當中。

Chrome v8的內存管理機制

內存管理模型

node程序運行中,此進程佔用的所有內存稱爲常駐內存。常駐內存由以下幾部分組成:

  • 代碼區:存放即將執行的代碼片段
  • 棧:存放局部變量
  • 堆:存放對象和閉包上下文,v8使用垃圾回收機制管理堆內存
  • 堆外內存:不通過V8分配,也不受V8管理。Buffer對象的數據就存放於此。

除堆外內存,其餘部分均由V8管理。

  1. 棧的分配與回收非常直接,當程序離開某作用域後,其棧指針下移(回退),整個作用域的局部變量都會出棧,內存收回。
  2. 最複雜的部分是堆的管理,V8使用垃圾回收機制進行堆的內存管理,也是開發中可能造成內存泄漏的部分,是程序員的關注點,也是本文的探討點。

內存的限制

node不像其他後端語言,對內存的使用沒有大小限制。在node中使用內存,其實只能使用到系統的一部分內存,這是因爲node基於V8構建,V8的內存管理機制限制了內存的用量。

V8爲何要限制堆的大小?原因是V8的垃圾回收機制的限制。垃圾回收會引起JavaScript線程暫停執行;內存太大,垃圾回收時間太長,在當時的考慮下,直接限制了堆內存大小。

老生代 新生代(默認) 新生代(最大)
64位系統 1400MB 32MB 64MB
32位系統 700MB 16MB 32MB

我們知道v8引擎的設計初衷其實只是運行在瀏覽器中,在瀏覽器的一般應用場景下使用起來是綽綽有餘的,能夠勝任前端頁面中的幾乎所有需求。雖然服務端操作大內存的場景不常見,但是如果有這樣的需求,是可以解除限制的,即在啓動node應用程序時,可以通過傳遞兩個參數來調整內存限制的大小,在v8初始化時生效,一旦修改不能變化。

  • 新生代 node --max-nex-space-size=1024 app.js // 單位爲KB
  • 老生代 node --max-old-space-size=2000 app.js // 單位爲MB

不受內存限制的特例

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

在不需要進行字符串操作時,可以不借助v8,使用Buffer操作,這樣不會受到v8的內存限制

內存的分配

chrome v8中所有的javascript對象都是堆存儲,當在代碼中聲明變量並賦值時,所使用對象的內存就分配在堆中。如果已申請的空閒內存不夠分配新的對象,將繼續申請堆內存,直到堆的大小超過V8的限制爲止。
在這裏插入圖片描述

Chrome v8的垃圾回收機制

分代式垃圾回收機制

V8的垃圾回收策略主要基於分代式垃圾回收機制,基於這個機制,V8把堆內存分爲新生代(New Space)和老生代 (Old Space)。
在這裏我們說下老生代和新生代的區別:

對象存活時間 內存空間
老生代 存活時間較長或常駐內存的對象 –max-old-space-size命令設置老生代內存空間的最大值
新生代 存活時間較短的對象 –max-new-space-size命令設置新生代內存空間的大小

在這裏插入圖片描述
在這裏會有個疑問,爲什麼要分爲新老生代呢?

原因是因爲:垃圾回收算法有很多種,但是並沒有一種是勝任所有的場景,在實際的應用中,需要根據對象的生存週期長短不一,而使用不同的算法,來達到最好的效果。在V8中,按對象的存活時間將內存的垃圾回收進行不同的分代,然後分別對不同的內存施以更高效的算法。所以就有個新老生代之分。

  1. 新生代中的垃圾回收

在新生代中,主要通過Scavenge算法進行垃圾回收。具體實現採用Cheney算法。

Scavenge算法原理

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

優點:時間短。
缺點:只能使用一半堆內存。新生代對象生命週期短,適合此算法。

在這裏插入圖片描述
注:當對象經過多次複製依然存活,就會晉升到老生代

在新生代中的對象怎樣才能到老生代中?

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

  • 對象是否經歷過Scavenge回收

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

  • To空間的內存佔比超過25%限制

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

  1. 老生代中的垃圾回收

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

Mark-Sweep
Mark-Sweep的字面意思是標記清除,分爲標記和清除兩個階段。在標記階段遍歷堆中的所有對象,並標記存活的對象,在隨後的清除階段中,只清除標記之外的對象。
但是Mark-Sweep有一個很嚴重的問題,就是進行一次標記清除回收之後,內存會變得碎片化。如果需要分配一個大對象,這時候就無法完成分配了。這時候就需要用到Mark-Compact了。

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

Incremental Marking(增量標記)

主要分爲如下三步:

  • 降低老生代的全堆垃圾回收帶來的時間停頓
  • 從標記階段入手,拆分爲許多小步進,與應用邏輯交替運行
  • 垃圾回收最大停頓時間降爲原來的1/6

具體實現如下:

由於Node單線程的特性,V8每次垃圾回收的時候,都需將應用邏輯暫停,待執行完垃圾回收後再恢復應用邏輯,被稱爲全停頓。在分代垃圾回收中,一次小垃圾回收只收集新生代,且存活對象也相對較少,即使全停頓也沒多大影響。但在老生代中,存活對象較多,垃圾回收的標記、清理、整理都需長時間停頓,這樣會嚴重影響系統的性能。

所以增量標記 (Incrememtal Marking)被提出來。它從標記階段入手,將原本要一口氣停頓完成的動作改爲增量標記,拆分爲許多小步,每做完一步進就讓JavaScript應用邏輯執行一小會,垃圾回收與應用邏輯這樣交替執行直到標記階段完成。

注:垃圾回收是影響性能的因素之一,要儘量減少垃圾回收,尤其全堆垃圾回收

內存C/C++的部分

這是node的原生部分也是根本上區別與前端js的部分,包括核心運行庫,在一些核心模塊的加載過程中,Node會調用一個名爲js2c的工具。這個工具會將核心的js模塊代碼以C數組的方式存儲在內存中,用以提升運行效率。

在這個部分,我們也不會有內存的使用限制,但是作爲C/C++擴展來使用大量內存的過程中,風險也是顯而易見的。

C/C++沒有內存回收機制。作爲沒有C/C++功底的純前端程序員,不建議去使用這部分,因爲C/C++模塊非常強大,如果對於對象生命週期的理解不夠到位,而在使用大內存對象的情境中,很容易就造成內存溢出導致整個Node的崩潰甚至是系統的崩潰。安全的使用大內存的方法就是使用buffer對象。

使用javascript的部分是由ChromeV8接管的嗎?那爲什麼仍然可以使用大量內存創立緩存呢?

這是node運行在服務端和Chrome運行在前端的區別,Chrome和Node都採用ChromeV8作爲JS的引擎,但是實際上他們所面對的對象是不同的,Node面對的是數據提供,邏輯和I/O,而Chrome面對的是界面的渲染,數據的呈現。因此在Chrome上,幾乎不會遇到大內存的情況,作爲爲Chrome的而生的V8引擎自然也不會考慮這種情況,因此纔會出現上文所述的內存限制。而現在,Node面對這樣的情況是不可以接受的,所以Buffer對象,是一個特殊的對象,它由更低層的模塊創建,存儲在V8引擎以外的內存空間上。

在內存的層面上講Buffer和V8是平級的。

如何高效使用內存

  • 手動銷燬變量
    js中能形成作用域的有函數調用、with和全局作用域
    例如,在函數調用時,會創建對應的作用域,在執行結束後銷燬,並且在該作用域申明的局部變量也會被銷燬
  1. 標識符查找(即變量名) 先查找當前作用域,再向上級作用域,一直到全局作用域
  2. 變量主動釋放 全局變量要直到進程退出才釋放,導致引用對象常駐老生代,可以用delete刪除或者賦undefined、null(delete刪除對象的屬性可能干擾v8,所以賦值更好)
  • 慎用閉包
    閉包是外部作用域訪問內部作用域的方法,得益於高階函數特性
var foo = function() {
   var bar = function() {
       var local = "內部變量";
       return function() {
           return local;
       };
   };
   var baz = bar();
   console.log(baz());
};

從上面代碼知bar()返回一個匿名函數,一旦 有變量引用它,它的作用域將不會釋放,直到沒有引用。
注:把閉包賦值給一個不可控的對象時,會導致內存泄漏。使用完,將變量賦其他值或置空

  • 大內存使用
  1. 使用stream,當我們需要操作大文件,應該利用Node提供的stream以及其管道方法,防止一次性讀入過多數據,佔用堆空間,增大堆內存壓力。
  2. 使用Buffer,Buffer是操作二進制數據的對象,不論是字符串還是圖片,底層都是二進制數據,因此Buffer可以適用於任何類型的文件操作。
    Buffer對象本身屬於普通對象,保存在堆,由V8管理,但是其儲存的數據,則是保存在堆外內存,是有C++申請分配的,因此不受V8管理,也不需要被V8垃圾回收,一定程度上節省了V8資源,也不必在意堆內存限制。

查看內存使用情況

  • process.memoryUsage():表示查看進程內存佔用,其中rss爲進程的常駐內存(node所佔的內存), 是分配的整體物理內存,包括堆、棧、代碼段, heapTotal整體堆內存、heapUsed爲堆內存使用情況,external: 代表v8管理的綁定到javascript的c++對象的內存,可以看到,rss是大於heapTotal的,因爲rss包括且不限於堆。
  • os.totalmem(),os.freemem() 查看系統內存。

注:Node使用的內存不是都通過v8分配,還有堆外內存,用於處理網絡流、I/O流

內存泄露

原因:緩存、隊列消費不及時、作用域未釋放等。
緩存:

  • 限制內存當緩存,要限制好大小,做好釋放
  • 進程之間不能共享內存,所以用內存做緩存也是

爲了加速模塊引入,模塊會在編譯後緩存,由於通過exports導出(閉包),作用域不會釋放,常駐老生代。要注意內存泄漏。

var arr = [];
exports.hello = function() {
    arr.push("hello" + Math.random());
};
//局部變量arr不停增加內存佔用,且不會釋放,如果必須如此設計,要提供釋放接口

隊列狀態

  • 在生產者和消費者中間
  • 監控隊列的長度,超過長度就拒絕
  • 任意的異步調用應該包含超時機制

內存泄露排查的工具

  • node-heapdump
  1. 安裝 npm install heapdump
  2. 引入 var heapdump = require(‘heapdump’);
  3. 發送命令kill -USR2 ,heapdump會抓拍一份堆內存快照,文件爲heapdump-..heapsnapshot格式,是json文件
  • node-memwatch
var memwatch = require('memwatch');
memwatch.on('leak', function(info) {
   console.log('leak:');
   console.log(info);
});
memwatch.on('stats', function(stats) {
   console.log('stats:') console.log(stats);
});

在進程使用node-memwatch後,每次全堆垃圾回收,會觸發stats事件,該事件會傳遞內存的統計信息

stats: {
        num_full_gc: 4, //   第幾次全堆垃圾回收
        num_inc_gc: 23, //    第幾次增量垃圾回收 
        heap_compactions: 4, //  第幾次對老生代整理
        usage_trend: 0, // 使用趨勢
        estimated_base: 7152944, // 預估基數 
        current_base: 7152944, // 當前基數
        min: 6720776, //  最小
        max: 7152944  //最大
    } 

如果經過連續的5次垃圾回收後,內存仍沒有被釋放,意味有內存泄漏,node-memwatch會觸發leak事件。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章