內(nèi)存泄露是每個開發(fā)者最終都不得不面對的問題。即便使用自動內(nèi)存管理的語言们衙,你還是會碰到一些內(nèi)存泄漏的情況。內(nèi)存泄露會導(dǎo)致一系列問題碱呼,比如:運行緩慢蒙挑,崩潰,高延遲愚臀,甚至一些與其他應(yīng)用相關(guān)的問題忆蚀。
什么是內(nèi)存泄漏
本質(zhì)上來講,內(nèi)存泄露是當一塊內(nèi)存不再被應(yīng)用程序使用的時候懊悯,由于某種原因蜓谋,這塊內(nèi)存沒有返還給操作系統(tǒng)或者空閑內(nèi)存池的現(xiàn)象。編程語言使用不同的方式來管理內(nèi)存炭分。這些方式可能會減少內(nèi)存泄露的機會桃焕。然而,某一塊具體的內(nèi)存是否被使用實際上是一個不可判定問題(undecidable problem)捧毛。換句話說观堂,只有開發(fā)者可以搞清楚一塊內(nèi)存是否應(yīng)該被操作系統(tǒng)回收。某些編程語言提供了幫助開發(fā)者來處理這件事情的特性呀忧。而其它的編程語言需要開發(fā)者明確知道內(nèi)存的使用情況师痕。維基百科上有幾篇寫的不錯的講述手動 和自動內(nèi)存管理的文章。
Javascript 的內(nèi)存管理
Javascript 是那些被稱作垃圾回收語言當中的一員而账。垃圾回收語言通過周期性地檢查那些之前被分配出去的內(nèi)存是否可以從應(yīng)用的其他部分訪問來幫助開發(fā)者管理內(nèi)存胰坟。換句話說,垃圾回收語言將內(nèi)存管理的問題從“什么樣的內(nèi)存是仍然被使用的泞辐?”簡化成為“什么樣的內(nèi)存仍然可以從應(yīng)用程序的其他部分訪問笔横?”。兩者的區(qū)別是細微的咐吼,但是很重要:開發(fā)者只需要知道一塊已分配的內(nèi)存是否會在將來被使用吹缔,而不可訪問的內(nèi)存可以通過算法確定并標記以便返還給操作系統(tǒng)。
非垃圾回收語言通常使用其他的技術(shù)來管理內(nèi)存锯茄,包括:顯式內(nèi)存管理厢塘,程序員顯式地告訴編譯器在何時不再需要某塊內(nèi)存茶没;引用計數(shù),一個計數(shù)器關(guān)聯(lián)著每個內(nèi)存塊(當計數(shù)器的計數(shù)變?yōu)?的時候晚碾,這塊內(nèi)存就被操作系統(tǒng)回收)抓半。這些技術(shù)都有它們的折中考慮(也就是說都有潛在的內(nèi)存泄漏風(fēng)險)。
Javascript 中的內(nèi)存泄露
引起垃圾收集語言內(nèi)存泄露的主要原因是不必要的引用迄薄。想要理解什么是不必要的引用琅关,首先我們需要理解垃圾收集器是怎樣確定一塊內(nèi)存能否被訪問的。
Mark-and-sweep
大多數(shù)的垃圾收集器(簡稱 GC)使用一個叫做 mark-and-sweep 的算法讥蔽。這個算法由以下的幾個步驟組成:
垃圾收集器建立了一個“根節(jié)點”列表。根節(jié)點通常是那些引用被保留在代碼中的全局變量画机。對于 Javascript 而言冶伞,“Window” 對象就是一個能作為根節(jié)點的全局變量例子。window 對象是一直都存在的(即:不是垃圾)步氏。所有根節(jié)點都是檢查過的并且被標記為活動的(即:不是垃圾)响禽。所有的子節(jié)點也都被遞歸地檢查過。每塊可以從根節(jié)點訪問的內(nèi)存都不會被視為垃圾荚醒。 所有沒有被標記為垃圾的內(nèi)存現(xiàn)在可以被當做垃圾芋类,而垃圾收集器也可以釋放這些內(nèi)存并將它們返還給操作系統(tǒng)。現(xiàn)代垃圾收集器使用不同的方式來改進這些算法界阁,但是它們都有相同的本質(zhì):可以訪問的內(nèi)存塊被標記為非垃圾而其余的就被視為垃圾侯繁。
不必要的引用就是那些程序員知道這塊內(nèi)存已經(jīng)沒用了,但是出于某種原因這塊內(nèi)存依然存在于活躍的根節(jié)點發(fā)出的節(jié)點樹中泡躯。在 Javascript 的環(huán)境中贮竟,不必要的引用是某些不再被使用的代碼中的變量。這些變量指向了一塊本來可以被釋放的內(nèi)存较剃。一些人認為這是程序員的失誤咕别。
所以想要理解什么是 Javascript 中最常見的內(nèi)存泄露,我們需要知道在什么情況下會出現(xiàn)不必要的引用写穴。
3 種常見的 Javascript 內(nèi)存泄露
1: 意外的全局變量
Javascript 語言的設(shè)計目標之一是開發(fā)一種類似于 Java 但是對初學(xué)者十分友好的語言惰拱。體現(xiàn) JavaScript 寬容性的一點表現(xiàn)在它處理未聲明變量的方式上:一個未聲明變量的引用會在全局對象中創(chuàng)建一個新的變量。在瀏覽器的環(huán)境下啊送,全局對象就是 window偿短,也就是說:
function foo(arg) {
? ?bar = "this is a hidden global variable";
}
實際上是:
function foo(arg) {
? ?window.bar = "this is an explicit global variable";
}
如果 bar 是一個應(yīng)該指向 foo 函數(shù)作用域內(nèi)變量的引用,但是你忘記使用 var 來聲明這個變量删掀,這時一個全局變量就會被創(chuàng)建出來翔冀。在這個例子中,一個簡單的字符串泄露并不會造成很大的危害披泪,但這無疑是錯誤的纤子。
另外一種偶然創(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 指向全局對象(window),(譯者注:這時候會為全局對象 window 添加一個 variable 屬性)而不是 undefined控硼。
foo();
為了防止這種錯誤的發(fā)生泽论,可以在你的 JavaScript 文件開頭添加 'use strict'; 語句。這個語句實際上開啟了解釋 JavaScript 代碼的嚴格模式卡乾,這種模式可以避免創(chuàng)建意外的全局變量翼悴。
全局變量的注意事項
盡管我們在討論那些隱蔽的全局變量,但是也有很多代碼被明確的全局變量污染的情況幔妨。按照定義來講鹦赎,這些都是不會被回收的變量(除非設(shè)置 null 或者被重新賦值)。特別需要注意的是那些被用來臨時存儲和處理一些大量的信息的全局變量误堡。如果你必須使用全局變量來存儲很多的數(shù)據(jù)古话,請確保在使用過后將它設(shè)置為 null 或者將它重新賦值。常見的和全局變量相關(guān)的引發(fā)內(nèi)存消耗增長的原因就是緩存锁施。緩存存儲著可復(fù)用的數(shù)據(jù)陪踩。為了讓這種做法更高效,必須為緩存的容量規(guī)定一個上界悉抵。由于緩存不能被及時回收的緣故肩狂,緩存無限制地增長會導(dǎo)致很高的內(nèi)存消耗。
2: 被遺漏的定時器和回調(diào)函數(shù)
在 JavaScript 中 setInterval 的使用十分常見姥饰。其他的庫也經(jīng)常會提供觀察者和其他需要回調(diào)的功能傻谁。這些庫中的絕大部分都會關(guān)注一點,就是當它們本身的實例被銷毀之前銷毀所有指向回調(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);
這個例子說明了搖晃的定時器會發(fā)生什么:引用節(jié)點或者數(shù)據(jù)的定時器已經(jīng)沒用了。那些表示節(jié)點的對象在將來可能會被移除掉篱竭,所以將整個代碼塊放在周期處理函數(shù)中并不是必要的力图。然而,由于周期函數(shù)一直在運行掺逼,處理函數(shù)并不會被回收(只有周期函數(shù)停止運行之后才開始回收內(nèi)存)吃媒。如果周期處理函數(shù)不能被回收吕喘,它的依賴程序也同樣無法被回收。這意味著一些資源氯质,也許是一些相當大的數(shù)據(jù)都也無法被回收。
下面舉一個觀察者的例子闻察,當它們不再被需要的時候(或者關(guān)聯(lián)對象將要失效的時候)顯式地將他們移除是十分重要的拱礁。在以前,尤其是對于某些瀏覽器(IE6)是一個至關(guān)重要的步驟吴超,因為它們不能很好地管理循環(huán)引用(下面的代碼描述了更多的細節(jié))。現(xiàn)在鸯乃,當觀察者對象失效的時候便會被回收鲸阻,即便 listener 沒有被明確地移除缨睡,絕大多數(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.
對象觀察者和循環(huán)引用中一些需要注意的點
觀察者和循環(huán)引用常常會讓 JavaScript 開發(fā)者踩坑鹏浅。以前在 IE 瀏覽器的垃圾回收器上會導(dǎo)致一個 bug(或者說是瀏覽器設(shè)計上的問題)。舊版本的 IE 瀏覽器不會發(fā)現(xiàn) DOM 節(jié)點和 JavaScript 代碼之間的循環(huán)引用之碗。這是一種觀察者的典型情況季希,觀察者通常保留著一個被觀察者的引用(正如上述例子中描述的那樣)式塌。換句話說,在 IE 瀏覽器中峰尝,每當一個觀察者被添加到一個節(jié)點上時武学,就會發(fā)生一次內(nèi)存泄漏。這也就是開發(fā)者在節(jié)點或者空的引用被添加到觀察者中之前顯式移除處理方法的原因硼补。目前,現(xiàn)代的瀏覽器(包括 IE 和 Microsoft Edge)都使用了可以發(fā)現(xiàn)這些循環(huán)引用并正確的處理它們的現(xiàn)代化垃圾回收算法已骇。換言之,嚴格地講奈辰,在廢棄一個節(jié)點之前調(diào)用 removeEventListener 不再是必要的操作乱豆。
像是 jQuery 這樣的框架和庫(當使用一些特定的 API 時候)都在廢棄一個結(jié)點之前移除了 listener 。它們在內(nèi)部就已經(jīng)處理了這些事情瑟啃,并且保證不會產(chǎn)生內(nèi)存泄露揩尸,即便程序運行在那些問題很多的瀏覽器中岩榆,比如老版本的 IE。
3: DOM 之外的引用
有些情況下將 DOM 結(jié)點存儲到數(shù)據(jù)結(jié)構(gòu)中會十分有用犹撒。假設(shè)你想要快速地更新一個表格中的幾行粒褒,如果你把每一行的引用都存儲在一個字典或者數(shù)組里面會起到很大作用奕坟。如果你這么做了,程序中將會保留同一個結(jié)點的兩個引用:一個引用存在于 DOM 樹中刃跛,另一個被保留在字典中沙合。如果在未來的某個時刻你決定要將這些行移除,則需要將所有的引用清除绊率。
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.
}
還需要考慮另一種情況滤否,就是對 DOM 樹子節(jié)點的引用最仑。假設(shè)你在 JavaScript 代碼中保留了一個表格中特定單元格(一個 ?標簽)的引用。在將來你決定將這個表格從 DOM 中移除欲芹,但是仍舊保留這個單元格的引用菱父。憑直覺,你可能會認為 GC 會回收除了這個單元格之外所有的東西官辽,但是實際上這并不會發(fā)生:單元格是表格的一個子節(jié)點且所有子節(jié)點都保留著它們父節(jié)點的引用粟瞬。換句話說裙品,JavaScript 代碼中對單元格的引用導(dǎo)致整個表格被保留在內(nèi)存中。所以當你想要保留 DOM 元素的引用時扶镀,要仔細的考慮清除這一點焰轻。
4: 閉包
JavaScript 開發(fā)中一個重要的內(nèi)容就是閉包昆雀,它是可以獲取父級作用域的匿名函數(shù)狞膘。Meteor 的開發(fā)者發(fā)現(xiàn)在一種特殊情況下有可能會以一種很微妙的方式產(chǎn)生內(nèi)存泄漏,這取決于 JavaScript 運行時的實現(xiàn)細節(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 時辅愿,theThing 都會得到新的包含一個大數(shù)組和新的閉包(someMethod)的對象。同時阔蛉,沒有用到的那個變量持有一個引用了 originalThing(replaceThing 調(diào)用之前的 theThing)閉包癞埠。哈聋呢,是不是已經(jīng)有點暈了削锰?關(guān)鍵的問題是每當在同一個父作用域下創(chuàng)建閉包作用域的時候毕莱,這個作用域是被共享的央串。在這種情況下,someMethod 的閉包作用域和 unused 的作用域是共享的稳摄。unused 持有一個 originalThing 的引用饲宿。盡管 unused 從來沒有被使用過,someMethod 可以在 theThing 之外被訪問仗阅。而且 someMethod 和 unused 共享了閉包作用域减噪,即便 unused 從來都沒有被使用過车吹,它對 originalThing 的引用還是強制它保持活躍狀態(tài)(阻止它被回收)窄驹。當這段代碼重復(fù)運行時,將可以觀察到內(nèi)存消耗穩(wěn)定地上漲抗斤,并且不會因為 GC 的存在而下降丈咐。本質(zhì)上來講,創(chuàng)建了一個閉包鏈表(根節(jié)點是 theThing 形式的變量)负拟,而且每個閉包作用域都持有一個對大數(shù)組的間接引用掩浙,這導(dǎo)致了一個巨大的內(nèi)存泄露。
這是一種人為的實現(xiàn)方式衅澈∶剑可以想到一個能夠解決這個問題的不同的閉包實現(xiàn)拭抬,就像 Metero 的博客里面說的那樣。
垃圾收集器的直觀行為
盡管垃圾收集器是便利的傅蹂,但是使用它們也需要有一些利弊權(quán)衡算凿。其中之一就是不確定性氓轰。也就是說,GC 的行為是不可預(yù)測的案糙。通常情況下都不能確定什么時候會發(fā)生垃圾回收靴庆。這意味著在一些情形下撒穷,程序會使用比實際需要更多的內(nèi)存裆熙。有些的情況下,在很敏感的應(yīng)用中可以觀察到明顯的卡頓蛤奥。盡管不確定性意味著你無法確定什么時候垃圾回收會發(fā)生凡桥,不過絕大多數(shù)的 GC 實現(xiàn)都會在內(nèi)存分配時遵從通用的垃圾回收過程模式蚀同。如果沒有內(nèi)存分配發(fā)生,大部分的 GC 都會保持靜默衰猛》仁。考慮以下的情形:
大量內(nèi)存分配發(fā)生時。
大部分(或者全部)的元素都被標記為不可達(假設(shè)我們講一個指向無用緩存的引用置 null 的時候)畦戒。
沒有進一步的內(nèi)存分配發(fā)生障斋。
這個情形下笼痹,GC 將不會運行任何進一步的回收過程。也就是說晴裹,盡管有不可達的引用可以觸發(fā)回收涧团,但是收集器并不要求回收它們经磅。嚴格的說這些不是內(nèi)存泄露预厌,但仍然導(dǎo)致高于正常情況的內(nèi)存空間使用。
Google 在它們的 JavaScript 內(nèi)存分析文檔中提供一個關(guān)于這個行為的優(yōu)秀例子苗沧,見示例#2.
Chrome 內(nèi)存分析工具簡介
Chrome 提供了一套很好的工具用來分析 JavaScript 的內(nèi)存適用炭晒。這里有兩個與內(nèi)存相關(guān)的重要視圖:timeline 視圖和 profiles 視圖网严。
Timeline view
timeline 視圖是我們用于發(fā)現(xiàn)不正常內(nèi)存模式的必要工具。當我們尋找嚴重的內(nèi)存泄漏時怜庸,內(nèi)存回收發(fā)生后產(chǎn)生的周期性的不會消減的內(nèi)存跳躍式增長會被一面紅旗標記休雌。在這個截圖里面我們可以看到,這很像是一個穩(wěn)定的對象內(nèi)存泄露驰凛。即便最后經(jīng)歷了一個很大的內(nèi)存回收恰响,它占用的內(nèi)存依舊比開始時多得多涌献。節(jié)點數(shù)也比開始要高燕垃。這些都是代碼中某處 DOM 節(jié)點內(nèi)存泄露的標志。
Profiles 視圖
你將會花費大部分的時間在觀察這個視圖上您旁。profiles 視圖讓你可以對 JavaScript 代碼運行時的內(nèi)存進行快照鹤盒,并且可以比較這些內(nèi)存快照侦副。它還讓你可以記錄一段時間內(nèi)的內(nèi)存分配情況秦驯。在每一個結(jié)果視圖中都可以展示不同類型的列表,但是對我們的任務(wù)最有用的是 summary 列表和 comparison 列表亲桥。
summary 視圖提供了不同類型的分配對象以及它們的合計大邢噶恰:shallow size (一個特定類型的所有對象的總和)和 retained size (shallow size 加上保留此對象的其它對象的大胁Wぁ)璧瞬。distance 顯示了對象到達 GC 根(校者注:最初引用的那塊內(nèi)存,具體內(nèi)容可自行搜索該術(shù)語)的最短距離渔欢。
comparison 視圖提供了同樣的信息但是允許對比不同的快照瘟忱。這對于找到泄露很有幫助访诱。
舉例: 使用 Chrome 來發(fā)現(xiàn)內(nèi)存泄露
有兩個重要類型的內(nèi)存泄露:引起內(nèi)存周期性增長的泄露和只發(fā)生一次且不引起更進一步內(nèi)存增長的泄露触菜。顯而易見的是,尋找周期性的內(nèi)存泄漏是更簡單的哲泊。這些也是最麻煩的事情:如果內(nèi)存會按時增長催蝗,泄露最終將導(dǎo)致瀏覽器變慢或者停止執(zhí)行腳本生逸。很明顯的非周期性大量內(nèi)存泄露可以很容易的在其他內(nèi)存分配中被發(fā)現(xiàn)槽袄。但是實際情況并不如此,往往這些泄露都是不足以引起注意的截酷。這種情況下迂苛,小的非周期性內(nèi)存泄露可以被當做一個優(yōu)化點鼓择。然而那些周期性的內(nèi)存泄露應(yīng)該被視為 bug 并且必須被修復(fù)呐能。
為了舉例抑堡,我們將會使用 Chrome 的文檔中提供的一個例子首妖。完整的代碼在下面可以找到:
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);
}
當調(diào)用 grow 的時候有缆,它會開始創(chuàng)建 div 節(jié)點并且把他們追加到 DOM 上温亲。它將會分配一個大數(shù)組并將它追加到一個全局數(shù)組中铸豁。這將會導(dǎo)致內(nèi)存的穩(wěn)定增長,使用上面提到的工具可以觀察到這一點在刺。
垃圾收集語言通常表現(xiàn)出內(nèi)存用量的抖動蚣驼。如果代碼在一個發(fā)生分配的循環(huán)中運行時相艇,這是很常見的坛芽。我們將要尋找那些在內(nèi)存分配之后周期性且不會回落的內(nèi)存增長咙轩。
查看內(nèi)存是否周期性增長
對于這個問題,timeline 視圖最合適不過了丐膝。在 Chrome 中運行這個例子帅矗,打開開發(fā)者工具煞烫,定位到 timeline滞详,選擇內(nèi)存并且點擊記錄按鈕。然后去到那個頁面點擊按鈕開始內(nèi)存泄露最冰。一段時間后停止記錄暖哨,然后觀察結(jié)果:
這個例子中每秒都會發(fā)生一次內(nèi)存泄露凰狞。記錄停止后赡若,在 grow 函數(shù)中設(shè)置一個斷點來防止 Chrome 強制關(guān)閉這個頁面逾冬。
在圖中有兩個明顯的標志表明我們正在泄漏內(nèi)存。節(jié)點的圖表(綠色的線)和 JS 堆內(nèi)存(藍色的線)产还。節(jié)點數(shù)穩(wěn)定地增長并且從不減少脐区。這是一個明顯的警告標志她按。
JS 堆內(nèi)存表現(xiàn)出穩(wěn)定的內(nèi)存用量增長酌泰。由于垃圾回收器的作用宫莱,這很難被發(fā)現(xiàn)授霸。你能看到一個初始內(nèi)存的增長的圖線,緊接著有一個很大的回落显设,接著又有一段增長然后出現(xiàn)了一個峰值捕捂,接著又是一個回落。這個情況的關(guān)鍵是在于一個事實慷妙,即每次內(nèi)存用量回落時候膝擂,堆內(nèi)存總是比上一次回落后的內(nèi)存占用量更多。也就是說,盡管垃圾收集器成功地回收了很多的內(nèi)存总珠,還是有一部分內(nèi)存周期性的泄露了。
我們現(xiàn)在確定程序中有一個泄露孕蝉,讓我們一起找到它降淮。
拍兩張快照
為了找到這個內(nèi)存泄漏搏讶,我們將使用 Chrome 開發(fā)者工具紅的 profiles 選項卡媒惕。為了保證內(nèi)存的使用在一個可控制的范圍內(nèi)妒蔚,在做這一步之前刷新一下頁面肴盏。我們將使用 Take Heap Snapshot 功能。
刷新頁面贞绵,在頁面加載結(jié)束后為堆內(nèi)存捕獲一個快照。我們將要使用這個快照作為我們的基準恍飘。然后再次點擊按鈕榨崩,等幾秒谴垫,然后再拍一個快照。拍完照后母蛛,推薦的做法是在腳本中設(shè)置一個斷點來停止它的運行翩剪,防止更多的內(nèi)存泄露。
有兩個方法來查看兩個快照之間的內(nèi)存分配情況彩郊,其中一種方法需要選擇 Summary 然后在右面選取在快照1和快照2之間分配的對象肢专,另一種方法焦辅,選擇 Comparison 而不是 Summary。兩種方法下椿胯,我們都將會看到一個列表筷登,列表中展示了在兩個快照之間分配的對象。
本例中哩盲,我們很容易就可以找到內(nèi)存泄露:它們很明顯前方。看一下(string)構(gòu)造函數(shù)的 Size Delta廉油。58個對象占用了8 MB 內(nèi)存惠险。這看起來很可疑:新的對象被創(chuàng)建,但是沒有被釋放導(dǎo)致了8 MB 的內(nèi)存消耗抒线。
如果我們打開(string)構(gòu)造函數(shù)分配列表班巩,我們會注意到在很多小內(nèi)存分配中摻雜著的幾個大量的內(nèi)存分配。這些情況立即引起了我們的注意嘶炭。如果我們選擇它們當中的任意一個抱慌,我們將會在下面的 retainer 選項卡中得到一些有趣的結(jié)果。
我們發(fā)現(xiàn)我們選中的內(nèi)存分配信息是一個數(shù)組的一部分眨猎。相應(yīng)地抑进,數(shù)組被變量 x 在全局 window 對象內(nèi)部引用。這給我們指引了一條從我們的大對象到不會被回收的根節(jié)點(window)的完整的路徑睡陪。我們也就找到了潛在的泄漏點以及它在哪里被引用寺渗。
到現(xiàn)在為止,一切都很不錯兰迫。但是我們的例子太簡單了:像例子中這樣大的內(nèi)存分配并不是很常見信殊。幸運的是我們的例子中還存在著細小的 DOM 節(jié)點內(nèi)存泄漏。使用上面的內(nèi)存快照可以很容易地找到這些節(jié)點汁果,但是在更大的站點中鸡号,事情變得復(fù)雜起來。最近须鼎,新的 Chrome 的版本中提供了一個附加的工具鲸伴,這個工具十分適合我們的工作府蔗,這就是堆內(nèi)存分配記錄(Record Heap Allocations)功能
通過記錄堆內(nèi)存分配來發(fā)現(xiàn)內(nèi)存泄露
取消掉你之前設(shè)置的斷點讓腳本繼續(xù)運行,然后回到開發(fā)者工具的 Profiles 選項卡」埃現(xiàn)在點擊 Record Heap Allocations姓赤。當工具運行時候你將注意到圖表頂部的藍色細線。這些代表著內(nèi)存分配仲吏。我們的代碼導(dǎo)致每秒鐘都有一個大的內(nèi)存分配發(fā)生不铆。讓它運行幾秒然后讓程序停止(不要忘記在此設(shè)置斷點來防止 Chrome 吃掉過多的內(nèi)存)。
在這張圖中你能看到這個工具的殺手锏:選擇時間線中的一片來觀察在這段時間片中內(nèi)存分配發(fā)生在什么地方裹唆。我們將時間片設(shè)置的盡量與藍色線接近誓斥。只有三個構(gòu)造函數(shù)在這個列表中顯示出來:一個是與我們的大泄露有關(guān)的(string),一個是和 DOM 節(jié)點的內(nèi)存分配相關(guān)的许帐,另一個是 Text 構(gòu)造函數(shù)(DOM 節(jié)點中的文本構(gòu)造函數(shù))劳坑。
從列表中選擇一個 HTMLDivElement 構(gòu)造函數(shù)然后選擇一個內(nèi)存分配堆棧。
啊哈成畦!我們現(xiàn)在知道那些元素在什么地方被分配了(grow -> createSomeNodes)距芬。如果我們集中精神觀察圖像中的每個藍色線,還會注意到 HTMLDivElement 的構(gòu)造函數(shù)被調(diào)用了很多次循帐。如果我們回到快照 comparison 視圖就不難發(fā)現(xiàn)這個構(gòu)造函數(shù)分配了很多次內(nèi)存但是沒有從未釋放它們框仔。也就是說,它不斷地分配內(nèi)存空間拄养,但卻沒有允許 GC 回收它們离斩。種種跡象表明這是一個泄露,加上我們確切地知道這些對象被分配到了什么地方(createSomeNodes 函數(shù))”衲洌現(xiàn)在應(yīng)該去研究代碼捐腿,并修復(fù)這個泄漏。
其他有用的特性
在堆內(nèi)存分配結(jié)果視圖中我們可以使用比 Summary 更好的 Allocation 視圖柿顶。
這個視圖為我們呈現(xiàn)了一個函數(shù)的列表茄袖,同時也顯示了與它們相關(guān)的內(nèi)存分配情況。我們能立即看到 grow 和 createSomeNodes 凸顯了出來嘁锯。當選擇 grow 我們看到了與它相關(guān)的對象構(gòu)造函數(shù)被調(diào)用的情況宪祥。我們注意到了(string),HTMLDivElement 和 Text 而現(xiàn)在我們已經(jīng)知道是對象的構(gòu)造函數(shù)被泄露了家乘。
這些工具的組合對找到泄漏有很大幫助蝗羊。和它們一起工作。為你的生產(chǎn)環(huán)境站點做不同的分析(最好用沒有最小化或混淆的代碼)仁锯∫遥看看你能不能找到那些比正常情況消耗更多內(nèi)存的對象吧(提示:這些很難被找到)。
如果要使用 Allocation 視圖,需要進入 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撮抓,確實會發(fā)生內(nèi)存泄露。一些情況下我們都不會意識到這些泄露摇锋,最終它們將會帶來毀滅性的災(zāi)難丹拯。正是由于這個原因,使用內(nèi)存分析工具來發(fā)現(xiàn)內(nèi)存泄露是十分重要的荸恕。運行分析工具應(yīng)該成為開發(fā)周期中的一部分乖酬,特別是對于中型或大型應(yīng)用來講。