深入理解Node.js垃圾回收與內存管理

使用JavaScript進行前端開發時幾乎完全不需要關心內存管理問題,對于前端編程來說,V8限制的內存幾乎不會出現用完的情況,但是由于后端程序往往進行的操作更加復雜,并且長期運行在服務器不重啟,如果不關注內存管理,導致內存泄漏,就算1GB,也會很快用盡。
Node.js構建于V8引擎之上,因此本文首先講解V8引擎的內存管理機制,了解底層原理后,再講解Node開發中的內存管理與優化。

一、V8的內存管理機制

1.1 內存管理模型

Node程序運行中,此進程占用的所有內存稱為常駐內存(Resident Set)。

  • 常駐內存由以下部分組成:
    1. 代碼區(Code Segment):存放即將執行的代碼片段
    2. 棧(Stack):存放局部變量
    3. 堆(Heap):存放對象、閉包上下文
    4. 堆外內存:不通過V8分配,也不受V8管理。Buffer對象的數據就存放于此。


      V8內存模型

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

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

通過process.memoryUsage()可以查看此Node進程的內存使用狀況:

內存使用狀況

rss是Resident Set Size的縮寫,為常駐內存的總大小,heapTotal是V8為堆分配的總大小,heapUsed是已使用的堆大小。可以看到,rss是大于heapTotal的,因為rss包括且不限于堆。

1.2 堆內存限制

默認情況下,V8為堆分配的內存不超過1.4G:64位系統1.4G,32位則僅分配0.7G。也就是說,如果你想使用Node程序讀一個2G的文件到內存,在默認的V8配置下,是無法實現的。不過我們可以通過Node的啟動命令更改V8為堆設置的內存上限:

//更改老年代堆內存
--max-old-space-size=3000 // 單位為MB
// 更改新生代堆內存
--max-new-space-size=1024 // 單位為KB

堆的內存上限在啟動時就已經決定,無法動態更改,想要更改,唯一的方法是關閉進程,使用新的配置重新啟動。

1.3 V8的垃圾回收機制

