本文將會(huì)先給大家介紹一些理論知識(shí),隨后再附上一個(gè)關(guān)于內(nèi)存泄漏的案例,有興趣的同學(xué)可以繼續(xù)往下看。
Node.js
使用 V8 引擎,它的特點(diǎn)是會(huì)自動(dòng)進(jìn)行垃圾回收(Garbage Collection,GC),所以我們?cè)趯?xiě)代碼的時(shí)候就不需要像C/C++
一樣去手動(dòng)分配、釋放內(nèi)存空間,方便不少,不過(guò)仍然需要注意內(nèi)存的使用,避免造成內(nèi)存泄漏(Memory Leak)。
從上圖中,可以看到 Node.js
的常駐內(nèi)存(Resident Set)分為堆和棧兩個(gè)部分,具體為:
- 堆
-
- 指針空間(Old pointer space):存儲(chǔ)的對(duì)象含有指向其它對(duì)象的指針。
- 數(shù)據(jù)空間(Old data space):存儲(chǔ)的對(duì)象僅含有數(shù)據(jù)(不含指向其它對(duì)象的指針),例如從新生代移動(dòng)過(guò)來(lái)的字符串等。
- 新生代(New Space/Young Generation):用來(lái)臨時(shí)存儲(chǔ)新對(duì)象,空間被等分為兩份,整體較小,采用 Scavenge(Minor GC) 算法進(jìn)行垃圾回收。
- 老生代(Old Space/Old Generation):用來(lái)存儲(chǔ)存活時(shí)間超過(guò)兩個(gè) Minor GC 時(shí)間的對(duì)象,采用 標(biāo)記清除 & 整理(Mark-Sweep & Mark-Compact,Major GC) 算法進(jìn)行垃圾回收,內(nèi)部可再劃分為兩個(gè)空間:
- 代碼空間(Code Space):用于存放代碼段,是唯一的可執(zhí)行內(nèi)存(不過(guò)過(guò)大的代碼段也有可能存放在大對(duì)象空間)。
- 大對(duì)象空間(Large Object Space):用于存放超過(guò)其它空間對(duì)象限制(Page::kMaxRegularHeapObjectSize)的大對(duì)象(可以參考這個(gè) V8 Commit),存放在此的對(duì)象不會(huì)在垃圾回收的時(shí)候被移動(dòng)。
- ...
- 棧:用于存放原始的數(shù)據(jù)類(lèi)型,函數(shù)調(diào)用時(shí)的入棧出棧也記錄于此。
棧的空間由操作系統(tǒng)負(fù)責(zé)管理,開(kāi)發(fā)者無(wú)需過(guò)于關(guān)心;堆的空間由 V8 引擎進(jìn)行管理,可能由于代碼問(wèn)題出現(xiàn)內(nèi)存泄漏,或者長(zhǎng)時(shí)間運(yùn)行后,垃圾回收導(dǎo)致程序運(yùn)行速度變慢。
(推薦教程:Node入門(mén))
我們可以通過(guò)下面代碼簡(jiǎn)單的觀察 Node.js
內(nèi)存使用情況:
const format = function (bytes) {
return `${(bytes / 1024 / 1024).toFixed(2)} MB`;
};
const memoryUsage = process.memoryUsage();
console.log(JSON.stringify({
rss: format(memoryUsage.rss), // 常駐內(nèi)存
heapTotal: format(memoryUsage.heapTotal), // 總的堆空間
heapUsed: format(memoryUsage.heapUsed), // 已使用的堆空間
external: format(memoryUsage.external), // C++ 對(duì)象相關(guān)的空間
}, null, 2));
external
是 C++
對(duì)象相關(guān)的空間,例如通過(guò) new ArrayBuffer(100000); 申請(qǐng)一塊 Buffer
內(nèi)存的時(shí)候,可以明顯看到 external
空間的增加。
可以通過(guò)下列參數(shù)調(diào)整相關(guān)空間的默認(rèn)大小,單位為 MB:
- --stack_size 調(diào)整??臻g
- --min_semi_space_size 調(diào)整新生代半空間的初始值
- --max_semi_space_size 調(diào)整新生代半空間的最大值
- --max-new-space-size 調(diào)整新生代空間的最大值
- --initial_old_space_size 調(diào)整老生代空間的初始值
- --max-old-space-size 調(diào)整老生代空間的最大值
其中比較常用的是 --max_new_space_size
和 --max-old-space-size
。
新生代的 Scavenge
回收算法、老生代的 Mark-Sweep & Mark-Compact
算法相關(guān)的文章已經(jīng)很多,這里就不贅述了。
內(nèi)存泄漏
由于不當(dāng)?shù)拇a,有時(shí)候難免會(huì)發(fā)生內(nèi)存泄漏,常見(jiàn)的有四個(gè)場(chǎng)景:
- 全局變量
- 閉包引用
- 事件綁定
- 緩存爆炸
接下來(lái)分別舉個(gè)例子講一講。
全局變量
沒(méi)有使用 var/let/const
聲明的變量會(huì)直接綁定在 Global
對(duì)象上(Node.js 中)或者 Windows
對(duì)象上(瀏覽器中),哪怕不再使用,仍不會(huì)被自動(dòng)回收:
function test() {
x = new Array(100000);
}
test();
console.log(x);
這段代碼的輸出為 [ <100000 empty items> ]
,可以看到 test
函數(shù)運(yùn)行完后,數(shù)組 x 仍未被釋放。
閉包引用
閉包引發(fā)的內(nèi)存泄漏往往非常隱蔽,例如下面這段代碼你能看出來(lái)是哪兒里有問(wèn)題嗎?
let theThing = null;
let replaceThing = function() {
const newThing = theThing;
const unused = function() {
if (newThing) console.log("hi");
};
// 不斷修改引用
theThing = {
longStr: new Array(1e8).join("*"),
someMethod: function() {
console.log("a");
},
};
// 每次輸出的值會(huì)越來(lái)越大
console.log(process.memoryUsage().heapUsed);
};
setInterval(replaceThing, 100);
運(yùn)行這段代碼可以看到輸出的已使用堆內(nèi)存越來(lái)越大,而其中的關(guān)鍵就是因?yàn)?在目前的 V8 實(shí)現(xiàn)當(dāng)中,閉包對(duì)象是當(dāng)前作用域中的所有內(nèi)部函數(shù)作用域共享的,也就是說(shuō) theThing.someMethod
和 unUsed
共享同一個(gè)閉包的 context
,導(dǎo)致 theThing.someMethod
隱式的持有了對(duì)之前的 newThing
的引用,所以會(huì)形成 theThing -> someMethod -> newThing -> 上一次 theThing ->...
的循環(huán)引用,從而導(dǎo)致每一次執(zhí)行 replaceThing
這個(gè)函數(shù)的時(shí)候,都會(huì)執(zhí)行一次 longStr: new Array(1e8).join("*")
,而且其不會(huì)被自動(dòng)回收,導(dǎo)致占用的內(nèi)存越來(lái)越大,最終內(nèi)存泄漏。
對(duì)于上面這個(gè)問(wèn)題有一個(gè)很巧妙的解決方法:通過(guò)引入新的塊級(jí)作用域,將 newThing
的聲明、使用與外部隔離開(kāi),從而打破共享,阻止循環(huán)引用。
let theThing = null;
let replaceThing = function() {
{
const newThing = theThing;
const unused = function() {
if (newThing) console.log("hi");
};
}
// 不斷修改引用
theThing = {
longStr: new Array(1e8).join("*"),
someMethod: function() {
console.log("a");
},
};
console.log(process.memoryUsage().heapUsed);
};
setInterval(replaceThing, 100);
這里通過(guò) { ... }
形成了單獨(dú)的塊級(jí)作用域,而且在外部沒(méi)有引用,從而 newThing
在 GC
的時(shí)候會(huì)被自動(dòng)回收,例如在我的電腦運(yùn)行這段代碼輸出如下:
2097128
2450104
2454240
...
2661080
2665200
2086736 // 此時(shí)進(jìn)行垃圾回收釋放了內(nèi)存
2093240
事件綁定
事件綁定導(dǎo)致的內(nèi)存泄漏在瀏覽器中非常常見(jiàn),一般是由于事件響應(yīng)函數(shù)未及時(shí)移除,導(dǎo)致重復(fù)綁定或者 DOM
元素已移除后未處理事件響應(yīng)函數(shù)造成的,例如下面這段 React
代碼:
class Test extends React.Component {
componentDidMount() {
window.addEventListener('resize', function() {
// 相關(guān)操作
});
}
render() {
return <div>test component</div>;
}
}
<Test />
組件在掛載的時(shí)候監(jiān)聽(tīng)了 resize
事件,但是在組件移除的時(shí)候沒(méi)有處理相應(yīng)函數(shù),假如 <Test />
的掛載和移除非常頻繁,那么就會(huì)在 window
上綁定很多無(wú)用的事件監(jiān)聽(tīng)函數(shù),最終導(dǎo)致內(nèi)存泄漏??梢酝ㄟ^(guò)如下的方式避免這個(gè)問(wèn)題:
class Test extends React.Component {
componentDidMount() {
window.addEventListener('resize', this.handleResize);
}
handleResize() { ... }
componentWillUnmount() {
window.removeEventListener('resize', this.handleResize);
}
render() {
return <div>test component</div>;
}
}
(推薦教程:Node.js教程)
緩存爆炸
通過(guò) Object/Map
的內(nèi)存緩存可以極大地提升程序性能,但是很有可能未控制好緩存的大小和過(guò)期時(shí)間,導(dǎo)致失效的數(shù)據(jù)仍緩存在內(nèi)存中,導(dǎo)致內(nèi)存泄漏:
const cache = {};
function setCache() {
cache[Date.now()] = new Array(1000);
}
setInterval(setCache, 100);
上面這段代碼中,會(huì)不斷的設(shè)置緩存,但是沒(méi)有釋放緩存的代碼,導(dǎo)致內(nèi)存最終被撐爆。
如果的確需要進(jìn)行內(nèi)存緩存的話,強(qiáng)烈建議使用 lru-cache
這個(gè) npm
包,可以設(shè)置緩存有效期和最大的緩存空間,通過(guò) LRU
淘汰算法來(lái)避免緩存爆炸。
內(nèi)存泄漏定位實(shí)操
當(dāng)出現(xiàn)內(nèi)存泄漏的時(shí)候,定位起來(lái)往往十分麻煩,主要有兩個(gè)原因:
- 程序開(kāi)始運(yùn)行的時(shí)候,問(wèn)題不會(huì)立即暴露,需要持續(xù)的運(yùn)行一段時(shí)間,甚至一兩天,才會(huì)復(fù)現(xiàn)問(wèn)題。
- 出錯(cuò)的提示信息非常模糊,往往只能看到
heap out of memory
錯(cuò)誤信息。
在這種情況下,可以借助兩個(gè)工具來(lái)定問(wèn)題:Chrome DevTools
和 heapdump
。heapdump
的作用就如同它的名字所說(shuō) - 將內(nèi)存中堆的狀態(tài)信息生成快照(snapshot)導(dǎo)出,然后我們將其導(dǎo)入到 Chrome DevTools
中看到具體的詳情,例如堆中有哪些對(duì)象、占據(jù)多少空間等等。
接下來(lái)通過(guò)上文中閉包引用里內(nèi)存泄漏的例子,來(lái)實(shí)際操作一把。首先 npm install heapdump
安裝后,修改代碼為下面的樣子:
// 一段存在內(nèi)存泄漏問(wèn)題的示例代碼
const heapdump = require('heapdump');
heapdump.writeSnapshot('init.heapsnapshot'); // 記錄初始內(nèi)存的堆快照
let i = 0; // 記錄調(diào)用次數(shù)
let theThing = null;
let replaceThing = function() {
const newThing = theThing;
let unused = function() {
if (newThing) console.log("hi");
};
// 不斷修改引用
theThing = {
longStr: new Array(1e8).join("*"),
someMethod: function() {
console.log("a");
},
};
if (++i >= 1000) {
heapdump.writeSnapshot('leak.heapsnapshot'); // 記錄運(yùn)行一段時(shí)間后內(nèi)存的堆快照
process.exit(0);
}
};
setInterval(replaceThing, 100);
在第 3 行和第 22 行,分別導(dǎo)出了初始狀態(tài)的快照和循環(huán)了 1000 次后的快照,保存為 init.heapsnapshot
與 leak.heapsnapshot
。
然后打開(kāi) Chrome
瀏覽器,按下 F12 調(diào)出DevTools
面板,點(diǎn)擊 Memory
的 Tab,最后通過(guò) Load 按鈕將剛剛的兩個(gè)快照依次導(dǎo)入:
導(dǎo)入后,在左側(cè)可以看到堆內(nèi)存有明顯的上漲,從 1.7 MB 上漲到了 3.1 MB,幾乎翻了一倍:
接下來(lái)就是最關(guān)鍵的步驟了,點(diǎn)擊 leak
快照,然后將其與 init
快照進(jìn)行對(duì)比:
右側(cè)紅框圈出來(lái)了兩列:
- Delta:表示變化的數(shù)量
- Size Delta:表述變化的空間大小
可以看到增長(zhǎng)最大的前兩項(xiàng)是 拼接的字符串(concatenated string ) 和 閉包(closure),那么我們點(diǎn)開(kāi)來(lái)看看具體有哪些:
從這兩個(gè)圖中,可以很直觀的看出來(lái)主要是 theThing.someMethod
這個(gè)函數(shù)的閉包上下文和 theThing.longStr
這個(gè)很長(zhǎng)的拼接字符串造成的內(nèi)存泄漏,到這里問(wèn)題就基本定位清楚了,我們還可以點(diǎn)擊下方的 Object
模塊來(lái)更清楚的看一下調(diào)用鏈的關(guān)系:
圖中很明顯的看出來(lái),內(nèi)存泄漏原因就是因?yàn)?newTHing <- 閉包上下文 <- someMethod<- 上一次 newThing
這樣的鏈?zhǔn)揭蕾?lài)關(guān)系導(dǎo)致內(nèi)存的快速增長(zhǎng)。圖中第二列的 distance
表示的是該變量距離根節(jié)點(diǎn)的距離,因而最上級(jí)的 newThing
是最遠(yuǎn)的,表示的是下級(jí)引用上級(jí)的關(guān)系。
(推薦微課:Node.js微課)
文章來(lái)源:www.toutiao.com/a6863362442957849102/
以上就是W3Cschool編程獅
關(guān)于關(guān)于Node.js內(nèi)存泄漏問(wèn)題的分析的相關(guān)介紹了,希望對(duì)大家有所幫助。