Node.js 性能調優之內存篇(二)——heapdump

heapdump 是一個 dump V8 堆信息的工具。v8-profiler 也包含了此功能,這兩個工具的原理都是一致的,都是 v8::Isolate::GetCurrent()->GetHeapProfiler()->TakeHeapSnapshot(title, control)。heapdump 的使用簡單些,下面我們以 heapdump 為例講解如何分析 Node.js 內存泄漏。

Node.js 測試代碼

我們用一段經典的內存泄漏代碼當作我們的測試代碼,這段代碼改自 Meteor 的官方博文《An interesting kind of JavaScript memory leak》。代碼如下:

// test/app.js"use strict";const heapdump = require("heapdump");let leakObject = null;let count = 0;setInterval(function testMemoryLeak() { const originLeakObject = leakObject; const unused = function () { if (originLeakObject) { console.log("originLeakObject"); } }; leakObject = { count: String(count++), leakStr: new Array(1e7).join("*"), leakMethod: function () { console.log("leakMessage"); } };}, 1000);

為了直觀的看到內存的增長,我們用 pm2 啟動這個程序:

$ pm2 start app.js --name="test-memory-leak"

運行幾次 pm2 list 查看 test-memory-leak 的內存程增長趨勢。然後先後執行兩次:

$ kill -USR2 PID

test 目錄下多了兩個 heapsnapshot 文件:

heapdump-415876605.166821.heapsnapshot 33.1MBheapdump-415882556.270052.heapsnapshot 70.8MB

然後使用:

$ pm2 delete test-memory-leak

停止程序,防止電腦內存被吃滿。

注意:通常要生成三次內存快照,才能更好的定位問題。第一次要在問題出現前生成,兩次或多次在出現內存泄漏的過程中生成。為什麼要這樣做呢?因為第一次是為了獲取正常情況下的堆棧信息,而在問題出現後,堆棧信息一定會發生變化,有了第一次的信息,我們才好進行後面的比對,過濾一些無用的信息。而後兩次的快照,用來比對某些對象的堆棧變化,來追蹤內存泄漏的對象。但實際情況中,內存泄漏是無法預料的,出現內存泄漏的時候我們才會去嘗試解決問題,也就是說通常是沒有正常情況下的內存快照的,只有後兩次也是可以的。

為什麼這段程序會發生內存泄漏呢?首先我們要明白閉包的原理:

同一個函數內部的閉包作用域只有一個,所有閉包共享。在執行函數的時候,如果遇到閉包,會創建閉包作用域內存空間,將該閉包所用到的局部變數添加進去,然後再遇到閉包,會在之前創建好的作用域空間添加此閉包會用到而前閉包沒用到的變數。函數結束,清除沒有被閉包作用域引用的變數。

這段代碼內存泄露原因是:testMemoryLeak 函數內有兩個閉包:unused 和 leakMethod。unused 這個閉包引用了父作用域中的 originLeakObject 變數,如果沒有後面的 leakMethod,則會在函數結束後清除,閉包作用域也跟著清除了。因為後面的 leakObject 是全局變數,即 leakMethod 是全局變數,它引用的閉包作用域(包含了 unused 所引用的 originLeakObject)不會釋放。而隨著 testMemoryLeak 不斷調用,originLeakObject 指向前一次的 leakObject,下次的 leakObject.leakMethod 又會引用之前的 originLeakObject,從而形成一個閉包引用鏈,而 leakStr 是一個大字元串,得不到釋放,從而造成內存泄漏。

解決方法:在 testMemoryLeak 函數內部最後添加 originLeakObject = null; 即可。

Chrome DevTools

我們使用 Chrome DevTools 來分析前面生成的 heapsnapshot 文件。調出 Chrome 控制台 -> Memory -> Load,按生成順序依次將前面兩個 heapsnapshot 文件載入進來。點擊第二個堆快照,如下所示:

左上角有個下拉菜單,默認是 Summary,有四個選項:

  1. Summary:以構造函數名分類顯示
  2. Comparison:比較多個快照之間的差異

  3. Containment:查看整個 GC 路徑

  4. Statistics:以餅狀圖顯示內存佔用信息

