如何定位 Node.js 的內存泄漏
在 《一次 Node.js 應用內存暴漲分析》 中,我們處理了一個 Node.js vm 引發的內存泄漏問題,處理過程也是比較艱辛。而在我們實際開發中,可能經常會碰到內存泄漏的問題,但很多情況下,我們對于這種問題的處理是有些迷茫的,沒有一定的操作流程,效率比較低。雖然這種問題對于經驗的要求比較高,但如果有一個簡單的排查流程,還是會有一定幫助的。
這里簡單整理一個流程,歡迎一起探討,補充。
基礎知識
Node.js 進程的內存管理,都是有 V8 自動處理的,包括內存分配和釋放。那么 V8 什么時候會將內存釋放呢?
在 V8 內部,會為程序中的所有變量構建一個圖,來表示變量間的關聯關系,當變量從根節點無法觸達時,就意味著這個變量不會再被使用了,就是可以回收的了。
而這個回收是一個過程性的,從快速 GC 到 最后的 Full GC,是需要一段時間的。
另外,Full GC 是有觸發閾值的,所以可能會出現內存長期占用在一個高值,也可以算是一種內存泄漏,可以從《一次 Node.js 應用內存暴漲分析》中找到例子。還有一種就是引用不釋放,導致無法進入 GC 環節,并且一直產生新的占用,這一般會發生在 Javascript 層面。
所以,定位內存泄漏問題,一般方案就是找那些不被使用又不會被釋放的變量,處理了這些變量,問題一般就可以解決了。如果是 Node.js 底層變量不釋放,除了提交 issue 等待解決外,只能通過優化啟動參數來解決。
如何找出并解決問題
工具
工欲善其事必先利其器,在排查時,我們還是需要一些工具來幫忙的。
devTool
這個是今年初出的 Node.js 調試工具,基于 Electron 將 Node.js 和 Chromium 的功能融合在了一起。操作起來比 node-inspector 方便,開放的 Timeline 功能還是比較實用的,雖然不是實時顯示。
僅需要 devtool xxx.js ,還可以通過 .devtoolrc 來進行參數定制,具體見 GitHub
heapdump + chrome devTool
這個是比較傳統的定位內存泄漏的組合。heapdump 可以直接在代碼中調用生成內存快照,然后將快照文件導入到 chrome devTool 進行分析,之后操作其實和前者就差不多了。不過,這個方案和前者有一點區別就是,前者實際還是在瀏覽器環境中,所以生成的內存快照會有一些 DOM 對象的存在,會有一定的干擾。而這個方案,是直接調用底層 V8 的方法,生成的快照只有 Node.js 環境中的對象。
memwatch
這個可以在代碼里直接使用,實時檢測內存動態,當發生內存泄漏的時候,會觸發 ‘leak’ 事件,會傳遞當前的堆狀態,配合 heapdump 有奇效。詳見 memwatch 。
流程
一、重現問題
對于垃圾回收,V8 引擎有很復雜的邏輯來決定什么時候進行回收。很多時候,當我們發現 Node.js 進程所使用的內存快速增長的時候,并不能確定是否是內存泄漏導致的,很有可能是程序設計問題,導致內存的不合理利用。只有當垃圾回收觸發,未使用內存被釋放后,內存增長還在持續,我們才能確定是發生了內存泄漏。
隱藏的內存泄漏問題,大多是有觸發條件的,重現問題是需要這些條件的,所以我們在平時寫代碼的時候,可以將一些重要環節的參數細節打印在 log 中,這樣我們在重現問題是就不會摸不著頭腦,亂試一氣。
有了參數可以用來重現問題,接下來要確定問題。我們要確定,這部分內存是否沒有被 GC 正確釋放。那么問題來了,我們如何知道程序進行了垃圾回收呢?很顯然,等待并不是辦法,我們要主動。
在 Node.js 的啟動參數中,提供了暴露手動調用 GC 方法的參數,即 --expose-gc 。我們用這個參數來啟動應用后,就可以在代碼中調用 global.gc() 手動觸發垃圾回收操作。同時,使用 process.memoryUsage().heapUsed 獲取進程運行時所占用的內存。如果 GC 之后,內存依然沒有下降,就可以確定是內存泄露了。
二、生成內存快照
既然內存是問題,我們就需要獲取程序運行的內存快照來幫助定位問題。但內存快照并不是隨便打得,是有一定技巧的。
我們 至少要生成三次內存快照 ,才能更好的定位問題。這三次中又一次要在問題出現前生成,之后可以在問題持續的過程中生成兩次或更多。
為什么要這樣做呢?理解起來很簡單。第一次是為了獲取正常情況下的堆棧信息,而在問題出現后,堆棧信息一定會發生變化,有了第一次的信息,我們才好進行后面的比對,過濾一些無用的信息。而后兩次的快照,用來比對某一對象的堆棧變化,來確定是否是有問題的對象。下面會詳細應用到。
三、定位問題
用 devTool 的可以忽略下面的過程:
打開 Chrome Devtools ,進入到 Profiles 選項卡,點 Load 按鈕,加載之前生成的快照。
對于內存快照,有四個視圖,Summary,Comparison,Containment,Statistics,這里面常用的是前三個。
在 Summary 視圖中,我們可以看到當前快照的全部信息,以及多個快照之間的信息。在列表里顯示的都是對象的構造函數名字,可以先忽略被括號包裹的對象,優先觀察其他的對象,最后再來看他們。后面的 shallow size 表示的是對象自身的大小, retained size 表示的是對象和它依賴對象的大小,一般是 GC 不可達的。
在 Comparison 視圖中,我們可以進行多個快照之間的對比,這個用處比較大,如果我們將前兩次快照進行對比,可能比較快速的定位出問題的對象。注意觀察 New、Deleted、Delta,如果是內存泄漏的對象,可能是一直在 New,而沒有 Deleted。
在 Containment 視圖中,我們可以查看整個 GC 路徑,當然一般不會用到。因為展開在 Summary 和 Comparison 列舉的每一項,都可以看到從 GC roots 到這個對象的路徑。通過這些路徑,你可以看到這個對象的句柄被什么持有,從而定位問題產生的原因。值的注意的是,其中背景色黃色的,表示這個對象在 Javascript 中還存在引用,所以可能沒有被清除。如果是紅色的,表示的是這個對象在 Javascript 中不存在引用,但是依然存活在內存中,一般常見于 DOM 對象,它們存放的位置和 Javascript 中對象還是有不同的,在 Node.js 中很少遇見。
更多的操作方法,可以看這個視頻 Memory Profiling with Chrome DevTools 和 Memory Management Masterclass 。還有 Chrome 的文檔 Memory Profiling (舊) 和 Memory Diagnosis (新)。講的還是很詳細的。(請自備梯子)
四、解決問題
一般在 Javascript 中存在引用而導致內存泄漏的情況,是比較好處理的,只需要在使用后及時的將引用釋放掉即可。
但像 《一次 Node.js 應用內存暴漲分析》 所存在的那種內存問題,是屬于底層機制的問題,如果等不了 bugfix,就只能先通過一些啟動參數來優化內存管理。常用的參數:
- --max-old-space-size 限制老生區大小,可以控制內存占用的最大值,即使發生泄漏,也不會讓內存占用保持很高。可以根據開啟進程數以及是否同機部署來優化。
- --gc_global 這其實是個 V8 的 debug flag,讓 GC 永遠都是 Full GC,使用上會有一定的性能損耗,根據應用復雜度不同,損耗不同。
當我們找到問題,進行修復后,重復上面的步驟,確認問題已經被解決。有時可能一次并不能解決問題,所以耐心還是很重要的。
實戰
可以在這里下載使用到的代碼, GitHub ,進入 memory-leak 文件夾。
我們來舉個例子,應用上面的步驟排查問題,使用 leak-memory 的例子,代碼還有另外一個例子,可以自己實踐。
這里我們為了方便,我們使用了 devTool。
devTool leak-memory.js
然后在打開的界面中進入內存快照界面,生成第一次快照。當控制臺有輸出后,間隔的生成兩次快照,結果如下。
我們切換視圖,對比下三次快照間的區別,可以看到 Foo 這個對象一直在創建而沒有被刪除。
我們展開 Foo ,選擇下面的一個實例,查看它的 GC path,可以看到它一直被 neverRelease 持有引用(黃色),所以沒有被釋放,之后就可以進行問題的處理了。
去掉 // neverRelease.splice(index, 1); 前的注釋,然后在重復上面的步驟,你會發現內存的變化已經正常了。
在使用 devTool 時,可以查看運行時的 memory timeline,如果圖像呈現階梯式增長,一般就是存在內存泄漏問題了。正常的應用曲線會類似于鋸齒,如圖:
來自: http://taobaofed.org/blog/2016/04/15/how-to-find-memory-leak/