Node.js 中記憶體洩漏分析
記憶體洩漏(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 物件上,不會被清除掉。
二、閉包
function out() { const bigData = new Buffer(100); inner = function () { void bigData; } }
閉包會引用到父級函式中的變數,如果閉包未釋放,就會導致記憶體洩漏。上面例子是 inner 直接掛在了 root 上,那麼每次執行 out 函式所產生的 bigData 都不會釋放,從而導致記憶體洩漏。
需要注意的是,這裡舉得例子只是簡單的將引用掛在全域性物件上,實際的業務情況可能是掛在某個可以從 root 追溯到的物件上導致的。
三、事件監聽
Node.js 的事件監聽也可能出現的記憶體洩漏。例如對同一個事件重複監聽,忘記移除(removeListener),將造成記憶體洩漏。這種情況很容易在複用物件上新增事件時出現,所以事件重複監聽可能收到如下警告:
(node:2752) Warning: Possible EventEmitter 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 = new EventEmitter(); heapdump.writeSnapshot('./' + Date.now() + '.heapsnapshot'); function run3() { const innerData = new Buffer(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 函式對事件和佔用資源進行釋放。
額外說明
在做了很多測試以後得到下面關於閉包的總結。
class Test{}; global.test = new Test() function run5(bigData) { const innerData = new Buffer(100); // 被閉包引用,建立一個 context: context1。 // context1 引用 bigData,innerData。 // closure 為 function run5() // run5函式沒有 context,所以 context1 沒有previous。 // 在 run5中新建的函式將繫結上 context1。 test.outClosure5 = function () { // 此函式閉包 context 指向 context1。 void bigData; const closureData = new Buffer(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(new Buffer(1000));
V8 會生成一個 context 內部物件來實現閉包。下面是 V8 生成 context 的規則。
V8 會在被閉包引用變數宣告處建立一個 context2,如果被閉包的變數所在函式擁有 context1 ,則建立的 context2 的 previous指向函式 context1。在被閉包引用變數的函式內新建的函式將會繫結上 context2。
由於這個和 V8版本相關,這裡只測試了 v6.2.2 和 v6.10.1 還有 v7.7.1,都是相同的情況。如果想實踐測試可以在這個 repo 上了解更多。
相關文章
- 分析記憶體洩漏和goroutine洩漏記憶體Go
- valgrind 記憶體洩漏分析記憶體
- PHP 記憶體洩漏分析定位PHP記憶體
- linux程式之記憶體洩漏分析Linux記憶體
- 解決記憶體洩漏(1)-ApacheKylin InternalThreadLocalMap洩漏問題分析記憶體Apachethread
- jvm 記憶體洩漏JVM記憶體
- Android 記憶體洩漏Android記憶體
- Java記憶體洩漏Java記憶體
- js記憶體洩漏JS記憶體
- Android記憶體洩漏Android記憶體
- Handler記憶體洩漏分析及解決記憶體
- 小心遞迴中記憶體洩漏遞迴記憶體
- vue使用中的記憶體洩漏Vue記憶體
- Android中的記憶體洩漏模式Android記憶體模式
- [譯] Swift 中的記憶體洩漏Swift記憶體
- Node.js記憶體洩漏實用指南 – Arbaz SiddiquiNode.js記憶體UI
- 記憶體洩漏問題分析之非託管資源洩漏記憶體
- 記一次堆外記憶體洩漏分析記憶體
- 慧銷平臺ThreadPoolExecutor記憶體洩漏分析thread記憶體
- 記憶體洩漏的原因記憶體
- 如何避免JavaScript中的記憶體洩漏?JavaScript記憶體
- 【記憶體洩漏和記憶體溢位】JavaScript之深入淺出理解記憶體洩漏和記憶體溢位記憶體溢位JavaScript
- JVM——記憶體洩漏與記憶體溢位JVM記憶體溢位
- Java應用程式中的記憶體洩漏及記憶體管理Java記憶體
- 記憶體洩漏除錯工具記憶體除錯
- ThreadLocal真會記憶體洩漏?thread記憶體
- WebView引起的記憶體洩漏WebView記憶體
- Perfdog 玩轉記憶體洩漏記憶體
- .Net程式記憶體洩漏解析記憶體
- iOS檢測記憶體洩漏iOS記憶體
- Android記憶體洩漏場景Android記憶體
- ThreadLocal記憶體洩漏問題thread記憶體
- JavaScript之記憶體洩漏【四】JavaScript記憶體
- [譯]理解閉包中的記憶體洩漏記憶體
- 翻譯 | 理解Java中的記憶體洩漏Java記憶體
- JavaScript中的垃圾回收和記憶體洩漏JavaScript記憶體
- 如何檢查Javascript中的記憶體洩漏JavaScript記憶體
- 記憶體的分配與釋放,記憶體洩漏記憶體
- 1.記憶體優化(一)記憶體洩漏記憶體優化