有意思的 Node.js 記憶體洩漏問題

有意思的 Node.js 記憶體洩漏問題

Node。js 使用的是 V8 引擎,會自動進行垃圾回收(Garbage Collection,GC),因而寫程式碼的時候不需要像 C/C++ 一樣手動分配、釋放記憶體空間,方便不少,不過仍然需要注意記憶體的使用,避免造成記憶體洩漏(Memory Leak)。

記憶體洩漏往往非常隱蔽,例如下面這段程式碼你能看出來是哪兒裡有問題嗎?

let theThing = null;let replaceThing = function() {  const newThing = theThing;  const unused = function() {    if (newThing) console。log(“hi”);  };  // 不斷修改引用  theThing = {    longStr: new Array(1e8)。join(“*”),    someMethod: function() {      console。log(“a”);    },  };   // 每次輸出的值會越來越大  console。log(process。memoryUsage()。heapUsed);}; setInterval(replaceThing, 100);

如果可以的話,歡迎加入我們微信支付境外團隊,一起不斷追求卓越。如果暫時看不出來的話,一起來讀讀這篇文章吧。

文章的前半部分會先介紹一些理論知識,然後再舉一個定位記憶體洩漏的例子,感興趣的朋友可以直接先看看 這個例子。

整體結構

有意思的 Node.js 記憶體洩漏問題

從上圖中,可以看到 Node。js 的常駐記憶體(Resident Set)分為堆和棧兩個部分,具體為:

指標空間(Old pointer space):儲存的物件含有指向其它物件的指標。

資料空間(Old data space):儲存的物件僅含有資料(不含指向其它物件的指標),例如從新生代移動過來的字串等。

新生代(New Space/Young Generation):用來臨時儲存新物件,空間被等分為兩份,整體較小,採用 Scavenge(Minor GC) 演算法進行垃圾回收。

老生代(Old Space/Old Generation):用來儲存存活時間超過兩個 Minor GC 時間的物件,採用 標記清除 & 整理(Mark-Sweep & Mark-Compact,Major GC) 演算法進行垃圾回收,內部可再劃分為兩個空間:

程式碼空間(Code Space):用於存放程式碼段,是唯一的可執行記憶體(不過過大的程式碼段也有可能存放在大物件空間)。

大物件空間(Large Object Space):用於存放超過其它空間物件限制(Page::kMaxRegularHeapObjectSize)的大物件(可以參考這個 V8 Commit),存放在此的物件不會在垃圾回收的時候被移動。

。。。

棧:用於存放原始的資料型別,函式呼叫時的入棧出棧也記錄於此。

棧的空間由作業系統負責管理,開發者無需過於關心;堆的空間由 V8 引擎進行管理,可能由於程式碼問題出現記憶體洩漏,或者長時間執行後,垃圾回收導致程式執行速度變慢。

我們可以透過下面程式碼簡單的觀察 Node。js 記憶體使用情況:

const format = function (bytes) {  return `${(bytes / 1024 / 1024)。toFixed(2)} MB`;}; const memoryUsage = process。memoryUsage(); console。log(JSON。stringify({    rss: format(memoryUsage。rss), // 常駐記憶體    heapTotal: format(memoryUsage。heapTotal), // 總的堆空間    heapUsed: format(memoryUsage。heapUsed), // 已使用的堆空間    external: format(memoryUsage。external), // C++ 物件相關的空間}, null, 2));

external 是 C++ 物件相關的空間,例如透過 new ArrayBuffer(100000); 申請一塊 Buffer 記憶體的時候,可以明顯看到 external 空間的增加。

可以透過下列引數調整相關空間的預設大小,單位為 MB:

——stack_size 調整棧空間

——min_semi_space_size 調整新生代半空間的初始值

——max_semi_space_size 調整新生代半空間的最大值

——max-new-space-size 調整新生代空間的最大值

——initial_old_space_size 調整老生代空間的初始值

——max-old-space-size 調整老生代空間的最大值

其中比較常用的是 ——max_new_space_size 和 ——max-old-space-size。

新生代的 Scavenge 回收演算法、老生代的 Mark-Sweep & Mark-Compact 演算法相關的文章已經很多,這裡就不贅述了,例如這篇文章講的不錯 Node。js 記憶體管理和 V8 垃圾回收機制。

記憶體洩漏

由於不當的程式碼,有時候難免會發生記憶體洩漏,常見的有四個場景:

全域性變數

閉包引用

事件繫結

快取爆炸

接下來分別舉個例子講一講。

全域性變數

沒有使用 var/let/const 宣告的變數會直接繫結在 Global 物件上(Node。js 中)或者 Windows 物件上(瀏覽器中),哪怕不再使用,仍不會被自動回收:

function test() {  x = new Array(100000);} test();console。log(x);

這段程式碼的輸出為 [ <100000 empty items> ],可以看到 test 函式執行完後,陣列 x 仍未被釋放。

閉包引用

閉包引發的記憶體洩漏往往非常隱蔽,例如下面這段程式碼你能看出來是哪兒裡有問題嗎?

let theThing = null;let replaceThing = function() {  const newThing = theThing;  const unused = function() {    if (newThing) console。log(“hi”);  };  // 不斷修改引用  theThing = {    longStr: new Array(1e8)。join(“*”),    someMethod: function() {      console。log(“a”);    },  };   // 每次輸出的值會越來越大  console。log(process。memoryUsage()。heapUsed);}; setInterval(replaceThing, 100);

執行這段程式碼可以看到輸出的已使用堆記憶體越來越大,而其中的關鍵就是因為 在目前的 V8 實現當中,閉包物件是當前作用域中的所有內部函式作用域共享的,也就是說 theThing。someMethod 和 unUsed 共享同一個閉包的 context,導致 theThing。someMethod 隱式的持有了對之前的 newThing 的引用,所以會形成 theThing -> someMethod -> newThing -> 上一次 theThing ->。。。 的迴圈引用,從而導致每一次執行 replaceThing 這個函式的時候,都會執行一次 longStr: new Array(1e8)。join(“*”),而且其不會被自動回收,導致佔用的記憶體越來越大,最終記憶體洩漏。

對於上面這個問題有一個很巧妙的解決方法:透過引入新的塊級作用域,將 newThing 的宣告、使用與外部隔離開,從而打破共享,阻止迴圈引用。

let theThing = null;let replaceThing = function() {  {    const newThing = theThing;    const unused = function() {      if (newThing) console。log(“hi”);    };  }  // 不斷修改引用  theThing = {    longStr: new Array(1e8)。join(“*”),    someMethod: function() {      console。log(“a”);    },  };   console。log(process。memoryUsage()。heapUsed);}; setInterval(replaceThing, 100);

這裡透過 { 。。。 } 形成了單獨的塊級作用域,而且在外部沒有引用,從而 newThing 在 GC 的時候會被自動回收,例如在我的電腦執行這段程式碼輸出如下:

209712824501042454240。。。266108026652002086736 // 此時進行垃圾回收釋放了記憶體2093240

事件繫結

事件繫結導致的記憶體洩漏在瀏覽器中非常常見,一般是由於事件響應函式未及時移除,導致重複繫結或者 DOM 元素已移除後未處理事件響應函式造成的,例如下面這段 React 程式碼:

class Test extends React。Component { componentDidMount() { window。addEventListener(‘resize’, function() { // 相關操作 }); } render() { return

test component
; }}

元件在掛載的時候監聽了 resize 事件,但是在元件移除的時候沒有處理相應函式,假如 的掛載和移除非常頻繁,那麼就會在 window 上繫結很多無用的事件監聽函式,最終導致記憶體洩漏。可以透過如下的方式避免這個問題:

class Test extends React。Component { componentDidMount() { window。addEventListener(‘resize’, this。handleResize); } handleResize() { 。。。 } componentWillUnmount() { window。removeEventListener(‘resize’, this。handleResize); } render() { return

test component
; }}

快取爆炸

透過 Object/Map 的記憶體快取可以極大地提升程式效能,但是很有可能未控制好快取的大小和過期時間,導致失效的資料仍快取在記憶體中,導致記憶體洩漏:

const cache = {}; function setCache() {  cache[Date。now()] = new Array(1000);} setInterval(setCache, 100);

上面這段程式碼中,會不斷的設定快取,但是沒有釋放快取的程式碼,導致記憶體最終被撐爆。

如果的確需要進行記憶體快取的話,強烈建議使用 lru-cache 這個 npm 包,可以設定快取有效期和最大的快取空間,透過 LRU 淘汰演算法來避免快取爆炸。

記憶體洩漏定位實操

當出現記憶體洩漏的時候,定位起來往往十分麻煩,主要有兩個原因:

程式開始執行的時候,問題不會立即暴露,需要持續的執行一段時間,甚至一兩天,才會復現問題。

出錯的提示資訊非常模糊,往往只能看到 heap out of memory 錯誤資訊。

在這種情況下,可以藉助兩個工具來定問題:Chrome DevTools 和 heapdump。heapdump 的作用就如同它的名字所說 - 將記憶體中堆的狀態資訊生成快照(snapshot)匯出,然後我們將其匯入到 Chrome DevTools 中看到具體的詳情,例如堆中有哪些物件、佔據多少空間等等。

接下來透過上文中閉包引用裡記憶體洩漏的例子,來實際操作一把。首先 npm install heapdump 安裝後,修改程式碼為下面的樣子:

// 一段存在記憶體洩漏問題的示例程式碼const heapdump = require(‘heapdump’); heapdump。writeSnapshot(‘init。heapsnapshot’); // 記錄初始記憶體的堆快照 let i = 0; // 記錄呼叫次數let theThing = null;let replaceThing = function() { const newThing = theThing; let unused = function() { if (newThing) console。log(“hi”); }; // 不斷修改引用 theThing = { longStr: new Array(1e8)。join(“*”), someMethod: function() { console。log(“a”); }, }; if (++i >= 1000) { heapdump。writeSnapshot(‘leak。heapsnapshot’); // 記錄執行一段時間後記憶體的堆快照 process。exit(0); }}; setInterval(replaceThing, 100);

在第 3 行和第 22 行,分別匯出了初始狀態的快照和迴圈了 1000 次後的快照,儲存為 init。heapsnapshot 與 leak。heapsnapshot。

然後開啟 Chrome 瀏覽器,按下 F12 調出 DevTools 面板,點選 Memory 的 Tab,最後透過 Load 按鈕將剛剛的兩個快照依次匯入:

有意思的 Node.js 記憶體洩漏問題

mark

匯入後,在左側可以看到堆記憶體有明顯的上漲,從 1。7 MB 上漲到了 3。1 MB,幾乎翻了一倍:

有意思的 Node.js 記憶體洩漏問題

接下來就是最關鍵的步驟了,點選 leak 快照,然後將其與 init 快照進行對比:

有意思的 Node.js 記憶體洩漏問題

右側紅框圈出來了兩列:

Delta:表示變化的數量

Size Delta:表述變化的空間大小

可以看到增長最大的前兩項是 拼接的字串(concatenated string ) 和 閉包(closure),那麼我們點開來看看具體有哪些:

有意思的 Node.js 記憶體洩漏問題

有意思的 Node.js 記憶體洩漏問題

從這兩個圖中,可以很直觀的看出來主要是 theThing。someMethod 這個函式的閉包上下文和 theThing。longStr 這個很長的拼接字串造成的記憶體洩漏,到這裡問題就基本定位清楚了,我們還可以點選下方的 Object 模組來更清楚的看一下呼叫鏈的關係:

有意思的 Node.js 記憶體洩漏問題

圖中很明顯的看出來,記憶體洩漏原因就是因為 newTHing <- 閉包上下文 <- someMethod <- 上一次 newThing 這樣的鏈式依賴關係導致記憶體的快速增長。圖中第二列的 distance 表示的是該變數距離根節點的距離,因而最上級的 newThing 是最遠的,表示的是下級引用上級的關係。

看到這,不妨點贊關注吧!

接下來我還會不定期在頭條分享裡面概括應用網站開發,css,html,JavaScript,jQuery,Ajax,node,angular等。

對web前端開發技術感興趣的同學私信我‘學習‘免費領取前端資料+工具+原始碼及開發專案。