垃圾回收機制演變至今,已經出現了數種垃圾回收算法,各有千秋,適用于不同場景,沒有一種垃圾回收算法能夠效率最優于所有場景。因此研發者們按照存活時間長短,將對象分類,為每一類特定的對象,制定其最適合的垃圾回收算法,以提高垃圾回收總效率。

  • 1.3.1 V8的內存分代

    • V8將堆中的對象分為兩類:
      1. 新生代:年輕的新對象,未經歷垃圾回收或僅經歷過一次
      2. 老年代:存活時間長的老對象,經歷過一次或更多次垃圾回收的對象


    默認情況下,V8為老年代分配的空間,大概是新生代的40多倍。
    新對象都會被分配到新生代中,當新生代空間不足以分配新對象時,將觸發新生代的垃圾回收。

  • 1.3.2 新生代的垃圾回收
    新生代中的對象主要通過Scavenge算法進行垃圾回收,這是一種采用復制的方式實現內存回收的算法。
    Scavenge算法將新生代的總空間一分為二,只使用其中一個,另一個處于閑置,等待垃圾回收時使用。使用中的那塊空間稱為From,閑置的空間稱為To

    From與To各占一半

    當新生代觸發垃圾回收時,V8將From空間中所有應該存活下來的對象依次復制到To空間。

    • 有兩種情況不會將對象復制到To空間,而是晉升至老年代:
      1. 對象此前已經經歷過一次新生代垃圾回收,這次依舊應該存活,則晉升至老年代。
      2. To空間已經使用了25%,則將此對象直接晉升至老年代。


    From空間所有應該存活的對象都復制完成后,原本的From空間將被釋放,成為閑置空間,原本To空間則成為使用中空間,兩個空間進行角色翻轉
    為何To空間使用超過25%時,就需要直接將對象復制到老年代呢?因為To空間完成垃圾回收后將會翻轉為From空間,新的對象分配都在此處進行,如果沒有足夠的空閑空間,將會影響程序的新對象分配。
    因為Scavenge只復制活著的對象,而根據統計學指導,新生代中大多數對象壽命都不長,長期存活對象少,則需要復制的對象相對來說很少,因此總體來說,新生代使用Scavenge算法的效率非常高。且由于Scavenge是依次連續復制,所以To空間永遠不會存在內存碎片。
    不過由于Scavenge會將空間對半劃分,所以此算法的空間利用率較低。

  • 1.3.3 老年代的垃圾回收
    在老年代中的對象,至少都已經歷過一次甚至更多次垃圾回收,相對于新生代中的對象,它們有更大的概率繼續存活,只有相對少數的對象面臨死亡,且由于老年代的堆內存是新生代的幾十倍,其中生活著大量對象,因此如果使用Scavenge算法回收老年代,將會面臨大量的存活對象需要復制的情況,將老年代空間對半劃分,也會浪費相當大的空間,效率低下。因此老年代垃圾回收主要采用標記清除(Mark-Sweep)標記整理(Mark-Compact)
    這兩種方式并非互相替代關系,而是配合關系,在不同情況下,選擇不同方式,交替配合以提高回收效率。
    新生代中死亡對象占多數,因此采用Scavenge算法只處理存活對象,提高效率。老年代中存活對象占多數,于是采用標記清除算法只處理死亡對象,提高效率。
    當老年代的垃圾回收被觸發時,V8會將需要存活對象打上標記,然后將沒有標記的對象,也就是需要死亡的對象,全部擦除,一次標記清除式回收就完成了:

    灰色為存活對象,白色為清除后的閑置空間

    一切看起來都完美了,可是隨著程序的繼續運行,卻會出現一個問題:被清除的對象遍布各個內存地址,空間有大有小,其閑置空間不連續,產生了很多內存碎片。當需要將一個足夠大的對象晉升至老年代時,無法找到一個足夠大的連續空間安置這個對象。
    為了解決這種空間碎片的問題,就出現了標記整理算法。它是在標記清除的基礎上演變而來,當清理了死亡對象后,它會將所有存活對象往一端移動,使其內存空間緊挨,另一端就成為了連續內存:

    雖然標記整理算法可以避免空間碎片,但是卻需要依次移動對象,效率比標記清除算法更低,因此大多數情況下V8會使用標記清理算法,當空間碎片不足以安放新晉升對象時,才會觸發標記整理算法。

  • 1.3.4 增量標記(Incremental Marking)
    早期V8在垃圾回收階段,采用全停頓(stop the world),也就是垃圾回收時程序運行會被暫停。這在JavaScript還僅被用于瀏覽器端開發時,并沒有什么明顯的缺點,前端開發使用的內存少,大多數時候僅觸發新生代垃圾回收,速度快,卡頓幾乎感覺不到。但是對于Node程序,使用內存更多,在老年代垃圾回收時,全停頓很容易帶來明顯的程序遲滯,標記階段很容易就會超過100ms,因此V8引入了增量標記,將標記階段分為若干小步驟,每個步驟控制在5ms內,每運行一段時間標記動作,就讓JavaScript程序執行一會兒,如此交替,明顯地提高了程序流暢性,一定程度上避免了長時間卡頓。

二、Node開發中的內存管理與優化

2.1 手動變量銷毀

當任一作用域存活于作用域棧(作用域鏈)時,其中的變量都不會被銷毀,其引用的數據也會一直被變量關聯,得不到GC。有的作用域存活時間非常長(越是棧底,存活時間越長,最長的是全局作用域),但是其中的某些變量也許在某一時刻后就沒有用處了,因此建議手動設置為null,斷開引用鏈接,使得V8可以及時GC釋放內存。
注意,不使用var聲明的變量,都會成為全局對象的屬性。前端開發中全局對象為window,Node中全局對象為global,如果global中有屬性已經沒有用處了,一定要設置為null,因為全局作用域只有等到程序停止運行,才會銷毀。
Node中,當一個模塊被引入,這個模塊就會被緩存在內存中,提高下次被引用的速度。也就是說,一般情況下,整個Node程序中對同一個模塊的引用,都是同一個實例(instance),這個實例一直存活在內存中。所以,如果任意模塊中有變量已經不再需要,最好手動設置為null,不然會白白占用內存,成為“活著的死對象”。

