作者:elvinpeng,騰訊 WXG 前端開發工程師
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 的常駐記憶體(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 按鈕将剛剛的兩個快照依次導入:
mark
導入後,在左側可以看到堆記憶體有明顯的上漲,從 1.7 MB 上漲到了 3.1 MB,幾乎翻了一倍:
接下來就是最關鍵的步驟了,點選 leak 快照,然後将其與 init 快照進行對比:
右側紅框圈出來了兩列:
- Delta:表示變化的數量
- Size Delta:表述變化的空間大小
可以看到增長最大的前兩項是 拼接的字元串(concatenated string ) 和 閉包(closure),那麼我們點開來看看具體有哪些:
從這兩個圖中,可以很直覺的看出來主要是 theThing.someMethod 這個函數的閉包上下文和 theThing.longStr 這個很長的拼接字元串造成的記憶體洩漏,到這裡問題就基本定位清楚了,我們還可以點選下方的 Object 子產品來更清楚的看一下調用鍊的關系:
圖中很明顯的看出來,記憶體洩漏原因就是因為 newTHing
參考文章
- Visualizing memory management in V8 Engine
- Github - 記憶體洩漏的例子
- ali node - 正确打開 Chrome devtools