通常我們只會用前兩個,第三個一般用不到,因為展開在 Summary 和 Comparison 中的每一項,都可以看到從 GC roots 到這個對象的路徑。第四個用處不大,只能看到內存佔用比,如下圖所示:

回到 Summary 頁,可以看到有 5 個屬性:

  1. Contructor:構造函數名,如 Object、Module、Socket,(array)、(string)、(regexp) 等加了括弧的分別代表內置的 Array、String、Regexp。
  2. Distance:到 GC roots (GC 根對象)的距離。GC 根對象在瀏覽器中一般是 window 對象,在 Node.js 中是 global 對象。距離越大,說明引用越深,則有必要重點關注一下,極大可能是內存泄漏的對象。
  3. Objects count:對象個數,即展開有多少項。
  4. Shallow Size:對象自身大小,不包括它引用的對象。
  5. Retained Size:對象自身大小和它引用對象的大小,即該對象被 GC 之後所能回收的內存的大小。

Tips:一個對象的 Retained Size = 該對象的 Shallow Size + 該對象可直接或間接引用到的對象的 Shallow Size 之和。Shallow Size == Retained Size 的有 (boolean)、(number)、(string),它們無法引用其他值,並且始終是葉子節點。

點擊 Retained Size 選擇降序顯示。展開 Object,可以看到一系列以 @ 開頭的數字,點擊第一個如:@583,下面的 Retainers 將會顯示這個對象的一些信息,可以看出這是個 global 對象。

Tips: 對象保留樹(Retainers,老版本 Chrome 叫 Object"s retaining tree)展示了對象的 GC path,如下圖展示了點擊一個 Distance 是 27 的對象,Retainers 會自動展開,而且 Distance 是從 27 遞減到 1。

我們繼續選擇 Object 下的第二項,不斷展開後如圖所示:

可以看出:有一個 count="6" 的 leakObject 的 leakMethod 函數的 context(即上下文) 引用了一個 count="5" 的 originLeakObject 對象,而這個 originLeakObject 對象的 leakMethod 函數的 context 引用了 count="4" 的 originLeakObject 對象,以此類推。而每個 leakObject 或者 originLeakObject 對象上都有一個大字元串 leakStr(佔用 13% 內存),從而造成內存泄漏,符合我們之前的推斷。

Tips:背景色是黃色的,表示這個對象在 JavaScript 中還存在引用,所以可能沒有被清除。如果是紅色的,表示的是這個對象在 JavaScript 中不存在引用,但是依然存活在內存中,一般常見於 DOM 對象,它們存放的位置和 JavaScript 中對象還是有不同的,在 Node.js 中很少遇見。

對比快照

切換到 Comparison 視圖下,可以看到一些 #New、#Deleted、#Delta 等屬性,+ 和 - 代表相對於比較的堆快照而言。從下圖可以看出,(string) 增加的最多,展開 (string) 點擊某個字元串,通過 Retainers 查看引用鏈信息,或者複製 leakStr 所在對象的地址(如下是 @56017)然後到 Summary 視圖下搜索。

真實案例

在 dump 了一份石墨線上的 web 進程的堆快照後,分析發現了一些可以優化的地方,比如:

  1. lodash 被引用了 10+ 次。因為項目開始較早,package.json 使用的 lodash@3,而依賴的第三方庫大部分使用 lodash@4,導致的問題的是項目的 node_modules 下安裝的 lodash@3,依賴 lodash@4 的第三方模塊都將 lodash 安裝到了自己目錄下的 node_modules 下,lodash 被 require 了多次。解決方法:將項目的 lodash 升級到 v4 版本。
  2. 使用了一個名為 pinyin 的模塊,這一個模塊會將所有漢字及拼音載入到內存中,佔用了大約 30M 的內存空間。解決方法:資料庫不存拼音欄位,拼音搜索通過 ES 插件實現。

參考鏈接

blog.meteor.com/an-inteblog.meteor.com/an-intezhihu.com/question/5680

taobaofed.org/blog/2016

developers.google.com/w
推薦閱讀:

NodeJS 工程師必備的 8 個工具

TAG:Nodejs | 内存泄露 |