2.2 慎用閉包

  • 2.2.1 V8的閉包實現
    先來看一段例子:
function outer(){
    var x = 1; // 真正的局部變量:outer執行完后立即死亡
    var y = 2; // 上下文變量:閉包死亡后才會死亡
    // 返回一個閉包
    return function(){
      console.log(y); // 使用了外層函數的變量 y
    }
}
var inner = outer(); // 通過inner變量持有閉包

有不少開發者認為,如果閉包被引用,那么閉包的外部函數也不會被釋放,其中的所有變量都不會被銷毀,比如我通過inner變量持有了閉包,此時outer中的 x、y 均活在內存中,不會被銷毀。事實真是這樣嗎?
答案是:在V8的實現中,當outer執行完畢,x 立即死亡,僅有 y 存活
V8是這么做的:
當程序進入一個函數時,將會為這個函數創建一個上下文(Context),初始狀態這個Context是空的,當讀到這個函數(outer)中的閉包聲明時,將會把此閉包(inner)中使用的外部變量,加入Context。在上面的例子中,由于inner函數使用了變量 y ,因此會將 y 加入Context。outer內部所有的閉包,都會持有這個Context


每一個閉包都會引用其外部函數的Context,以此訪問需要讀取的外部變量。被閉包捕捉,加入Context中的變量,我們稱為Context變量,分配在堆。而真正的 局部變量(local variable)是 x ,保存在棧,當outer執行完畢后,其信息出棧,變量 x 自然銷毀,而Context被閉包引用,如果有任何一個閉包存活,Context都將存活,y 將不會被銷毀。
舉一反三,再來看一個更復雜的例子:

function outer () { 
    var x; // 真正的局部變量
    var y; // context variable, 被inner1使用
    var z; // context variable, 被inner2使用
    function inner1 () { 
      use(y); 
    } 
    function inner2 () { 
      use(z); 
    } 
    function inner3 () { 
      /* 雖然函數體為空,但是作為閉包,依舊引用outer的Context */
    } 
    return [inner1, inner2, inner3];
}

x、y、z 三個變量何時死亡?
x 在outer執行完后立即死亡, y、z 需要等到inner1、inner2、inner3三個閉包都死亡后,才會死亡。
x 未被任何閉包使用,因此是一個真正的局部變量,保存在棧,函數執行完即被出棧死亡。由于 y、z 兩個變量分別被inner1、inner2使用,則它們會被加入outer的Context。所有閉包都會引用外部函數的Context,即使inner3為空,不使用任何外部函數的變量,也會引用Context,所以需要等到三個閉包都死亡后,y、z 才會死亡。


因此:如果較大的對象成為了Context變量,建議嚴格控制引用此Context的閉包生命周期以及閉包數量,或在不需要時,設置為null,以免引起較多內存的長期占用。

  • 2.2.2 避免深層閉包嵌套
function outer() { 
    var x = HUGE; // 超大對象
    function inner() { 
      var y = GIANT; // 大對象
      use(x); // x 需要使用,需要成為Context變量
      function innerF() { 
        use(y); // y 需要使用,需要成為Context變量
      } 
      function innerG() { 
        /* 空函數體 */
      } 
      return innerG; 
    } 
    return inner();
}
var o = outer(); // HUGE and GIANT 均得不到釋放

變量 o 持有的是innerG閉包,innerG持有著inner的Context,且內部閉包的Context會持有外部閉包的Context,產生Context鏈

上下文鏈

為了減輕GC壓力,建議避免過深嵌套函數/閉包,或及早手動斷開Context變量所引用的大對象。

