如何定位 Node.js 的內存泄漏


基礎知識

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

然后在打開的界面中進入內存快照界面,生成第一次快照。當控制台有輸出后,間隔的生成兩次快照,結果如下。

screenshot

我們切換視圖,對比下三次快照間的區別,可以看到 Foo 這個對象一直在創建而沒有被刪除。

screenshot

screenshot

我們展開 Foo,選擇下面的一個實例,查看它的 GC path,可以看到它一直被 neverRelease 持有引用(黃色),所以沒有被釋放,之后就可以進行問題的處理了。

screenshot

去掉 // neverRelease.splice(index, 1); 前的注釋,然后在重復上面的步驟,你會發現內存的變化已經正常了。

在使用 devTool 時,可以查看運行時的 memory timeline,如果圖像呈現階梯式增長,一般就是存在內存泄漏問題了。正常的應用曲線會類似於鋸齒,如圖:

screenshot

總結

  1. 內存泄漏問題的定位,經驗很重要,但有了良好工具的輔助,可以節省很多時間。如果懶得自己一步步的操作,可以接入 alinode,這個可以幫助你很方便的生成快照等運行時數據,並有一定的分析輔助,還是方便的。

  2. 你可能看到很多內存分析的文章會有一些圖來表示內存的增長,可以使用 python 來快速生成相關的圖片,使用 matplotlib.pyplot 這個包。

screenshot

參考

轉載自:http://taobaofed.org/blog/2016/04/15/how-to-find-memory-leak/
作者:凌恆


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM