作者 | 後端Team朱捷峰
整理 | 包包
V8 垃圾回收機制
事實上,我們平時在寫 Node.js 的時候很少去關心內存問題,那是因為 Node.js 對 Google V8 進行封裝,底層的垃圾收回機制都交給 V8 處理。大部分時候,是不會有內存問題的。相對於 C/C++ 這類需要自己管理內存的語言,Node.js 有更加平滑的學習曲線,這也是 Node.js 最大的優勢之一。但是也總有意外情況,可能導致 Node.js 進程內存洩漏。
那麼如何避免我們的 Node.js 程序出現內存洩漏的情況呢?我們先來了解下 V8 內存管理機制。
一個進程通常是通過在內存中分配空間來體現的,這個空間我們稱之為 Resident Set(常駐空間)。V8 將內存分為了以下幾塊:
•代碼區:實際正在運行的代碼
•棧區:包含了所有的值類型(數字、布爾值等)、指向存儲在堆區的對象指針、定義程序控制流的指針
•堆區:專門用來存儲引用類型的內存區域,比如對象、字符串和閉包
在 Node.js 中,我們可以通過調用process.memoryUsage() 方法來來查詢內存使用情況。該函數返回值如下:
memory usage
{
rss: 4935680,
heapTotal: 1826816,
heapUsed: 650472,
external: 49879
}
以上數值以字節為單位
•rss:表示 Resident Set 的大小
•heapTotal:表示堆的總大小
•heapUsed:表示堆的實際使用大小
•external:表示 V8 管理的綁定到 JavaScript 對象的 C++ 對象的大小
我們知道在 Node.js 的運行時中,JavaScript 是由 V8 編譯成可執行的機器碼。運行時的數據結構是由 V8 來管理的,我們能做的很有限。通過 JavaScript 我們是沒法做到分配內存和釋放內存的。
V8 的垃圾回收算法實現還是很複雜的,感興趣的同學可以參考:http://newhtml.net/v8-garbage-collection/。但是我們仍然可以把原理簡單抽象:如果一個內存片段沒有被任何地方引用,我們可以假設它不再會被用到,那麼該內存片段可以被釋放。
上圖表示在內存中各個對象的引用情況,只有當紅球對象不再被任何對象引用的時候,它才能被回收。
異常情況
既然 V8 會進行垃圾回收,那我們為什麼還要關心內存情況呢?
理想情況,內存佔用會保持在一個相對穩定的範圍:
實際上,我們仍然可能會看到內存佔用升高的情況:
V8 垃圾回收機制儘可能地回收和釋放內存,但是每次執行垃圾回收以後,內存佔用仍然持續上升,這明顯就是內存洩漏了。
製造內存洩漏
有一些很明顯的情況會導致內存洩漏:1、比如將每位訪客的 IP 記錄在 global 上存儲數組上;2、再比如著名的“ 沃爾瑪內存洩漏事件”,它是由 Node.js 核心代碼中一個遺漏的聲明引發的血案,工程師們花了好幾個星期去排查並最終得以解決。
在這篇文章裡,我們就不一一列舉所有可能產生問題的錯誤情況。我們來看一下一個難以排查的情況,代碼很簡單,你可以自己運行調試:
memory leak demo
const express = require('express');
const app = express();
const port = 3000;
let theThing = null;
const replaceThing = function () {
let originalThing = theThing;
let unused = function () {
if (originalThing)
console.log("hi");
};
theThing = {
longStr: new Array(1000000).join('*'),
someMethod: function () {
console.log(someMessage);
}
};
};
app.get('/leak', (req, res) => {
replaceThing();
let memoryInfo = JSON.stringify(process.memoryUsage());
console.log(memoryInfo);
res.send(memoryInfo);
})
app.listen(port, () => console.log(`Example app listening on port ${port}!`));
初看的話,這段代碼沒啥問題。我們可以想象 theThing 在每次調用 replaceThing() 時會被重寫。問題就在於,someMethod 有閉包作用域作為上下文,這就意味著當調用 someMethod 時,unused 是可見的。雖然實際上 unused 並沒有被調用,但是它卻阻止了 V8 垃圾回收機制對 originalThing 進行回收。這就是我們平時所說的“循環引用”:
既然找到問題所在,那麼如何解決呢?答案很簡單,我們只要切斷循環引用就可以了,這裡我們只需要在 replaceThing 這個方法最後加入 theThing = null。
針對這個問題,我們還可以通過 ESLint 的 no-unused-vars 規則來避免定義了但是未使用的變量,這樣可以減少循環引用的可能性。
排查問題
理解了垃圾回收的原理,那麼我們平常在碼代碼的時候也要注意避免循環引用的情況出現。但是就像上面這種情況,有時候就是防不勝防。那麼遇到問題的時候,我們應該如何排查呢?
推薦一下我寫的一個小工具 heapsnapshot.js ,可以獲取生成堆的快照信息,如下圖:
然後利用 Chrome 開發者工具,Memory 來做具體分析:
請選擇相鄰的3個堆快照文件,導入 Memory 分析工具中,如下圖:
第一步,先選擇 Profiles 中的第二個文件,然後篩選 Objects 選項選擇“Objects allocated between 1539255057342 and 1539255076968 ”,然後在 Constructor 中進行具體的分析 。
第二步,同理對第二個和第三個文件進行對比分析。找到兩次分析都出現過的元素,重點排查,定位到具體的問題代碼,再做修改。
第三步,重複上述過程,檢查內存洩漏問題是否解決。
以上只是對 Node.js 內存問題的一個初步探討,感興趣的話推薦大家去看下 V8 垃圾回收的原理。平常我們在編碼的時候也要注意儘量避免產生循環引用,但是如果遇到了也不要擔心,可以通過上面的步驟排查解決。
閱讀更多 科技狂人 的文章