內(nèi)存泄露是每個(gè)開發(fā)者最終都不得不面對(duì)的問題。即便使用自動(dòng)內(nèi)存管理的語言,你還是會(huì)碰到一些內(nèi)存泄漏的情況。內(nèi)存泄露會(huì)導(dǎo)致一系列問題,比如:運(yùn)行緩慢,崩潰,高延遲,甚至一些與其他應(yīng)用相關(guān)的問題。
什么是內(nèi)存泄漏
本質(zhì)上來講,內(nèi)存泄露是當(dāng)一塊內(nèi)存不再被應(yīng)用程序使用的時(shí)候,由于某種原因,這塊內(nèi)存沒有返還給操作系統(tǒng)或者空閑內(nèi)存池的現(xiàn)象。編程語言使用不同的方式來管理內(nèi)存。這些方式可能會(huì)減少內(nèi)存泄露的機(jī)會(huì)。然而,某一塊具體的內(nèi)存是否被使用實(shí)際上是一個(gè)不可判定問題(undecidable problem)。換句話說,只有開發(fā)者可以搞清楚一塊內(nèi)存是否應(yīng)該被操作系統(tǒng)回收。某些編程語言提供了幫助開發(fā)者來處理這件事情的特性。而其它的編程語言需要開發(fā)者明確知道內(nèi)存的使用情況。維基百科上有幾篇寫的不錯(cuò)的講述手動(dòng) 和自動(dòng)內(nèi)存管理的文章。
Javascript 的內(nèi)存管理
Javascript 是那些被稱作垃圾回收語言當(dāng)中的一員。垃圾回收語言通過周期性地檢查那些之前被分配出去的內(nèi)存是否可以從應(yīng)用的其他部分訪問來幫助開發(fā)者管理內(nèi)存。換句話說,垃圾回收語言將內(nèi)存管理的問題從“什么樣的內(nèi)存是仍然被使用的?”簡(jiǎn)化成為“什么樣的內(nèi)存仍然可以從應(yīng)用程序的其他部分訪問?”。兩者的區(qū)別是細(xì)微的,但是很重要:開發(fā)者只需要知道一塊已分配的內(nèi)存是否會(huì)在將來被使用,而不可訪問的內(nèi)存可以通過算法確定并標(biāo)記以便返還給操作系統(tǒng)。
非垃圾回收語言通常使用其他的技術(shù)來管理內(nèi)存,包括:顯式內(nèi)存管理,程序員顯式地告訴編譯器在何時(shí)不再需要某塊內(nèi)存;引用計(jì)數(shù),一個(gè)計(jì)數(shù)器關(guān)聯(lián)著每個(gè)內(nèi)存塊(當(dāng)計(jì)數(shù)器的計(jì)數(shù)變?yōu)?的時(shí)候,這塊內(nèi)存就被操作系統(tǒng)回收)。這些技術(shù)都有它們的折中考慮(也就是說都有潛在的內(nèi)存泄漏風(fēng)險(xiǎn))。
Javascript 中的內(nèi)存泄露
引起垃圾收集語言內(nèi)存泄露的主要原因是不必要的引用。想要理解什么是不必要的引用,首先我們需要理解垃圾收集器是怎樣確定一塊內(nèi)存能否被訪問的。
Mark-and-sweep
大多數(shù)的垃圾收集器(簡(jiǎn)稱 GC)使用一個(gè)叫做 mark-and-sweep 的算法。這個(gè)算法由以下的幾個(gè)步驟組成:
垃圾收集器建立了一個(gè)“根節(jié)點(diǎn)”列表。根節(jié)點(diǎn)通常是那些引用被保留在代碼中的全局變量。對(duì)于 Javascript 而言,“Window” 對(duì)象就是一個(gè)能作為根節(jié)點(diǎn)的全局變量例子。window 對(duì)象是一直都存在的(即:不是垃圾)。所有根節(jié)點(diǎn)都是檢查過的并且被標(biāo)記為活動(dòng)的(即:不是垃圾)。所有的子節(jié)點(diǎn)也都被遞歸地檢查過。每塊可以從根節(jié)點(diǎn)訪問的內(nèi)存都不會(huì)被視為垃圾。 所有沒有被標(biāo)記為垃圾的內(nèi)存現(xiàn)在可以被當(dāng)做垃圾,而垃圾收集器也可以釋放這些內(nèi)存并將它們返還給操作系統(tǒng)。現(xiàn)代垃圾收集器使用不同的方式來改進(jìn)這些算法,但是它們都有相同的本質(zhì):可以訪問的內(nèi)存塊被標(biāo)記為非垃圾而其余的就被視為垃圾。
不必要的引用就是那些程序員知道這塊內(nèi)存已經(jīng)沒用了,但是出于某種原因這塊內(nèi)存依然存在于活躍的根節(jié)點(diǎn)發(fā)出的節(jié)點(diǎn)樹中。在 Javascript 的環(huán)境中,不必要的引用是某些不再被使用的代碼中的變量。這些變量指向了一塊本來可以被釋放的內(nèi)存。一些人認(rèn)為這是程序員的失誤。
所以想要理解什么是 Javascript 中最常見的內(nèi)存泄露,我們需要知道在什么情況下會(huì)出現(xiàn)不必要的引用。
3 種常見的 Javascript 內(nèi)存泄露
1: 意外的全局變量
Javascript 語言的設(shè)計(jì)目標(biāo)之一是開發(fā)一種類似于 Java 但是對(duì)初學(xué)者十分友好的語言。體現(xiàn) JavaScript 寬容性的一點(diǎn)表現(xiàn)在它處理未聲明變量的方式上:一個(gè)未聲明變量的引用會(huì)在全局對(duì)象中創(chuàng)建一個(gè)新的變量。在瀏覽器的環(huán)境下,全局對(duì)象就是 window,也就是說:
function foo(arg) {
? ?bar = "this is a hidden global variable";
}
實(shí)際上是:
function foo(arg) {
? ?window.bar = "this is an explicit global variable";
}
如果 bar 是一個(gè)應(yīng)該指向 foo 函數(shù)作用域內(nèi)變量的引用,但是你忘記使用 var 來聲明這個(gè)變量,這時(shí)一個(gè)全局變量就會(huì)被創(chuàng)建出來。在這個(gè)例子中,一個(gè)簡(jiǎn)單的字符串泄露并不會(huì)造成很大的危害,但這無疑是錯(cuò)誤的。
另外一種偶然創(chuàng)建全局變量的方式如下:
function foo() {
? ?this.variable = "potential accidental global";
}
// Foo called on its own, this points to the global object (window)
// rather than being undefined.
// 函數(shù)自身發(fā)生了調(diào)用,this 指向全局對(duì)象(window),(譯者注:這時(shí)候會(huì)為全局對(duì)象 window 添加一個(gè) variable 屬性)而不是 undefined。
foo();
為了防止這種錯(cuò)誤的發(fā)生,可以在你的 JavaScript 文件開頭添加 'use strict'; 語句。這個(gè)語句實(shí)際上開啟了解釋 JavaScript 代碼的嚴(yán)格模式,這種模式可以避免創(chuàng)建意外的全局變量。
全局變量的注意事項(xiàng)
盡管我們?cè)谟懻撃切╇[蔽的全局變量,但是也有很多代碼被明確的全局變量污染的情況。按照定義來講,這些都是不會(huì)被回收的變量(除非設(shè)置 null 或者被重新賦值)。特別需要注意的是那些被用來臨時(shí)存儲(chǔ)和處理一些大量的信息的全局變量。如果你必須使用全局變量來存儲(chǔ)很多的數(shù)據(jù),請(qǐng)確保在使用過后將它設(shè)置為 null 或者將它重新賦值。常見的和全局變量相關(guān)的引發(fā)內(nèi)存消耗增長(zhǎng)的原因就是緩存。緩存存儲(chǔ)著可復(fù)用的數(shù)據(jù)。為了讓這種做法更高效,必須為緩存的容量規(guī)定一個(gè)上界。由于緩存不能被及時(shí)回收的緣故,緩存無限制地增長(zhǎng)會(huì)導(dǎo)致很高的內(nèi)存消耗。
2: 被遺漏的定時(shí)器和回調(diào)函數(shù)
在 JavaScript 中 setInterval 的使用十分常見。其他的庫也經(jīng)常會(huì)提供觀察者和其他需要回調(diào)的功能。這些庫中的絕大部分都會(huì)關(guān)注一點(diǎn),就是當(dāng)它們本身的實(shí)例被銷毀之前銷毀所有指向回調(diào)的引用。在 setInterval 這種情況下,一般情況下的代碼是這樣的:
var someResource = getData();
setInterval(function() {
? ?var node = document.getElementById('Node');
? ?if(node) {
? ? ? ?// Do stuff with node and someResource.
? ? ? ?node.innerHTML = JSON.stringify(someResource));
? ?}
}, 1000);
這個(gè)例子說明了搖晃的定時(shí)器會(huì)發(fā)生什么:引用節(jié)點(diǎn)或者數(shù)據(jù)的定時(shí)器已經(jīng)沒用了。那些表示節(jié)點(diǎn)的對(duì)象在將來可能會(huì)被移除掉,所以將整個(gè)代碼塊放在周期處理函數(shù)中并不是必要的。然而,由于周期函數(shù)一直在運(yùn)行,處理函數(shù)并不會(huì)被回收(只有周期函數(shù)停止運(yùn)行之后才開始回收內(nèi)存)。如果周期處理函數(shù)不能被回收,它的依賴程序也同樣無法被回收。這意味著一些資源,也許是一些相當(dāng)大的數(shù)據(jù)都也無法被回收。
下面舉一個(gè)觀察者的例子,當(dāng)它們不再被需要的時(shí)候(或者關(guān)聯(lián)對(duì)象將要失效的時(shí)候)顯式地將他們移除是十分重要的。在以前,尤其是對(duì)于某些瀏覽器(IE6)是一個(gè)至關(guān)重要的步驟,因?yàn)樗鼈儾荒芎芎玫毓芾硌h(huán)引用(下面的代碼描述了更多的細(xì)節(jié))。現(xiàn)在,當(dāng)觀察者對(duì)象失效的時(shí)候便會(huì)被回收,即便 listener 沒有被明確地移除,絕大多數(shù)的瀏覽器可以或者將會(huì)支持這個(gè)特性。盡管如此,在對(duì)象被銷毀之前移除觀察者依然是一個(gè)好的實(shí)踐。示例如下:
var element = document.getElementById('button');
function onClick(event) {
? ?element.innerHtml = 'text';
}
element.addEventListener('click', onClick);
// Do stuff
element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);
// Now when element goes out of scope,
// both element and onClick will be collected even in old browsers that don't
// handle cycles well.
對(duì)象觀察者和循環(huán)引用中一些需要注意的點(diǎn)
觀察者和循環(huán)引用常常會(huì)讓 JavaScript 開發(fā)者踩坑。以前在 IE 瀏覽器的垃圾回收器上會(huì)導(dǎo)致一個(gè) bug(或者說是瀏覽器設(shè)計(jì)上的問題)。舊版本的 IE 瀏覽器不會(huì)發(fā)現(xiàn) DOM 節(jié)點(diǎn)和 JavaScript 代碼之間的循環(huán)引用。這是一種觀察者的典型情況,觀察者通常保留著一個(gè)被觀察者的引用(正如上述例子中描述的那樣)。換句話說,在 IE 瀏覽器中,每當(dāng)一個(gè)觀察者被添加到一個(gè)節(jié)點(diǎn)上時(shí),就會(huì)發(fā)生一次內(nèi)存泄漏。這也就是開發(fā)者在節(jié)點(diǎn)或者空的引用被添加到觀察者中之前顯式移除處理方法的原因。目前,現(xiàn)代的瀏覽器(包括 IE 和 Microsoft Edge)都使用了可以發(fā)現(xiàn)這些循環(huán)引用并正確的處理它們的現(xiàn)代化垃圾回收算法。換言之,嚴(yán)格地講,在廢棄一個(gè)節(jié)點(diǎn)之前調(diào)用 removeEventListener 不再是必要的操作。
像是 jQuery 這樣的框架和庫(當(dāng)使用一些特定的 API 時(shí)候)都在廢棄一個(gè)結(jié)點(diǎn)之前移除了 listener 。它們?cè)趦?nèi)部就已經(jīng)處理了這些事情,并且保證不會(huì)產(chǎn)生內(nèi)存泄露,即便程序運(yùn)行在那些問題很多的瀏覽器中,比如老版本的 IE。
3: DOM 之外的引用
有些情況下將 DOM 結(jié)點(diǎn)存儲(chǔ)到數(shù)據(jù)結(jié)構(gòu)中會(huì)十分有用。假設(shè)你想要快速地更新一個(gè)表格中的幾行,如果你把每一行的引用都存儲(chǔ)在一個(gè)字典或者數(shù)組里面會(huì)起到很大作用。如果你這么做了,程序中將會(huì)保留同一個(gè)結(jié)點(diǎn)的兩個(gè)引用:一個(gè)引用存在于 DOM 樹中,另一個(gè)被保留在字典中。如果在未來的某個(gè)時(shí)刻你決定要將這些行移除,則需要將所有的引用清除。
var elements = {
? ?button: document.getElementById('button'),
? ?image: document.getElementById('image'),
? ?text: document.getElementById('text')
};
function doStuff() {
? ?image.src = 'http://some.url/image';
? ?button.click();
? ?console.log(text.innerHTML);
? ?// Much more logic
}
function removeButton() {
? ?// The button is a direct child of body.
? ?document.body.removeChild(document.getElementById('button'));
? ?// At this point, we still have a reference to #button in the global
? ?// elements dictionary. In other words, the button element is still in
? ?// memory and cannot be collected by the GC.
}
還需要考慮另一種情況,就是對(duì) DOM 樹子節(jié)點(diǎn)的引用。假設(shè)你在 JavaScript 代碼中保留了一個(gè)表格中特定單元格(一個(gè) ?標(biāo)簽)的引用。在將來你決定將這個(gè)表格從 DOM 中移除,但是仍舊保留這個(gè)單元格的引用。憑直覺,你可能會(huì)認(rèn)為 GC 會(huì)回收除了這個(gè)單元格之外所有的東西,但是實(shí)際上這并不會(huì)發(fā)生:?jiǎn)卧袷潜砀竦囊粋€(gè)子節(jié)點(diǎn)且所有子節(jié)點(diǎn)都保留著它們父節(jié)點(diǎn)的引用。換句話說,JavaScript 代碼中對(duì)單元格的引用導(dǎo)致整個(gè)表格被保留在內(nèi)存中。所以當(dāng)你想要保留 DOM 元素的引用時(shí),要仔細(xì)的考慮清除這一點(diǎn)。
4: 閉包
JavaScript 開發(fā)中一個(gè)重要的內(nèi)容就是閉包,它是可以獲取父級(jí)作用域的匿名函數(shù)。Meteor 的開發(fā)者發(fā)現(xiàn)在一種特殊情況下有可能會(huì)以一種很微妙的方式產(chǎn)生內(nèi)存泄漏,這取決于 JavaScript 運(yùn)行時(shí)的實(shí)現(xiàn)細(xì)節(jié)。
var theThing = null;
var replaceThing = function () {
?var originalThing = theThing;
?var unused = function () {
? ?if (originalThing)
? ? ?console.log("hi");
?};
?theThing = {
? ?longStr: new Array(1000000).join('*'),
? ?someMethod: function () {
? ? ?console.log(someMessage);
? ?}
?};
};
setInterval(replaceThing, 1000);
這段代碼做了一件事:每次調(diào)用 replaceThing 時(shí),theThing 都會(huì)得到新的包含一個(gè)大數(shù)組和新的閉包(someMethod)的對(duì)象。同時(shí),沒有用到的那個(gè)變量持有一個(gè)引用了 originalThing(replaceThing 調(diào)用之前的 theThing)閉包。哈,是不是已經(jīng)有點(diǎn)暈了?關(guān)鍵的問題是每當(dāng)在同一個(gè)父作用域下創(chuàng)建閉包作用域的時(shí)候,這個(gè)作用域是被共享的。在這種情況下,someMethod 的閉包作用域和 unused 的作用域是共享的。unused 持有一個(gè) originalThing 的引用。盡管 unused 從來沒有被使用過,someMethod 可以在 theThing 之外被訪問。而且 someMethod 和 unused 共享了閉包作用域,即便 unused 從來都沒有被使用過,它對(duì) originalThing 的引用還是強(qiáng)制它保持活躍狀態(tài)(阻止它被回收)。當(dāng)這段代碼重復(fù)運(yùn)行時(shí),將可以觀察到內(nèi)存消耗穩(wěn)定地上漲,并且不會(huì)因?yàn)?GC 的存在而下降。本質(zhì)上來講,創(chuàng)建了一個(gè)閉包鏈表(根節(jié)點(diǎn)是 theThing 形式的變量),而且每個(gè)閉包作用域都持有一個(gè)對(duì)大數(shù)組的間接引用,這導(dǎo)致了一個(gè)巨大的內(nèi)存泄露。
這是一種人為的實(shí)現(xiàn)方式。可以想到一個(gè)能夠解決這個(gè)問題的不同的閉包實(shí)現(xiàn),就像 Metero 的博客里面說的那樣。
垃圾收集器的直觀行為
盡管垃圾收集器是便利的,但是使用它們也需要有一些利弊權(quán)衡。其中之一就是不確定性。也就是說,GC 的行為是不可預(yù)測(cè)的。通常情況下都不能確定什么時(shí)候會(huì)發(fā)生垃圾回收。這意味著在一些情形下,程序會(huì)使用比實(shí)際需要更多的內(nèi)存。有些的情況下,在很敏感的應(yīng)用中可以觀察到明顯的卡頓。盡管不確定性意味著你無法確定什么時(shí)候垃圾回收會(huì)發(fā)生,不過絕大多數(shù)的 GC 實(shí)現(xiàn)都會(huì)在內(nèi)存分配時(shí)遵從通用的垃圾回收過程模式。如果沒有內(nèi)存分配發(fā)生,大部分的 GC 都會(huì)保持靜默。考慮以下的情形:
大量?jī)?nèi)存分配發(fā)生時(shí)。
大部分(或者全部)的元素都被標(biāo)記為不可達(dá)(假設(shè)我們講一個(gè)指向無用緩存的引用置 null 的時(shí)候)。
沒有進(jìn)一步的內(nèi)存分配發(fā)生。
這個(gè)情形下,GC 將不會(huì)運(yùn)行任何進(jìn)一步的回收過程。也就是說,盡管有不可達(dá)的引用可以觸發(fā)回收,但是收集器并不要求回收它們。嚴(yán)格的說這些不是內(nèi)存泄露,但仍然導(dǎo)致高于正常情況的內(nèi)存空間使用。
Google 在它們的 JavaScript 內(nèi)存分析文檔中提供一個(gè)關(guān)于這個(gè)行為的優(yōu)秀例子,見示例#2.
Chrome 內(nèi)存分析工具簡(jiǎn)介
Chrome 提供了一套很好的工具用來分析 JavaScript 的內(nèi)存適用。這里有兩個(gè)與內(nèi)存相關(guān)的重要視圖:timeline 視圖和 profiles 視圖。
Timeline view
timeline 視圖是我們用于發(fā)現(xiàn)不正常內(nèi)存模式的必要工具。當(dāng)我們尋找嚴(yán)重的內(nèi)存泄漏時(shí),內(nèi)存回收發(fā)生后產(chǎn)生的周期性的不會(huì)消減的內(nèi)存跳躍式增長(zhǎng)會(huì)被一面紅旗標(biāo)記。在這個(gè)截圖里面我們可以看到,這很像是一個(gè)穩(wěn)定的對(duì)象內(nèi)存泄露。即便最后經(jīng)歷了一個(gè)很大的內(nèi)存回收,它占用的內(nèi)存依舊比開始時(shí)多得多。節(jié)點(diǎn)數(shù)也比開始要高。這些都是代碼中某處 DOM 節(jié)點(diǎn)內(nèi)存泄露的標(biāo)志。
Profiles 視圖
你將會(huì)花費(fèi)大部分的時(shí)間在觀察這個(gè)視圖上。profiles 視圖讓你可以對(duì) JavaScript 代碼運(yùn)行時(shí)的內(nèi)存進(jìn)行快照,并且可以比較這些內(nèi)存快照。它還讓你可以記錄一段時(shí)間內(nèi)的內(nèi)存分配情況。在每一個(gè)結(jié)果視圖中都可以展示不同類型的列表,但是對(duì)我們的任務(wù)最有用的是 summary 列表和 comparison 列表。
summary 視圖提供了不同類型的分配對(duì)象以及它們的合計(jì)大小:shallow size (一個(gè)特定類型的所有對(duì)象的總和)和 retained size (shallow size 加上保留此對(duì)象的其它對(duì)象的大小)。distance 顯示了對(duì)象到達(dá) GC 根(校者注:最初引用的那塊內(nèi)存,具體內(nèi)容可自行搜索該術(shù)語)的最短距離。
comparison 視圖提供了同樣的信息但是允許對(duì)比不同的快照。這對(duì)于找到泄露很有幫助。
舉例: 使用 Chrome 來發(fā)現(xiàn)內(nèi)存泄露
有兩個(gè)重要類型的內(nèi)存泄露:引起內(nèi)存周期性增長(zhǎng)的泄露和只發(fā)生一次且不引起更進(jìn)一步內(nèi)存增長(zhǎng)的泄露。顯而易見的是,尋找周期性的內(nèi)存泄漏是更簡(jiǎn)單的。這些也是最麻煩的事情:如果內(nèi)存會(huì)按時(shí)增長(zhǎng),泄露最終將導(dǎo)致瀏覽器變慢或者停止執(zhí)行腳本。很明顯的非周期性大量?jī)?nèi)存泄露可以很容易的在其他內(nèi)存分配中被發(fā)現(xiàn)。但是實(shí)際情況并不如此,往往這些泄露都是不足以引起注意的。這種情況下,小的非周期性內(nèi)存泄露可以被當(dāng)做一個(gè)優(yōu)化點(diǎn)。然而那些周期性的內(nèi)存泄露應(yīng)該被視為 bug 并且必須被修復(fù)。
為了舉例,我們將會(huì)使用 Chrome 的文檔中提供的一個(gè)例子。完整的代碼在下面可以找到:
var x = [];
function createSomeNodes() {
? ?var div,
? ? ? ?i = 100,
? ? ? ?frag = document.createDocumentFragment();
? ?for (;i > 0; i--) {
? ? ? ?div = document.createElement("div");
? ? ? ?div.appendChild(document.createTextNode(i + " - "+ new Date().toTimeString()));
? ? ? ?frag.appendChild(div);
? ?}
? ?document.getElementById("nodes").appendChild(frag);
}
function grow() {
? ?x.push(new Array(1000000).join('x'));
? ?createSomeNodes();
? ?setTimeout(grow,1000);
}
當(dāng)調(diào)用 grow 的時(shí)候,它會(huì)開始創(chuàng)建 div 節(jié)點(diǎn)并且把他們追加到 DOM 上。它將會(huì)分配一個(gè)大數(shù)組并將它追加到一個(gè)全局?jǐn)?shù)組中。這將會(huì)導(dǎo)致內(nèi)存的穩(wěn)定增長(zhǎng),使用上面提到的工具可以觀察到這一點(diǎn)。
垃圾收集語言通常表現(xiàn)出內(nèi)存用量的抖動(dòng)。如果代碼在一個(gè)發(fā)生分配的循環(huán)中運(yùn)行時(shí),這是很常見的。我們將要尋找那些在內(nèi)存分配之后周期性且不會(huì)回落的內(nèi)存增長(zhǎng)。
查看內(nèi)存是否周期性增長(zhǎng)
對(duì)于這個(gè)問題,timeline 視圖最合適不過了。在 Chrome 中運(yùn)行這個(gè)例子,打開開發(fā)者工具,定位到 timeline,選擇內(nèi)存并且點(diǎn)擊記錄按鈕。然后去到那個(gè)頁面點(diǎn)擊按鈕開始內(nèi)存泄露。一段時(shí)間后停止記錄,然后觀察結(jié)果:
這個(gè)例子中每秒都會(huì)發(fā)生一次內(nèi)存泄露。記錄停止后,在 grow 函數(shù)中設(shè)置一個(gè)斷點(diǎn)來防止 Chrome 強(qiáng)制關(guān)閉這個(gè)頁面。
在圖中有兩個(gè)明顯的標(biāo)志表明我們正在泄漏內(nèi)存。節(jié)點(diǎn)的圖表(綠色的線)和 JS 堆內(nèi)存(藍(lán)色的線)。節(jié)點(diǎn)數(shù)穩(wěn)定地增長(zhǎng)并且從不減少。這是一個(gè)明顯的警告標(biāo)志。
JS 堆內(nèi)存表現(xiàn)出穩(wěn)定的內(nèi)存用量增長(zhǎng)。由于垃圾回收器的作用,這很難被發(fā)現(xiàn)。你能看到一個(gè)初始內(nèi)存的增長(zhǎng)的圖線,緊接著有一個(gè)很大的回落,接著又有一段增長(zhǎng)然后出現(xiàn)了一個(gè)峰值,接著又是一個(gè)回落。這個(gè)情況的關(guān)鍵是在于一個(gè)事實(shí),即每次內(nèi)存用量回落時(shí)候,堆內(nèi)存總是比上一次回落后的內(nèi)存占用量更多。也就是說,盡管垃圾收集器成功地回收了很多的內(nèi)存,還是有一部分內(nèi)存周期性的泄露了。
我們現(xiàn)在確定程序中有一個(gè)泄露,讓我們一起找到它。
拍兩張快照
為了找到這個(gè)內(nèi)存泄漏,我們將使用 Chrome 開發(fā)者工具紅的 profiles 選項(xiàng)卡。為了保證內(nèi)存的使用在一個(gè)可控制的范圍內(nèi),在做這一步之前刷新一下頁面。我們將使用 Take Heap Snapshot 功能。
刷新頁面,在頁面加載結(jié)束后為堆內(nèi)存捕獲一個(gè)快照。我們將要使用這個(gè)快照作為我們的基準(zhǔn)。然后再次點(diǎn)擊按鈕,等幾秒,然后再拍一個(gè)快照。拍完照后,推薦的做法是在腳本中設(shè)置一個(gè)斷點(diǎn)來停止它的運(yùn)行,防止更多的內(nèi)存泄露。
有兩個(gè)方法來查看兩個(gè)快照之間的內(nèi)存分配情況,其中一種方法需要選擇 Summary 然后在右面選取在快照1和快照2之間分配的對(duì)象,另一種方法,選擇 Comparison 而不是 Summary。兩種方法下,我們都將會(huì)看到一個(gè)列表,列表中展示了在兩個(gè)快照之間分配的對(duì)象。
本例中,我們很容易就可以找到內(nèi)存泄露:它們很明顯。看一下(string)構(gòu)造函數(shù)的 Size Delta。58個(gè)對(duì)象占用了8 MB 內(nèi)存。這看起來很可疑:新的對(duì)象被創(chuàng)建,但是沒有被釋放導(dǎo)致了8 MB 的內(nèi)存消耗。
如果我們打開(string)構(gòu)造函數(shù)分配列表,我們會(huì)注意到在很多小內(nèi)存分配中摻雜著的幾個(gè)大量的內(nèi)存分配。這些情況立即引起了我們的注意。如果我們選擇它們當(dāng)中的任意一個(gè),我們將會(huì)在下面的 retainer 選項(xiàng)卡中得到一些有趣的結(jié)果。
我們發(fā)現(xiàn)我們選中的內(nèi)存分配信息是一個(gè)數(shù)組的一部分。相應(yīng)地,數(shù)組被變量 x 在全局 window 對(duì)象內(nèi)部引用。這給我們指引了一條從我們的大對(duì)象到不會(huì)被回收的根節(jié)點(diǎn)(window)的完整的路徑。我們也就找到了潛在的泄漏點(diǎn)以及它在哪里被引用。
到現(xiàn)在為止,一切都很不錯(cuò)。但是我們的例子太簡(jiǎn)單了:像例子中這樣大的內(nèi)存分配并不是很常見。幸運(yùn)的是我們的例子中還存在著細(xì)小的 DOM 節(jié)點(diǎn)內(nèi)存泄漏。使用上面的內(nèi)存快照可以很容易地找到這些節(jié)點(diǎn),但是在更大的站點(diǎn)中,事情變得復(fù)雜起來。最近,新的 Chrome 的版本中提供了一個(gè)附加的工具,這個(gè)工具十分適合我們的工作,這就是堆內(nèi)存分配記錄(Record Heap Allocations)功能
通過記錄堆內(nèi)存分配來發(fā)現(xiàn)內(nèi)存泄露
取消掉你之前設(shè)置的斷點(diǎn)讓腳本繼續(xù)運(yùn)行,然后回到開發(fā)者工具的 Profiles 選項(xiàng)卡。現(xiàn)在點(diǎn)擊 Record Heap Allocations。當(dāng)工具運(yùn)行時(shí)候你將注意到圖表頂部的藍(lán)色細(xì)線。這些代表著內(nèi)存分配。我們的代碼導(dǎo)致每秒鐘都有一個(gè)大的內(nèi)存分配發(fā)生。讓它運(yùn)行幾秒然后讓程序停止(不要忘記在此設(shè)置斷點(diǎn)來防止 Chrome 吃掉過多的內(nèi)存)。
在這張圖中你能看到這個(gè)工具的殺手锏:選擇時(shí)間線中的一片來觀察在這段時(shí)間片中內(nèi)存分配發(fā)生在什么地方。我們將時(shí)間片設(shè)置的盡量與藍(lán)色線接近。只有三個(gè)構(gòu)造函數(shù)在這個(gè)列表中顯示出來:一個(gè)是與我們的大泄露有關(guān)的(string),一個(gè)是和 DOM 節(jié)點(diǎn)的內(nèi)存分配相關(guān)的,另一個(gè)是 Text 構(gòu)造函數(shù)(DOM 節(jié)點(diǎn)中的文本構(gòu)造函數(shù))。
從列表中選擇一個(gè) HTMLDivElement 構(gòu)造函數(shù)然后選擇一個(gè)內(nèi)存分配堆棧。
啊哈!我們現(xiàn)在知道那些元素在什么地方被分配了(grow -> createSomeNodes)。如果我們集中精神觀察圖像中的每個(gè)藍(lán)色線,還會(huì)注意到 HTMLDivElement 的構(gòu)造函數(shù)被調(diào)用了很多次。如果我們回到快照 comparison 視圖就不難發(fā)現(xiàn)這個(gè)構(gòu)造函數(shù)分配了很多次內(nèi)存但是沒有從未釋放它們。也就是說,它不斷地分配內(nèi)存空間,但卻沒有允許 GC 回收它們。種種跡象表明這是一個(gè)泄露,加上我們確切地知道這些對(duì)象被分配到了什么地方(createSomeNodes 函數(shù))。現(xiàn)在應(yīng)該去研究代碼,并修復(fù)這個(gè)泄漏。
其他有用的特性
在堆內(nèi)存分配結(jié)果視圖中我們可以使用比 Summary 更好的 Allocation 視圖。
這個(gè)視圖為我們呈現(xiàn)了一個(gè)函數(shù)的列表,同時(shí)也顯示了與它們相關(guān)的內(nèi)存分配情況。我們能立即看到 grow 和 createSomeNodes 凸顯了出來。當(dāng)選擇 grow 我們看到了與它相關(guān)的對(duì)象構(gòu)造函數(shù)被調(diào)用的情況。我們注意到了(string),HTMLDivElement 和 Text 而現(xiàn)在我們已經(jīng)知道是對(duì)象的構(gòu)造函數(shù)被泄露了。
這些工具的組合對(duì)找到泄漏有很大幫助。和它們一起工作。為你的生產(chǎn)環(huán)境站點(diǎn)做不同的分析(最好用沒有最小化或混淆的代碼)。看看你能不能找到那些比正常情況消耗更多內(nèi)存的對(duì)象吧(提示:這些很難被找到)。
如果要使用 Allocation 視圖,需要進(jìn)入 Dev Tools -> Settings,選中“record heap allocation stack traces”。獲取記錄之前必須要這么做。
延伸閱讀
Memory Management – Mozilla Developer Network
JScript Memory Leaks – Douglas Crockford (old, in relation to Internet Explorer 6 leaks)
JavaScript Memory Profiling – Chrome Developer Docs
Memory Diagnosis – Google Developers
An Interesting Kind of JavaScript Memory Leak – Meteor blog
Grokking V8 closures
結(jié)論
在垃圾回收語言中,如 JavaScript,確實(shí)會(huì)發(fā)生內(nèi)存泄露。一些情況下我們都不會(huì)意識(shí)到這些泄露,最終它們將會(huì)帶來毀滅性的災(zāi)難。正是由于這個(gè)原因,使用內(nèi)存分析工具來發(fā)現(xiàn)內(nèi)存泄露是十分重要的。運(yùn)行分析工具應(yīng)該成為開發(fā)周期中的一部分,特別是對(duì)于中型或大型應(yīng)用來講。