2.3 大內存使用

  • 2.3.1 使用stream
    當我們需要操作大文件,應該利用Node提供的stream以及其管道方法,防止一次性讀入過多數據,占用堆空間,增大堆內存壓力。

  • 2.3.2 使用Buffer
    Buffer是操作二進制數據的對象,不論是字符串還是圖片,底層都是二進制數據,因此Buffer可以適用于任何類型的文件操作。
    Buffer對象本身屬于普通對象,保存在堆,由V8管理,但是其儲存的數據,則是保存在堆外內存,是有C++申請分配的,因此不受V8管理,也不需要被V8垃圾回收,一定程度上節省了V8資源,也不必在意堆內存限制。

參考資料:

**
本人技術有限,且技術更新很快,如果文中存在錯誤或者不足,歡迎大家指正,相互交流。
郵箱:hjaurum@gmail.com
**

最后編輯于
?著作權歸作者所有,轉載或內容合作請聯系作者
平臺聲明:文章內容(如有圖片或視頻亦包括在內)由作者上傳并發布,文章內容僅代表作者本人觀點,簡書系信息發布平臺,僅提供信息存儲服務。
  • 序言:七十年代末,一起剝皮案震驚了整個濱河市,隨后出現的幾起案子,更是在濱河造成了極大的恐慌,老刑警劉巖,帶你破解...
    沈念sama閱讀 230,048評論 6 542
  • 序言:濱河連續發生了三起死亡事件,死亡現場離奇詭異,居然都是意外死亡,警方通過查閱死者的電腦和手機,發現死者居然都...
    沈念sama閱讀 99,414評論 3 429
  • 文/潘曉璐 我一進店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人,你說我怎么就攤上這事。” “怎么了?”我有些...
    開封第一講書人閱讀 178,169評論 0 383
  • 文/不壞的土叔 我叫張陵,是天一觀的道長。 經常有香客問我,道長,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 63,722評論 1 317
  • 正文 為了忘掉前任,我火速辦了婚禮,結果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己,他們只是感情好,可當我...
    茶點故事閱讀 72,465評論 6 412
  • 文/花漫 我一把揭開白布。 她就那樣靜靜地躺著,像睡著了一般。 火紅的嫁衣襯著肌膚如雪。 梳的紋絲不亂的頭發上,一...
    開封第一講書人閱讀 55,823評論 1 328
  • 那天,我揣著相機與錄音,去河邊找鬼。 笑死,一個胖子當著我的面吹牛,可吹牛的內容都是我干的。 我是一名探鬼主播,決...
    沈念sama閱讀 43,813評論 3 446
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了?” 一聲冷哼從身側響起,我...
    開封第一講書人閱讀 43,000評論 0 290
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎,沒想到半個月后,有當地人在樹林里發現了一具尸體,經...
    沈念sama閱讀 49,554評論 1 335
  • 正文 獨居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內容為張勛視角 年9月15日...
    茶點故事閱讀 41,295評論 3 358
  • 正文 我和宋清朗相戀三年,在試婚紗的時候發現自己被綠了。 大學時的朋友給我發了我未婚夫和他白月光在一起吃飯的照片。...
    茶點故事閱讀 43,513評論 1 374
  • 序言:一個原本活蹦亂跳的男人離奇死亡,死狀恐怖,靈堂內的尸體忽然破棺而出,到底是詐尸還是另有隱情,我是刑警寧澤,帶...
    沈念sama閱讀 39,035評論 5 363
  • 正文 年R本政府宣布,位于F島的核電站,受9級特大地震影響,放射性物質發生泄漏。R本人自食惡果不足惜,卻給世界環境...
    茶點故事閱讀 44,722評論 3 348
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧,春花似錦、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 35,125評論 0 28
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至,卻和暖如春,著一層夾襖步出監牢的瞬間,已是汗流浹背。 一陣腳步聲響...
    開封第一講書人閱讀 36,430評論 1 295
  • 我被黑心中介騙來泰國打工, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人。 一個月前我還...
    沈念sama閱讀 52,237評論 3 398
  • 正文 我出身青樓,卻偏偏與公主長得像,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個殘疾皇子,可洞房花燭夜當晚...
    茶點故事閱讀 48,482評論 2 379

推薦閱讀更多精彩內容