介紹
幾月前,我不得不調式 Node.js程序中的 內存泄漏,對此找到了很多此類的文章,但細讀了一些后,還是不知所措。
這篇文章的初衷是 定位Node.js中內存泄漏的 一個簡單的教程。我將介紹一簡單的方法入門,(以我來看)這應該是任何內存泄漏調試的起點。對于一些情況,這方法或許并不詳盡。我將附上一些你想要了解的 資源 鏈接。
最小化理論
JavaScript是一個垃圾回收的語言。因此,所有Node.js進程使用的內存將由V8 JavaScript引擎自動分配、釋放。
V8如何知道何時釋放內存?V8維護著一個的圖,這個圖始于根節點,記錄 程序中所有變量 。JavaScript有4種數據類型:Boolean, String, Number及Object。前3個是簡單類型,僅由分配的變量保存(如string文本)。Object及其其它任何數據在JavaScript中皆為Object(如數組是Object),能記錄其它對象的引用。
V8會周期性的遍歷內存圖,試圖確認不再被根節點抵達的一組數據。假如無法從根節點抵達,V8會認為此數據不再被使用,隨即釋放內存。這個過程稱為垃圾回收。
何時發生內存泄漏?
當不再需要的數據依然可從根節點抵達時, JavaScript中 就出現了 內存泄漏。V8將認為此類數據依然在使用當中,并且不會進行垃圾回收。為了調試內存泄漏,我們需要定位被錯誤保留的數據的位置,確保V8能夠清理。
值得注意的是垃圾回收并不會時刻進行,通常當V8認為時機合適時,才會進行觸發垃圾回收。如周期性的觸發,或可用內存很緊張時會特殊性調用。Node.js的每個進程的內存使用有限,所以V8必須謹慎使用。
另一特殊性調用的情況,是造成性能急速下降的地方。
試想有一個app有諸多內存泄漏, 不久,Node進程會用盡內存,由此V8會觸發特殊性內存回收。但自大多數數據依然可從根節點訪問,只有非常少的數據會被清理。
不久,Node進程將再次用盡內存,觸發另一次垃圾回收。在你察覺前,app會不斷的進行垃圾回收,只是嘗試讓進程運行。盡管V8花了大多精力在垃圾回收,但只有非常少的資源留給實際的程序。
步驟 1、重現并確認問題
正如我前面所指出的,JavaScript的V8引擎使用了一套復雜的邏輯來覺id那個什么時候垃圾收集應該運行。明白了這個,就會知道盡管我們可以看到用于一個Node現成的內存在持續地增漲,我們還是不能確定自己是否目擊了一次內存泄露, 直到我們知曉了垃圾收集已經運行起來,讓不再被使用的內存可以被清理出來。
值得慶幸的是,Node允許我們手動觸發垃圾回收,而這是在嘗試確認一個內存泄露問題時我們應該要做的第一件事情。這件事情可以借助在運行Node 時帶上 --expose-gc 標識(例如node --expose-gc index.js)來完成。一旦node以那個模式運行,你就可以用編程的方式通過從你的程序調用 global.gc() 來在任何時刻觸發一次垃圾回收。
你也可以借助于調用 process.memoryUsage().heapUsed 來檢測進程使用的內存數量。
通過手動觸發垃圾回收并檢測堆的使用情況,你就能夠判別出自己是否實際地觀察到了程序中的一次內存泄露。
示例程序
我已經創建了一個簡單的內存泄露程序,你可以看看這兒: https://github.com/akras14/memory-leak-example
你可以把它clone下來,然后運行 node --expose-gc index.js 將它跑起來。
quot;use strictquot;; require('heapdump'); var leakyData = http://www.tuicool.com/articles/[]; var nonLeakyData = []; class SimpleClass { constructor(text){ this.text = text; } } function cleanUpData(dataStore, randomObject){ var objectIndex = dataStore.indexOf(randomObject); dataStore.splice(objectIndex, 1); } function getAndStoreRandomData(){ var randomData = Math.random().toString(); var randomObject = new SimpleClass(randomData); leakyData.push(randomObject); nonLeakyData.push(randomObject); // cleanUpData(leakyData, randomObject); //lt;-- Forgot to clean up cleanUpData(nonLeakyData, randomObject); } function generateHeapDumpAndStats(){ //1. Force garbage collection every time this function is called try { global.gc(); } catch (e) { console.log(quot;You must run program with'node --expose-gc index.js' or 'npm start'quot;); process.exit(); } //2. Output Heap stats var heapUsed = process.memoryUsage().heapUsed; console.log(quot;Program is using quot; heapUsed quot; bytes of Heap.quot;) //3. Get Heap dump process.kill(process.pid, 'SIGUSR2'); } //Kick off the program setInterval(getAndStoreRandomData, 5); //Add random data every 5 milliseconds setInterval(generateHeapDumpAndStats, 2000); //Do garbage collection and heap dump every 2 seconds
這個程序將會:
-
每過5毫秒生成一個隨機的對象并將其存儲到兩個數組中,一個叫做 leakyData 而另外一個叫做 nonLeakyData 。每過5毫秒我們將清理掉 nonLeakyData 數組, 而我們將會“忘記”清理 leakyData 數組。
-
每過兩秒程序就會輸出內存的使用數量 (并發生堆的轉儲,而我們會在下一節對此進行更詳細的描述)。
如果你使用 node --expose-gc index.js(或者是 npm start)來運行這個程序, 它就會開始輸出內存的統計信息。我們讓它跑一兩分鐘然后使用 Ctr c 快捷鍵殺掉它(進程)。
你會看到內存使用在快速地上漲,盡管每兩分鐘我們都是觸發了垃圾回收的,就在我們獲得這份統計數據之前:
//1. Force garbage collection every time this function is called try { global.gc(); } catch (e) { console.log(quot;You must run program with 'node --expose-gc index.js' or 'npm start'quot;); process.exit(); } //2. Output Heap stats var heapUsed = process.memoryUsage().heapUsed; console.log(quot;Program is using quot; heapUsed quot; bytes of Heap.quot;)
With the stats output looking something like the following:
Program is using 3783656 bytes of Heap. Program is using 3919520 bytes of Heap. Program is using 3849976 bytes of Heap. Program is using 3881480 bytes of Heap. Program is using 3907608 bytes of Heap. Program is using 3941752 bytes of Heap. Program is using 3968136 bytes of Heap. Program is using 3994504 bytes of Heap. Program is using 4032400 bytes of Heap. Program is using 4058464 bytes of Heap. Program is using 4084656 bytes of Heap. Program is using 4111128 bytes of Heap. Program is using 4137336 bytes of Heap. Program is using 4181240 bytes of Heap. Program is using 4207304 bytes of Heap.
如果你以圖表展現數據,那么內存的增長態勢會表現得更加明顯。
注意:如果你比較好奇我是如何做到以圖表展現數據得,請繼續都下去。如果不感興趣的話請跳到 下一節 。
我是將輸出的統計數據保存到了一個 JSON 文件中,然后用幾行Python代碼讀入它并以圖表展示出來。為避免混亂,我已經將其保存造一個獨立的分支中,而你可以在這兒check出來: https://github.com/akras14/memory-leak-example/tree/plot
相關的部分內容如下:
var fs = require('fs'); var stats = []; //--- skip --- var heapUsed = process.memoryUsage().heapUsed; stats.push(heapUsed); //--- skip --- //On ctrl c save the stats and exit process.on('SIGINT', function(){ var data = http://www.tuicool.com/articles/JSON.stringify(stats); fs.writeFile(quot;stats.jsonquot;, data, function(err) { if(err) { console.log(err); } else { console.log(quot;/nSaved stats to stats.jsonquot;); } process.exit(); }); });
還有:
#!/usr/bin/env python import matplotlib.pyplot as plt import json statsFile = open('stats.json', 'r') heapSizes = json.load(statsFile) print('Plotting %s' % ', '.join(map(str, heapSizes))) plt.plot(heapSizes) plt.ylabel('Heap Size') plt.show()
你可以check出 plot 分支,然后跟往常一樣運行程序。一旦你運行完 plot.py ,就會有圖表生成出來。你會需要在機器上安裝好 Matplotlib 庫,才能讓程序跑起來。
或者你也可以在Excel中以圖表展現出來。
步驟 2、做至少3次堆的轉儲
好了,我們已經重現了問題,接下來該如何呢? 現在我們需要搞清楚問題出在哪兒,然后解決它。
你也許已經注意到在我上面的示例中如下的幾行代碼:
require('heapdump'); // ---skip--- //3. Get Heap dump process.kill(process.pid, 'SIGUSR2'); // ---skip---
我是使用了一個 node-heapdump 模塊,你可以在這兒找到: https://github.com/bnoordhuis/node-heapdump
為了能使用 node-heapdump, 你只需要這樣做:
-
安裝它。
-
在程序的頂部引入它
-
在類Unix的平臺上調用 kill -USR2 {{pid}}
如果你從前沒有遇到過 kill 這部分的話,其實它是 Unix 中的一個命令,你可以用它來(在其它東西中) 發送自定義信號 (也就是用戶信號(User Signal))給任何正在運行的進程。Node-heapdump 被配置為當它收到一個用戶信號二,也就是 -USR2, 后面帶上進程id,就 要做一次進程的堆轉儲 。
在我的示例程序中,通過運行 process.kill(process.pid, 'SIGUSR2'); ,我對 kill -USR2 {{pid}} 命令進行了自動化,這里 process.kill 是針對 kill 命令的一個封裝,SIGUSR2 是 -USR2 的Node表示方式, 而 process.pid 會獲取到當前 Node 進程的 id。我會在每次垃圾回收之后運行這個命令來獲得一個干凈的堆轉儲。
我想 process.kill(process.pid, 'SIGUSR2'); 是不會在 Windows 上面運行的, 不過你還是可以運行 heapdump.writeSnapshot() 來實現同樣的事情。
如果第一時間 就使用 heapdump.writeSnapshot() 的話, 這個示例也許會稍微簡單一點,不過我想提一提的就是,你還是可以在類 Unix 平臺上使用kill -USR2 {{pid}} 信號來觸發一次堆轉儲,而這可能會拍上用場。
下一節會講到我們如何使用生成的堆轉儲來堆內存泄露進行隔離。
步驟3、定位問題
在第二步中,我們做了堆轉儲,但是我們將至少需要3塊,你不久就會明白為什么要這樣。
你一旦有了堆轉儲。馬上去谷歌瀏覽器,打開瀏覽器開發者工具(windows系統快捷鍵是F12,Mac上是Commands Options i)。
一旦在開發者工具里導航到“Profiles”的標簽,在屏幕的底部選擇“ 加載 ”按鍵,導航到你導入的第一塊對轉儲并且選中它。對轉儲將會加載進瀏覽器里,如下圖所示:
繼續把另外2塊堆轉儲 加載 到view視圖中。例如,你可以使用你導入的最后2塊堆轉儲。最重要的事情是,堆轉儲必須依照順序來加載。你的文件夾導航大概如下圖所示:
你可以從圖中獲取的信息是,堆繼續隨著時間的推移而增長。
3個堆轉儲方法
堆轉儲一旦加載好,你將會在文件夾導航欄看見許多子視圖,并且它們很容易丟失。但是,我發現有一個視圖特別有用。
點擊你導入的最后一個堆轉儲,它將會馬上呈現“ 概要 ”視圖給你。到左邊的概要導航欄下拉,你可以看見另一個全部的下拉菜單。點擊它并且選擇“對象被分配在你第一塊堆轉儲與第二和最后一塊堆轉儲之間”,如下圖所示:
它將會展示我們在第一塊與最后一塊堆轉儲所分配的所有對象。事實是,這些對象依然存在于你的堆中,引起關注和值得研究,由于它們已經被垃圾回收器回收。
事實很令人吃驚,但是并不是靠著直覺來查找,并且容易被忽略。
忽略 括號里任何事物,至少要做的如下(字符串)
完成示例程序的概述步驟后,我以如下視圖作為總結。
注意到陰影大小代表對象本身,而剩下的數量代表對象與所有子對象。
似乎還有5個條目保存在我的快照里(數組)、(編譯的代碼)、(字符串)、(系統)以及簡單類。
它們看起來只有簡單類似曾相識,由于它來自如下示例程序中的代碼。
ar randomObject = new SimpleClass(randomData);
它可能是誘人的開始,通過(數組)或者(字符串)。在概要視圖中所有對象由它們的的構造函數名稱來分組。對于數組或者字符串來說,那些構造函數內嵌到JavaScript引擎里。當你的程序非常確定:通過構造函數創建來傳遞一些數據,你也會在那里獲取一些臟數據,使得我們更難找出內存泄露的根源。
這就是為什么我們一開始跳過那些步驟,看看這樣你是否能發現可疑的內存,比如在示例程序的簡單類構造函數里。
在簡單類的構造函數點擊下拉菜單,從結果列表中選擇任意被創建的對象,將會在窗口下部填充剩下的路徑(請看上圖)。從那里很容跟蹤我們數據中的內存泄露部分。
如果在你的app中你不夠幸運,像我在app里遇到問題一樣,你應該查找內部構造方法(比如字符串),從那里嘗試找出內存泄露的來源。在這種情況下,關鍵是要嘗試其他組別的值,經常出現在一些內部構造函數里,嘗試使用提示指向一個內存泄漏的可疑之處。
比如,在示例程序中,你可以觀察到許多字符串,看起來像是隨機數轉換成字符串的。假如你檢測他們的原始路徑,Chrome瀏覽器開發者工具將會指出內存泄露的數組。
第四步.確認問題已解決
確認并解決了一個可疑的內存泄漏之后,你就應該在你的堆使用中看到很大的不同。
如果我們在示例應用程序中取消下面這一行的注解:
cleanUpData(leakyData, randomObject); //lt;-- Forgot to clean up
然后像第一步描述的那樣重新運行應用,你將會觀察到下面這樣的輸出:
Program is using 3756664 bytes of Heap. Program is using 3862504 bytes of Heap. Program is using 3763208 bytes of Heap. Program is using 3763400 bytes of Heap. Program is using 3763424 bytes of Heap. Program is using 3763448 bytes of Heap. Program is using 3763472 bytes of Heap. Program is using 3763496 bytes of Heap. Program is using 3763784 bytes of Heap. Program is using 3763808 bytes of Heap. Program is using 3763832 bytes of Heap. Program is using 3758368 bytes of Heap. Program is using 3758368 bytes of Heap. Program is using 3758368 bytes of Heap. Program is using 3758368 bytes of Heap.
如果我們繪制數據圖表,就會得到類似于這張圖:
萬歲!!內存泄漏消失了。
注意在內存使用初始時的尖峰時刻仍然存在,當程序等到穩定時就會正常。注意那個尖峰時刻,在你分析的時候不要把它當做內存泄漏
其他相關資源
谷歌瀏覽器開發者工具中的內存分析
在這篇文章中,你讀到的大多數東西可以從上面的視頻中得到。這篇文章存在的唯一理由是因為我在這兩周的學習中為了發現(我所相信的)關鍵點,我不得不觀看3次視頻,我希望讓這個發現過程對其他人來說比較容易些。
我強烈建議你看這個視頻來 補充 這個帖子。
另一個有用的工具——memwatch-next
這是另一個我認為值得一提的工具。你從 這里 可以閱讀更多推薦它的理由(篇幅不長,值得閱讀)。
或直接去github庫: https://github.com/marcominetti/node-memwatch
不用點擊下載,你也可以通過這樣的方式安裝:npm install memwatch-next
然后通過兩個事件使用它:
var memwatch = require('memwatch-next'); memwatch.on('leak', function(info) { /*Log memory leak info, runs when memory leak is detected */ }); memwatch.on('stats', function(stats) { /*Log memory stats, runs when V8 does Garbage Collection*/ }); //It can also do this... var hd = new memwatch.HeapDiff(); // Do something that might leak memory var diff = hd.end(); console.log(diff);
最后的后臺日志將會輸出像下面這樣的東西,為你顯示什么類型的對象在內存中增長了。
{ quot;beforequot;: { quot;nodesquot;: 11625, quot;size_bytesquot;: 1869904, quot;sizequot;: quot;1.78 mbquot; }, quot;afterquot;: { quot;nodesquot;: 21435, quot;size_bytesquot;: 2119136, quot;sizequot;: quot;2.02 mbquot; }, quot;changequot;: { quot;size_bytesquot;: 249232, quot;sizequot;: quot;243.39 kbquot;, quot;freed_nodesquot;: 197, quot;allocated_nodesquot;: 10007, quot;detailsquot;: [ { quot;whatquot;: quot;Stringquot;, quot;size_bytesquot;: -2120, quot;sizequot;: quot;-2.07 kbquot;, quot; quot;: 3, quot;-quot;: 62 }, { quot;whatquot;: quot;Arrayquot;, quot;size_bytesquot;: 66687, quot;sizequot;: quot;65.13 kbquot;, quot; quot;: 4, quot;-quot;: 78 }, { quot;whatquot;: quot;LeakingClassquot;, quot;size_bytesquot;: 239952, quot;sizequot;: quot;234.33 kbquot;, quot; quot;: 9998, quot;-quot;: 0 } ] } }
很酷.
來自developer.chrome.com的JavaScript內存分析。
https://developer.chrome.com/devtools/docs/javascript-memory-profiling
強烈推薦必讀。它涵蓋了所有比我接觸到的還要多的主題,并且更加詳細更加準確。
不要忽視Addy Osmani在下面談到的,他提到了一些調試的提示和資源。
總結
-
在嘗試重現并驗證一個內存泄漏問題時手動觸發垃圾回收。你可以在運行Node 時帶上 --expose-gc 標記,并且在你的程序里面嗲用 global.gc() 。
-
做至少3次堆的轉儲(Heap Dump),要使用 https://github.com/bnoordhuis/node-heapdump
-
使用3次堆的轉儲方法來對內存泄露問題進行隔離
-
確認內存泄露現象已經消失
-
取得效果
Tags: Node.js
文章來源:https://www.oschina.net/translate/simple-guide-to-