I. 三種比較典型的內存泄漏
一. 閉包引用導致的泄漏
這段代碼已經在很多講解內存泄漏的地方引用了,非常經典,所以拿出來作為第一個例子,以下是泄漏代碼:
'use strict'; const express = require('express'); const app = express(); //以下是產生泄漏的代碼 let theThing = null; let replaceThing = function () { let leak = theThing; let unused = function () { if (leak) console.log("hi") }; // 不斷修改theThing的引用 theThing = { longStr: new Array(1000000), someMethod: function () { console.log('a'); } }; }; app.get('/leak', function closureLeak(req, res, next) { replaceThing(); res.send('Hello Node'); }); app.listen(8082);
js中的閉包非常有意思,通過打印heapsnapshot,在chrome的dev tools中展示,會發現閉包中真正存儲本作用域數據的是類型為 closure
的一個函數(其__proto__指向的function)的 context
屬性指向的對象。
這個例子中泄漏引起的原因就是v8對上述的 context
選擇性持有本作用域的數據的兩個特點:
- 父作用域的所有子作用域持有的閉包對象是同一個。
- 該閉包對象是子作用域閉包對象中的
context
屬性指向的對象,並且其中只會包含所有的子作用域中使用到的父作用域變量。
二. 原生Socket重連策略不恰當導致的泄漏
這種類型的泄漏本質上node中的events模塊里的偵聽器泄漏,因為比較隱蔽,所以放在第二個例子,以下是泄漏代碼:
const net = require('net'); let client = new net.Socket(); function connect() { client.connect(26665, '127.0.0.1', function callbackListener() { console.log('connected!'); }); } //第一次連接 connect(); client.on('error', function (error) { // console.error(error.message); }); client.on('close', function () { //console.error('closed!'); //泄漏代碼 client.destroy(); setTimeout(connect, 1); });
泄漏產生的原因其實也很簡單:event.js
核心模塊實現的事件發布/訂閱本質上是一個js對象結構(在v6版本中為了性能采用了new EventHandles(),並且把EventHandles的原型置為null來節省原型鏈查找的消耗),因此我們每一次調用 event.on
或者 event.once
相當於在這個對象結構中對應的 type
跟着的數組增加一個回調處理函數。
那么這個例子里面的泄漏屬於非常隱蔽的一種:net
模塊的重連每一次都會給 client
增加一個 connect事件
的偵聽器,如果一直重連不上,偵聽器會無限增加,從而導致泄漏。
三. 不恰當的全局緩存導致的泄漏
這個例子就比較簡單了,但是也屬於在失誤情況下容易不小心寫出來的,以下是泄漏代碼
'use strict'; const easyMonitor = require('easy-monitor'); const express = require('express'); const app = express(); const _cached = []; app.get('/arr', function arrayLeak(req, res, next) { //泄漏代碼 _cached.push(new Array(1000000)); res.send('Hello World'); }); app.listen(8082);
如果我們在項目中不恰當的使用了全局緩存:主要是指只有增加緩存的操作而沒有清除的操作,那么就會引起泄漏。
這種緩存引用不當的泄漏雖然簡單,但是我曾經親自排查過:Appium自動化測試工具中,某一個版本的日志緩存策略有bug,導致搭建的server跑一段時間就重啟。
II. 常規排查方式
一. heapdump/v8-profiler + chrome dev tools
目前node上面用於排查內存泄漏的輔助工具也有一些,主要是:
- heapdump
- v8-profiler
這兩個工具的原理都是一致的:調用v8引擎暴露的接口: v8::Isolate::GetCurrent()->GetHeapProfiler()->TakeHeapSnapshot(title, control)
然后將獲取的c++對象數據轉換為js對象。
這個對象中其實就是一個很大的json,通過chrome提供的dev tools,可以將這個json解析成可視化的樹或者統計概覽圖,通過多次打印內存結構,compare出只增不減的對象,來定位到泄漏點。
二. Easy-Monitor工具自動定位疑似泄漏點
我之前項目中遇到疑似的內存泄漏基本都是這樣排查的,但是排查的過程中也遇到了幾個比較困擾的問題:
- 只能在線下進行,而線上情況復雜,有些錯誤線下很難復現
- 總是需要多次插工具打印,然后對比,比較麻煩
所以后面花了點時間,詳細解析了下v8引擎輸出的heapsnapshot里面的json結構,做了一個輕量級的線上內存泄漏排查工具,也是之前的Easy-monitor性能監控工具的一個補完。
對如何測試自己項目線上js代碼性能,以及找出js函數可優化點感興趣的朋友可以參看這一篇:
本文下一節主要是以第I節中的三種非常典型的內存泄漏狀況,來使用新一版的Easy-Monitor進行簡單的定位排查。
III. 使用Easy-Monitor快速定位泄漏點
一. 安裝&嵌入項目
Easy-Monitor的使用非常簡單,安裝啟動總共三步
1.安裝模塊
npm install easy-monitor
2.引入模塊
const easyMonitor = require('easy-monitor'); easyMonitor('你的項目名稱');
3.訪問監控頁面
打開你的瀏覽器,輸入以下地址,即可看到進程相關信息:
http://127.0.0.1:12333
二. 內存泄漏排查使用方式
Easy-Monitor可以實時展示內存分析信息,所以在線上使用也是沒有問題的,下面就來使用此工具分析第I節中出現的問題。
1.閉包泄漏
在閉包泄漏的代碼中,按照上面的步驟引入easy-monitor,然后不停在瀏覽器中訪問:
http://127.0.0.1:8082/leak
那么幾次后通過top或者別的自帶內存監控工具能看到內存明顯上升:
這里我本地訪問多次后,已經飆升到211MB。
此時,我們可以在Easy-Monitor的首頁,點擊對應Pid后面的 MEM
鏈接,即可自動進行當前業務進程的堆內內存快照打印以及泄漏點分析:
大約等待10s左右,頁面即會呈現出解析的結果。最上面的 Heap Status
一欄呈現的內容是一個對當前堆內內存解析后的概覽,大概看看就行了,比較重要的泄漏點定位在下面的 Memory Leak
一欄。
我對疑似的內存泄漏點推測是從計算得到的 retainedSize
着手的:泄漏的感知首先是內存無故增加,且只增不減,那么當前堆內內存結構中從 (GC roots)
節點出發開始,占據的 retainedSize
最大的就可能是疑似泄漏點的起始。
遵循這個規則,Memory Leak
第一個子欄目得到的是疑似泄漏點的概覽:
這里按照 retainedSize
大小做了從大到小的排序,可以看到,這幾個點基本上占據了90%以上的堆內內存大小。
好了,下面的子欄目則是對這里面的5個疑似泄漏點構建 引力圖,來找出泄漏鏈條,原理和前面一樣:占據總堆內內存 retainedSize
最大的對象下面一定也有占據其 retainedSize
最大的節點:
根據引力圖可以很清晰看到 retainedSize
最大的疑似泄漏鏈條,顏色和大小的一部分含義:
- 藍色表示疑似的泄漏節點
- 紫色表示普通節點
- 最大的節點表示的是當前疑似泄漏鏈條的根節點
這里的展示用了Echarts2,所有的節點都可以點擊展開/折疊。當我們把鼠標移動到疑似泄漏鏈條的最后一個子節點時,引力圖下面會用文字顯示出當前的泄漏鏈條的詳細指向信息 Reference List
,這里簡單的解析下其內容:
[object] (Route::@122187) ' stack ---> [object] (Array::@124261) ' [0] ---> [object] (Layer::@124265) ' handle ---> [closure] (closureLeak::@124169) ' context ---> [object] (system / Context::@84427) ' theThing ---> [object] (Object::@122271) ' someMethod ---> [closure] (someMethod::@122275) ' context ---> [object] (system / Context::@122269) ' leak ---> [object] (Object::@122113) ' someMethod ---> [closure] (someMethod::@122117) ' context ---> [object] (system / Context::@122111)
每一行表示一個節點:[類型] (名稱::節點唯一id) ’ 屬性名稱或者index。 因為測試代碼用了Express框架,熟悉Express框架源碼的小伙伴都能看出來了:
- 根節點是初始化express時構造的
Route
的實例。 - 該
Route
實例的stack
屬性對應的數組的第一個元素,即這里的[0]
對應的元素,其實也就是一個中間件,所以是Layer
的一個實例。 - 該中間件的
handle
屬性指向closureLeak
函數,這里開始出現我們自己編寫的Express框架外的代碼了,簡單分析下也很容易明白這個中間件其實就是我們編寫的app.get
部分。 closureLeak
函數持有了上級作用域產生的閉包對象,這個閉包對象中retainedSize
最大的變量為theThing
theThing
持有了someMethod
的引用,someMethod
又通過上級作用域的閉包對象持有了leak
變量,leak
變量又指向theThing
變量指向的上一次的老對象,這個老對象中依舊包含了someMethod
…
通過這個引力圖和下面提供的 Reference List
分析,其實很容易發現泄漏點和泄漏原因:正是因為第I節中提到的v8引擎作用域生成和持有閉包引用的規則,那么 unused
函數的存在,導致了 leak
變量被 replaceThing
函數作用域生成的閉包對象存儲了,那么 theThing
每一次指向的新對象里面的 someMethod
函數持有了這個閉包對象,因此間接持有了上一次訪問 theThing
指向的老對象。所以每一次訪問后,老對象永遠因為被持有永遠無法得到釋放,從而引起了泄漏。
這里也把關鍵詞整理出來,方便大家項目全局搜索排查:Leak Key
2.Socket重連泄漏
同樣的方式,第I節中的代碼保存后執行,注意 connect
操作的端口填寫一個本地不存在的端口,來模擬觸發客戶端的斷線重連。
那么這段代碼跑大概一分鍾左右,即開始產生比較明顯的泄漏現象。同樣打開easy-monitor監控頁面進行堆內存分析,得到如下結果:
這個圖很容易看出來,占據 retainedSize
最大的對象正是 socket
對象,幾乎占到了堆內總內存的 50% 以上。
接着往下看引力圖,如下所示:
其中的 Reference List
如下:
[object] (Socket::@97097) ' _events ---> [object] (EventHandlers::@97101) ' connect ---> [object] (Array::@102511)
這里熟悉Node核心模塊 events
的小伙伴就能感到熟悉,_events
正是存儲訂閱事件/事件回調函數的屬性,那么這邊很顯然是原生的socket觸發斷線重連時,會不停增加 connect
事件的處理,如果服務器一直掛掉,即客戶端無法斷線重連成功,那么內存就會不斷增加導致泄漏。
題外插一句,我翻了下net.js的代碼,這里的 connect
事件是以 once
的方式添加的,所以只要重連過程中能夠連上一次,這部分偵聽器增加的內存就能夠被回收掉。
3.全局緩存泄漏
這個是最簡單的原因了,大家可以使用Easy-Monitor自行嘗試一番~
IV. 如何修改避免泄漏
一. 斷掉閉包中的泄漏變量引用鏈條
根據第III節中的解析,明白了這種泄漏的原理,就比較容易對代碼進行修改了,斷掉 unused
函數對 leak
變量的引用,那么 replaceThing
函數作用域的閉包對象中就不會有 leak
變量了,這樣 someMethod
即不會再對老對象間接產生引用導致泄漏,修改后代碼如下:
'use strict'; const express = require('express'); const app = express(); const easyMonitor = require('easy-monitor'); easyMonitor('Closure Leak'); let theThing = null; let replaceThing = function () { let leak = theThing; //斷掉leak的閉包引用即可解決這種泄漏 let unused = function (leak) { if (leak) console.log("hi") }; theThing = { longStr: new Array(1000000), someMethod: function () { console.log('a'); } }; }; app.get('/leak', function closureLeak(req, res, next) { replaceThing(); res.send('Hello Node'); }); app.listen(8082);
二. 斷線重連時去掉老偵聽器
修改主要目的是在重連時去掉連接失敗時添加的 connect
事件,修改后代碼如下:
const net = require('net'); const easyMonitor = require('easy-monitor'); easyMonitor('Socket Leak'); let client = new net.Socket(); function callbackListener() { console.log('connected!'); }); function connect() { client.connect(26665, '127.0.0.1', callbackListener} connect(); client.on('error', function (error) { // console.error(error.message); }); client.on('close', function () { //console.error('closed!'); //斷線時去掉本次偵聽的connect事件的偵聽器 client.removeListener('connect', callbackListener); client.destroy(); setTimeout(connect, 1); });
三.
修改和測試大家可以自行嘗試一番。
V. 結語
做這個工具也讓自己對於v8的內存管理有了更深入的認識,收獲挺大的,下一步的計划是優化代碼邏輯和前台呈現界面,提高易用性和開發者的體驗。
Easy-Monitor新版本下依舊支持線上部署和多項目cluster部署,最后項目的git地址在:
如果大家覺得有幫助或者不錯,歡迎給個star 💕~