【Nodejs】516- 分析 Node.js 記憶體泄漏
- 2020 年 3 月 2 日
- 筆記
作者:淘寶@冰森
前言
@冰森,日常 qcon、ssdc、Node party、Node 地下鐵講師/分享者。Node 基金會摸魚成員。
正文從這開始~~
記憶體泄漏(Memory Leak)指由於疏忽或錯誤造成程式未能釋放已經不再使用的記憶體的情況。如果記憶體泄漏的位置比較關鍵,那麼隨著處理的進行可能持有越來越多的無用記憶體,這些無用的記憶體變多會引起伺服器響應速度變慢,嚴重的情況下導致記憶體達到某個極限(可能是進程的上限,如 v8 的上限;也可能是系統可提供的記憶體上限)會使得應用程式崩潰。
傳統的 C/C++ 中存在野指針,對象用完之後未釋放等情況導致的記憶體泄漏。而在使用虛擬機執行的語言中如 Java、JavaScript 由於使用了 GC (Garbage Collection,垃圾回收)機制自動釋放記憶體,使得程式設計師的精力得到的極大的解放,不用再像傳統語言那樣時刻對於記憶體的釋放而戰戰兢兢。
但是,即便有了 GC 機制可以自動釋放,但這並不意味這記憶體泄漏的問題不存在了。記憶體泄漏依舊是開發者們不能繞過的一個問題,今天讓我們來了解如何分析 Node.js 中的記憶體泄漏。
GC in Node.js
Node.js 使用 V8 作為 JavaScript 的執行引擎,所以討論 Node.js 的 GC 情況就等於在討論 V8 的 GC。在 V8 中一個對象的記憶體是否被釋放,是看程式中是否還有地方持有改對象的引用。
在 V8 中,每次 GC 時,是根據 root 對象 (瀏覽器環境下的 window,Node.js 環境下的 global ) 依次梳理對象的引用,如果能從 root 的引用鏈到達訪問,V8 就會將其標記為可到達對象,反之為不可到達對象。被標記為不可到達對象(即無引用的對象)後就會被 V8 回收。更多細節,可以參見 alinode 的 解讀 V8 GC。
了解上述的點之後,你就會知道,在 Node.js 中記憶體泄露的原因就是本該被清除的對象,被可到達對象引用以後,未被正確的清除而常駐記憶體。
記憶體泄漏的幾種情況
一、全局變數
a =10;//未聲明對象。global.b =11;//全局變數引用
這種比較簡單的原因,全局變數直接掛在 root 對象上,不會被清除掉。
二、閉包
functionout(){ const bigData =newBuffer(100); inner =function(){ void bigData; }}
閉包會引用到父級函數中的變數,如果閉包未釋放,就會導致記憶體泄漏。上面例子是 inner 直接掛在了 root 上,從而導致記憶體泄漏(bigData 不會釋放)。
需要注意的是,這裡舉得例子只是簡單的將引用掛在全局對象上,實際的業務情況可能是掛在某個可以從 root 追溯到的對象上導致的。
三、事件監聽
Node.js 的事件監聽也可能出現的記憶體泄漏。例如對同一個事件重複監聽,忘記移除(removeListener),將造成記憶體泄漏。這種情況很容易在復用對象上添加事件時出現,所以事件重複監聽可能收到如下警告:
(node:2752)Warning:PossibleEventEmitter memory leak detected。11 haha listeners added。Use emitter。setMaxListeners() to increase limit
例如,Node.js 中 Agent 的 keepAlive 為 true 時,可能造成的記憶體泄漏。當 Agent keepAlive 為 true 的時候,將會復用之前使用過的 socket,如果在 socket 上添加事件監聽,忘記清除的話,因為 socket 的復用,將導致事件重複監聽從而產生記憶體泄漏。
原理上與前一個添加事件監聽的時候忘了清除是一樣的。在使用 Node.js 的 http 模組時,不通過 keepAlive 復用是沒有問題的,復用了以後就會可能產生記憶體泄漏。所以,你需要了解添加事件監聽的對象的生命周期,並注意自行移除。
關於這個問題的實例,可以看 Github 上的 issues(node Agent keepAlive 記憶體泄漏)
四、其他原因
還有一些其他的情況可能會導致記憶體泄漏,比如快取。在使用快取的時候,得清楚快取的對象的多少,如果快取對象非常多,得做限制最大快取數量處理。還有就是非常佔用 CPU 的程式碼也會導致記憶體泄漏,伺服器在運行的時候,如果有高 CPU 的同步程式碼,因為Node.js 是單執行緒的,所以不能處理處理請求,請求堆積導致記憶體佔用過高。
定位記憶體泄漏
一、重現記憶體泄漏情況
想要定位記憶體泄漏,通常會有兩種情況:
對於只要正常使用就可以重現的記憶體泄漏,這是很簡單的情況只要在測試環境模擬就可以排查了。
對於偶然的記憶體泄漏,一般會與特殊的輸入有關係。想穩定重現這種輸入是很耗時的過程。如果不能通過程式碼的日誌定位到這個特殊的輸入,那麼推薦去生產環境列印記憶體快照了。需要注意的是,列印記憶體快照是很耗 CPU 的操作,可能會對線上業務造成影響。
快照工具推薦使用 heapdump 用來保存記憶體快照,使用 devtool 來查看記憶體快照。使用 heapdump 保存記憶體快照時,只會有 Node.js 環境中的對象,不會受到干擾(如果使用 node-inspector 的話,快照中會有前端的變數干擾)。
PS:安裝 heapdump 在某些 Node.js 版本上可能出錯,建議使用 npm install heapdump -target=Node.js 版本來安裝。
二、列印記憶體快照
將 heapdump 引入程式碼中,使用 heapdump.writeSnapshot 就可以列印記憶體快照了了。為了減少正常變數的干擾,可以在列印記憶體快照之前會調用主動釋放記憶體的 gc() 函數(啟動時加上 —expose-gc 參數即可開啟)。
const heapdump =require('heapdump');const save =function(){ gc(); heapdump.writeSnapshot('./'+Date.now()+'.heapsnapshot');}
在列印線上的程式碼的時候,建議按照記憶體增長情況來列印快照。heapdump 可以使用 kill 向程式發送訊號來列印記憶體快照(只在 *nix 系統上提供)。
kill -USR2 <pid>
推薦列印 3 個記憶體快照,一個是記憶體泄漏之前的記憶體快照,一個是少量測試以後的記憶體快照,還有一個是多次測試以後的記憶體快照。
第一個記憶體快照作為對比,來查看在測試後有哪些對象增長。在記憶體泄漏不明顯的情況下,可以與大量測試以後的記憶體快照對比,這樣能更容易定位。
三、對比記憶體快照找出泄漏位置
通過記憶體快照找到數量不斷增加的對象,找到增加對象是被誰給引用,找到問題程式碼,改正之後就行,具體問題具體分析,這裡通過我們在工作中遇到的情況來講解。
const{EventEmitter}=require('events');const heapdump =require('heapdump');global.test =newEventEmitter(); heapdump.writeSnapshot('./'+Date.now()+'.heapsnapshot');function run3(){ const innerData =newBuffer(100); const outClosure3 =function(){ void innerData; }; test.on('error',()=>{ console.log('error'); }); outClosure3();}for(let i =0; i <10; i++){ run3();} gc(); heapdump.writeSnapshot('./'+Date.now()+'.heapsnapshot');
這裡是對錯誤程式碼的最小重現程式碼。
首先使用 node —expose-gc index.js 運行程式碼,將會得到兩個記憶體快照,之後打開 devtool,點擊 profile,載入記憶體快照。打開對比,Delta 會顯示對象的變化情況,如果對象 Delta 一直增長,就很有可能是記憶體泄漏了。

