一直以來,跟蹤 Node.js 的內存泄漏是一個反覆出現的話題,人們始終但願對其複雜性和緣由瞭解更多。html
並不是全部的內存泄漏都顯而易見。可是,一旦咱們肯定了其模式,就必須在內存使用率,內存中保存的對象和響應時間之間尋找關聯。在檢查對象時,應該根據本身所用的框架或技術(例如服務器端渲染),研究收集了多少對象,以及它們是否正常。但願在完成本文結束以後,你將可以理解並尋找一種策略來調試 Node.js 程序的內存消耗。node
JavaScript 是一種垃圾回收語言,而 Google 的 V8 最初是爲 Google Chrome 建立的JavaScript引擎,在許多狀況下均可以用做獨立的運行時。Node.js 中垃圾收集器的兩個重要操做是:算法
肯定有用的或無用的對象,而且chrome
須要記住的要點:在垃圾回收器運行時,它將徹底暫停你的程序,直到完成工做爲止。所以,你須要經過維護對象的引用來最大程度地減小其工做。npm
V8 JavaScript 引擎會自動分配和取消分配 Node.js 進程使用的全部內存。讓咱們看看實際狀況是怎樣的。編程
若是你將內存視爲一個樹結構,那麼能夠想象 V8 從「根節點」開始保存程序中全部的變量。這多是你的 window 對象,也多是 Node.js 模塊中的全局對象,一般稱爲控制者。須要牢記的一點是,你沒法對怎樣取消分配「根」節點進行控制。json
接下來,你將找到一個 Object 節點,一般被稱爲葉子(沒有子引用的節點)。最後 JavaScript 中有 4 種數據類型:布爾值,字符串,數字和對象。數組
V8 將遍歷該樹並嘗試識別沒法從「根」節點訪問的數據組。若是沒法從「根」節點訪問該數據,則 V8 假定再也不使用該數據,並釋放內存。請記住:要肯定某個對象是否處於活動狀態,須要檢查是否可經過被定義爲活動對象的某個指針鏈到達;其餘全部的狀況,例如沒法從根節點訪問,或沒法被根節點或另外一個活動對象引用的對象,都會被視爲垃圾。服務器
簡而言之,垃圾收集器有兩個主要任務:markdown
跟蹤
當你須要跟蹤來自另外一個進程的遠程引用時,它可能會變得很棘手,可是在 Node.js 程序中,咱們一般用單進程,這樣使咱們更加輕鬆。
V8 使用相似於 Java 虛擬機的方案,並將內存劃分爲多個段。實現這種包裝方案的東西被稱爲「駐留集」,它是指在 RAM 中駐留的進程所佔用的內存部分。
在駐留集中,你會發現:
代碼段:代碼實際執行的位置。
棧 : 包含局部變量和全部值類型,其指針引用堆上的對象或定義程序的控制流。
還有重要的兩點要記住:
對象的淺大小 :保存對象自己所需的內存大小
Node.js 有一個對象,以字節爲單位描述 Node.js 進程的內存使用狀況。在對象內部,你會發現:
rss : 是指駐留集大小。
heapTotal 和 heapUsed : 是指 V8 的內存使用狀況。
Chrome DevTools 是一個很棒的工具,可用於經過遠程調試來診斷 Node.js 程序中的內存泄漏。也有其餘爲你提供相似功能的工具。可是,你須要記住,概要分析是一項繁重的 CPU 任務,可能會對你的程序產生負面影響,必定要注意這一點!
咱們將要介紹的 Node.js 程序是一個簡單的 HTTP API Server,它具備多個端點,向使用該服務的人返回不一樣的信息。你能夠克隆這個程序的repository。
1const http = require('http') 2 3const leak = [] 4 5function requestListener(req, res) { 6 7 if (req.url === '/now') { 8 let resp = JSON.stringify({ now: new Date() }) 9 leak.push(JSON.parse(resp)) 10 res.writeHead(200, { 'Content-Type': 'application/json' }) 11 res.write(resp) 12 res.end() 13 } else if (req.url === '/getSushi') { 14 function importantMath() { 15 let endTime = Date.now() + (5 * 1000); 16 while (Date.now() < endTime) { 17 Math.random(); 18 } 19 } 20 21 function theSushiTable() { 22 return new Promise(resolve => { 23 resolve('壽司'); 24 }); 25 } 26 27 async function getSushi() { 28 let sushi = await theSushiTable(); 29 res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }) 30 res.write(`Enjoy! ${sushi}`); 31 res.end() 32 } 33 34 getSushi() 35 importantMath() 36 } else { 37 res.end('Invalid request') 38 } 39} 40 41const server = http.createServer(requestListener) 42server.listen(process.env.PORT || 3000)
啓動Node.js應用程序:
咱們一直在使用 3S(3 Snapshot)方法進行診斷並肯定可能的內存問題。有趣的是,咱們發現這是 Gmail 團隊的 Loreena Lee 長期使用的一種解決內存問題的方法。此方法的步驟:
打開 Chrome DevTools 並訪問 chrome://inspect。
注意: 要確保已將 Inspector 附加到要分析的 Node.js 程序。你還能夠用 ndb 鏈接到 Chrome DevTools。
當應用運行時,你將在控制檯的輸出中看到一條 Debugger Connected 消息。
轉到 Chrome DevTools > Memory
在這種狀況下,咱們獲得了第一個快照,而服務沒有進行任何負載或處理。這是針對某些用例的提示:若是咱們可以肯定在接受請求或進行某些處理以前不須要對程序進行任何預熱,那就很好了。有時,在獲取第一個堆快照以前先進行熱身操做是有意義的,由於在某些狀況下,你可能會在第一次調用時對全局變量進行了延遲初始化。
在這種狀況下,咱們將運行 npm run load-mem。這將啓動 ab 來模擬 Node.js 應用程序中的流量或負載。
2 .獲得堆快照
3 .再次在你的程序中執行你認爲會致使內存泄漏的操做。
4 .獲取最終的堆快照
5.選擇最新獲得的快照。
6.在窗口頂部,找到顯示 「All objects」 的下拉列表,並將其切換爲「Objects allocated between snapshots 1 and 2」。(若是須要,你也能夠對 2 和 3 執行相同的操做)。這將大大減小你看到的對象數量。
比較視圖也能夠幫你識別那些對象:
在該視圖中,你將看到泄漏對象的列表:頂級條目(每一個構造函數一行)、對象到GC根的距離、對象實例數、淺大小和保留大小。你能夠經過選擇一行來查看其內容。一個好的經驗法則是,首先忽略括號中的項目,由於它們是內置結構。@ 字符是對象的惟一 ID,可以讓你比較每一個對象的堆快照。
典型的內存泄漏多是經過意外地將對對象的引用存儲在沒法進行垃圾回收的全局對象中,從而保留了預期僅在一個請求週期內持續存在的對象的引用。
這個例子故意留下了一個內存泄漏的問題,在請求一個從 API 查詢返回的對象時生成帶有日期時間戳的隨機對象,並將其存儲在全局數組中來泄漏該對象。經過查看幾個保留的對象,你會看到一些泄漏數據的示例,可用於跟蹤應用程序中的泄漏。
NSolid 很是適合這種類型的用例,由於它可使你很好地瞭解在執行的每一個任務或負載測試中內存是怎樣增長的。若是你感到好奇,還能夠實時查看每一個性能分析動做如何影響 CPU。
demo
在實際項目中,你不可能老是盯着用於監視程序的工具。NSolid 的一大優勢是能夠爲應用程序的不一樣指標設置閾值和限制。例如,你能夠將 NSolid 設置爲在使用的內存量超過 X 時,或者在 X 時間內還沒有從高消耗高峯恢復內存的狀況下,進行堆快照。聽起來不錯吧?
V8 的垃圾收集器主要基於 Mark-Sweep 收集算法,該算法包括跟蹤垃圾收集,該操做經過標記可達的對象,而後清理內存並回收未標記的對象(必須沒法訪問),將其歸入釋放列表。這也稱爲世代垃圾收集器,對象能夠在新聲代、重新生代到老生代、以及老生代中移動。
移動對象的代價很是打,由於須要將對象的基礎內存複製到新位置,而且指向這些對象的指針也須要更新。
用人話解釋:
V8 遞歸查找全部對象到「根」節點的引用路徑。例如:在 JavaScript 中,「window」 對象是能夠充當 Root 的全局變量的示例。window 對象始終存在,所以垃圾收集器能夠認爲它及其全部子對象始終存在(即不是垃圾)。若是有任何引用,則沒有指向「根」節點的路徑。特別是當它以遞歸方式查找未引用的對象時,將被標記爲垃圾,稍後將會被清除以釋放該內存並將其返回給操做系統。
可是,現代的垃圾收集器以不一樣的方式對這種算法進行了改進,但本質是相同的:可訪問的內存被標記爲一類,其他的被視爲垃圾。
請記住,從根能夠訪問到的全部內容均不視爲垃圾。不須要的引用是保留在代碼中某個位置的變量,這些變量將再也不使用,而且指向能夠釋放的內存,所以,要了解 JavaScript 中最多見的泄漏,咱們須要瞭解一般忘記引用的方式。
Orinoco 是最新 GC 項目的代號,它利用最新的增量和併發技術進行垃圾回收,並有釋放主線程的功能。描述 Orinoco 性能的重要指標之一是垃圾回收器執行時主線程暫停的頻率和時間。對於經典的「世界末日」收集者而言,這些時間間隔會由於延遲、質量差的渲染以及響應時間的增長而影響程序的用戶體驗。
V8 在新聲代內存中的輔助流之間分配垃圾回收工做(清除)。每一個流接收一組指針,而後將全部活動對象移動到「to-space」。
將對象移至「to-space」時,線程須要經過讀、寫、比較和交換的原子操做進行同步,以免出現另外一個線程找到相同的對象但遵循不一樣路徑並嘗試移動的狀況。
引用自 V8 官網:
在現有 GC 中添加並行、增量和併發技術是一項多年的努力,但已取得了回報,將大量工做移交給了後臺任務。它大大改善了暫停時間、延遲和頁面加載,使動畫、滾動和用戶交互更加順暢。並行的 Scavenger 根據工做量將主線程新聲代垃圾收集的總時間減小了大約 20%–50%。Idle-time GC 能夠在 Gmail 空閒時將其 JavaScript 堆內存減小 45%。併發標記和清除能夠將笨重的 WebGL 遊戲中的暫停時間減小多達 50%。
Mark-Evacuate 收集器包括三個階段:標記、複製和更新指針。爲了不在新聲代中清理頁面以維護空閒列表,仍然使用 semi-space 來維護新生代,它始終保持緊湊狀態,即在垃圾回收期間將活動對象複製到 「to-space」 中。並行進行的好處是能夠得到「exact liveness」信息。經過僅移動和從新連接主要包含活動對象的頁面,能夠用此信息來避免複製,這也能夠由完整的 Mark-Sweep-Compact 收集器執行。它經過和標記清除算法相同的方式標記堆中的活動對象來工做,這意味着堆一般會被碎片化。V8 當前隨附有並行的 Scavenger,可在大量基準測試中減小主線程新生代垃圾回收約 20%–50% 的總時間。
與暫停主線程、響應時間和頁面加載有關的全部方面都獲得了顯着改善,這使得頁面上的動畫、滾動和用戶交互更加流暢。並行收集器能夠將新內存的總處理時間減小 20–50%,具體取決於負載。可是工做尚未結束:減小停頓仍然是一項重要任務,咱們將繼續尋找使用更先進的技術來實現這一目標的可能性。
大多數開發人員在開發 JavaScript 程序時無需考慮 GC,可是瞭解一些內部知識能夠幫助你考慮內存使用狀況和有用的編程模式。例如考慮到 V8 中基於世代的堆結構,從 GC 角度來講,維護低生存期的對象的成本其實是至關低的,由於咱們主要爲存在的對象付出代價。這種模式不只特定於 JavaScript,並且對於許多支持垃圾回收的語言也都有效。
請勿使用過期或不推薦的軟件包(例如,node-memwatch,node-inspector 或 v8-profiler)來檢查內存。你須要的一切都已經集成在了 Node.js 的二進制文件中(尤爲是 node.js 檢查器和調試器)。若是你須要更專業的工具,則可使用 NSolid、Chrome DevTools 或其餘知名軟件。
考慮在什麼時候何地觸發堆快照和 CPU profile。因爲要在生產環境中進行快照,你將會但願同時觸發這二者(主要是在測試中),因此這會須要大量的 CPU 操做。另外,在關閉進程和進行冷重啓以前,請確認有多少堆轉儲被寫入了。