可以看到有三處對象明顯增長的地方,閉包、上下文以及 Buffer 對象增長。點擊查看一下對象的引用情況:

其實這三處對象增長都是一個問題導致的。test 對象中的 error 監聽事件中閉包引用了 innerData 對象,導致 buffer 沒有被清除,從而導致記憶體泄漏。
其實這裡的 error 監聽事件中沒有引用 innerData 為什麼會閉包引用了 innerData 對象,這個問題很是疑惑,後來弄清是 V8 的優化問題,在文末會額外講解一下。對於對比快照找到問題,得看你對程式碼的熟悉程度,還有眼力了。
如何避免記憶體泄漏
文中的例子基本都可以很清楚的看出記憶體泄漏,但是在工作中,程式碼混合上業務以後就不一定能很清楚的看出記憶體泄漏了,還是得依靠工具來定位記憶體泄漏。另外下面是一些避免記憶體泄漏的方法。
ESLint 檢測程式碼檢查非期望的全局變數。
使用閉包的時候,得知道閉包了什麼對象,還有引用閉包的對象何時清除閉包。最好可以避免寫出複雜的閉包,因為複雜的閉包引起的記憶體泄漏,如果沒有列印記憶體快照的話,是很難看出來的。
綁定事件的時候,一定得在恰當的時候清除事件。在編寫一個類的時候,推薦使用 init 函數對類的事件監聽進行綁定和資源申請,然後 destroy 函數對事件和佔用資源進行釋放。
額外說明
在做了很多測試以後得到下面關於閉包的總結。
classTest{};global.test =newTest()function run5(bigData){ const innerData =newBuffer(100); // 被閉包引用,創建一個 context: context1。 // context1 引用 bigData,innerData。 // closure 為 function run5() // run5函數沒有 context,所以 context1 沒有previous。 // 在 run5中新建的函數將綁定上 context1。 test.outClosure5 =function(){ // 此函數閉包 context 指向 context1。 void bigData; const closureData =newBuffer(100); // 被閉包使用,創建 context: context2。 // outClosure5 函數有 context1,previous 指向 context1。 // 在 outClosure5 中新建的函數將綁定上context2。 test.innerClosure5 =function(){ // 此函數閉包 context 指向 context2。 void innerData; } test.innerClosure5_1 =function(){ // 此函數閉包 context 指向 context2。 void closureData; } }; test.outClosure5_1 =function(){ } test.outClosure5();} run5(newBuffer(1000));
V8 會生成一個 context 內部對象來實現閉包。下面是 V8 生成 context 的規則。
V8 會在被閉包引用變數聲明處創建一個 context2,如果被閉包的變數所在函數擁有 context1 ,則創建的 context2 的 previous指向函數 context1。在被閉包引用變數的函數內新建的函數將會綁定上 context2。
由於這個和 V8版本相關,這裡只測試了 v6.2.2 和 v6.10.1 還有 v7.7.1,都是相同的情況。如果想實踐測試可以在這個 repo 上了解更多。
關於本文 作者:@ellansin 原文:https://zhuanlan.zhihu.com/p/25736931