在本文中,我們將探討客戶端JavaScript代碼中常見的內(nèi)存泄漏類型。 我們還將學(xué)習(xí)如何使用Chrome開發(fā)工具找到它們。
1、介紹
內(nèi)存泄漏是每個開發(fā)人員都要面臨的問題。 即使使用內(nèi)存管理的語言,也存在內(nèi)存泄漏的情況。 內(nèi)存泄漏是導(dǎo)致遲緩,崩潰,高延遲的根本原因,甚至?xí)?dǎo)致其他應(yīng)用問題。
2、什么是內(nèi)存泄露
實質(zhì)上,內(nèi)存泄漏可以定義為應(yīng)用程序不再需要的內(nèi)存,因為某種原因其不會返回到操作系統(tǒng)或可用內(nèi)存池。編程語言有不同的管理內(nèi)存的方式。這些方法可以減少泄漏內(nèi)存的機會。然而,某一塊內(nèi)存是否未被使用實際上是一個不可判定的問題。 換句話說,只有開發(fā)人員才能明確是否可以將一塊內(nèi)存返回到操作系統(tǒng)。 某些編程語言提供了幫助開發(fā)人員執(zhí)行此操作的功能。
3、JavaScript的內(nèi)存管理
JavaScript是垃圾回收語言之一。 垃圾回收語言通過定期檢查哪些先前分配的內(nèi)存是否“可達(dá)”來幫助開發(fā)人員管理內(nèi)存。 換句話說,垃圾回收語言將管理內(nèi)存的問題從“什么內(nèi)存仍可用? 到“什么內(nèi)存仍可達(dá)?”。區(qū)別是微妙的,但重要的是:雖然只有開發(fā)人員知道將來是否需要一塊分配的內(nèi)存,但是不可達(dá)的內(nèi)存可以通過算法確定并標(biāo)記為返回到操作系統(tǒng)。
非垃圾回收的語言通常使用其他技術(shù)來管理內(nèi)存:顯式管理,開發(fā)人員明確告訴編譯器何時不需要一塊內(nèi)存; 和引用計數(shù),其中使用計數(shù)與存儲器的每個塊相關(guān)聯(lián)(當(dāng)計數(shù)達(dá)到零時,其被返回到OS)。
4、JavaScript的內(nèi)存泄露
垃圾回收語言泄漏的主要原因是不需要的引用。要理解什么不需要的引用,首先我們需要了解垃圾回收器如何確定一塊內(nèi)存是否“可達(dá)”。
垃圾回收語言泄漏的主要原因是不需要的引用。
Mark-and-sweep
大多數(shù)垃圾回收器使用稱為標(biāo)記和掃描的算法。該算法由以下步驟組成:
- 1、垃圾回收器構(gòu)建一個“根”列表。根通常是在代碼中保存引用的全局變量。在JavaScript中,“window”對象是可以充當(dāng)根的全局變量的示例。窗口對象總是存在的,所以垃圾回收器可以考慮它和它的所有的孩子總是存在(即不是垃圾)。
- 2、所有根被檢查并標(biāo)記為活動(即不是垃圾)。所有孩子也被遞歸檢查。從根可以到達(dá)的一切都不被認(rèn)為是垃圾。
- 3、所有未標(biāo)記為活動的內(nèi)存塊現(xiàn)在可以被認(rèn)為是垃圾。回收器現(xiàn)在可以釋放該內(nèi)存并將其返回到操作系統(tǒng)。
現(xiàn)代垃圾回收器以不同的方式改進(jìn)了該算法,但本質(zhì)是相同的:可訪問的內(nèi)存段被標(biāo)記,其余被垃圾回收。不需要的引用是開發(fā)者知道它不再需要,但由于某種原因,保存在活動根的樹內(nèi)部的內(nèi)存段的引用。 在JavaScript的上下文中,不需要的引用是保存在代碼中某處的變量,它不再被使用,并指向可以被釋放的一塊內(nèi)存。 有些人會認(rèn)為這些都是開發(fā)者的錯誤。所以要了解哪些是JavaScript中最常見的漏洞,我們需要知道在哪些方式引用通常被忽略。
5、四種常見的JavaScript 內(nèi)存泄漏
-
1、意外的全局變量
JavaScript背后的目標(biāo)之一是開發(fā)一種看起來像Java的語言,容易被初學(xué)者使用。 JavaScript允許的方式之一是處理未聲明的變量:對未聲明的變量的引用在全局對象內(nèi)創(chuàng)建一個新的變量。 在瀏覽器的情況下,全局對象是窗口。 換一種說法:
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.
foo();
為了防止這些錯誤發(fā)生,添加'use strict'; 在您的JavaScript文件的開頭。 這使得能夠更嚴(yán)格地解析JavaScript以防止意外的全局變量。
即使我們討論了不可預(yù)測的全局變量,但是仍有一些明確的全局變量產(chǎn)生的垃圾。這些是根據(jù)定義不可回收的(除非被取消或重新分配)。特別地,用于臨時存儲和處理大量信息的全局變量是令人關(guān)注的。 如果必須使用全局變量來存儲大量數(shù)據(jù),請確保將其置空或在完成后重新分配它。與全局變量有關(guān)的增加的內(nèi)存消耗的一個常見原因是高速緩存)。緩存存儲重復(fù)使用的數(shù)據(jù)。 為了有效率,高速緩存必須具有其大小的上限。 無限增長的緩存可能會導(dǎo)致高內(nèi)存消耗,因為緩存內(nèi)容無法被回收。
-
2、被遺忘的計時器或回調(diào)函數(shù)
setInterval的使用在JavaScript中是很常見的。大多數(shù)這些庫在它們自己的實例變得不可達(dá)之后,使得對回調(diào)的任何引用不可達(dá)。在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ù)的計時器。 由節(jié)點表示的對象可以在將來被移除,使得區(qū)間處理器內(nèi)部的整個塊不需要了。 但是,處理程序(因為時間間隔仍處于活動狀態(tài))無法回收(需要停止時間間隔才能發(fā)生)。 如果無法回收間隔處理程序,則也無法回收其依賴項。 這意味著someResource,它可能存儲大小的數(shù)據(jù),也不能被回收。
對于觀察者的情況,重要的是進(jìn)行顯式調(diào)用,以便在不再需要它們時刪除它們(或者相關(guān)對象即將無法訪問)。 在過去,以前特別重要,因為某些瀏覽器(Internet Explorer 6)不能管理循環(huán)引用(參見下面的更多信息)。 現(xiàn)在,一旦觀察到的對象變得不可達(dá),即使沒有明確刪除監(jiān)聽器,大多數(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.
關(guān)于對象觀察者和循環(huán)引用:
觀察者和循環(huán)引用曾經(jīng)是JavaScript開發(fā)者的禍根。 這是由于Internet Explorer的垃圾回收器中的錯誤(或設(shè)計決策)。舊版本的Internet Explorer無法檢測DOM節(jié)點和JavaScript代碼之間的循環(huán)引用。這是一個典型的觀察者,通常保持對可觀察者的引用(如上例所示)。換句話說,每當(dāng)觀察者被添加到Internet Explorer中的一個節(jié)點時,它就會導(dǎo)致泄漏。這是開發(fā)人員在節(jié)點或在觀察者中引用之前明確刪除處理程序的原因。 現(xiàn)在,現(xiàn)代瀏覽器(包括Internet Explorer和Microsoft Edge)使用現(xiàn)代垃圾回收算法,可以檢測這些周期并正確處理它們。 換句話說,在使節(jié)點不可達(dá)之前,不必嚴(yán)格地調(diào)用removeEventListener。框架和庫(jQuery)在處理節(jié)點之前刪除偵聽器(當(dāng)為其使用特定的API時)。這是由庫內(nèi)部處理,并確保不產(chǎn)生泄漏,即使運行在有問題的瀏覽器,如舊的Internet Explorer。
-
3、脫離 DOM 的引用
有時,將DOM節(jié)點存儲在數(shù)據(jù)結(jié)構(gòu)中可能很有用。 假設(shè)要快速更新表中多行的內(nèi)容。 在字典或數(shù)組中存儲對每個DOM行的引用可能是有意義的。 當(dāng)發(fā)生這種情況時,會保留對同一個DOM元素的兩個引用:一個在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樹內(nèi)的內(nèi)部或葉節(jié)點的引用有關(guān)。 假設(shè)您在JavaScript代碼中保留對表的特定單元格(<td>標(biāo)記)的引用。 在將來的某個時候,您決定從DOM中刪除表,但保留對該單元格的引用。 直觀地,可以假設(shè)GC將回收除了該單元之外的所有東西。 在實踐中,這不會發(fā)生:單元格是該表的子節(jié)點,并且子級保持對其父級的引用。 換句話說,從JavaScript代碼對表單元格的引用導(dǎo)致整個表保留在內(nèi)存中。 在保持對DOM元素的引用時仔細(xì)考慮這一點。
-
4、閉包
JavaScript開發(fā)的一個關(guān)鍵方面是閉包:從父作用域捕獲變量的匿名函數(shù)。 Meteor開發(fā)人員發(fā)現(xiàn)了一個特定的情況,由于JavaScript運行時的實現(xiàn)細(xì)節(jié),可能以一種微妙的方式泄漏內(nèi)存:
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);
這個片段做了一件事:每次replaceThing被調(diào)用,theThing獲取一個新的對象,其中包含一個大數(shù)組和一個新的閉包(someMethod)。同時,unused變量保持一個閉包,該閉包具有對originalThing的引用(來自之前對replaceThing的調(diào)用的Thing)。已經(jīng)有點混亂了,是嗎?重要的是,一旦為同一父作用域中的閉包創(chuàng)建了作用域,則該作用域是共享的。在這種情況下,為閉包someMethod創(chuàng)建的作用域由unused共享。unused的引用了originalThing。即使unused未使用,可以通過theThing使用someMethod。由于someMethod與unused共享閉包范圍,即使未使用,它對originalThing的引用強制它保持活動(防止其收集)。當(dāng)此代碼段重復(fù)運行時,可以觀察到內(nèi)存使用量的穩(wěn)定增加。這在GC運行時不會變小。實質(zhì)上,創(chuàng)建一個閉包的鏈接列表(其根以theThing變量的形式),并且這些閉包的范圍中的每一個都包含對大數(shù)組的間接引用,導(dǎo)致相當(dāng)大的泄漏。
Meteor的博文解釋了如何修復(fù)此種問題。在replaceThing的最后添加originalThing = null。
垃圾回收器的不直觀行為:
雖然垃圾回收器很方便,但他們有自己的一套權(quán)衡。 這些權(quán)衡之一是非確定性。 換句話說,GC是不可預(yù)測的。 通常不可能確定何時執(zhí)行回收。 這意味著在某些情況下,正在使用比程序?qū)嶋H需要的更多的內(nèi)存。 在其他情況下,短暫停頓在特別敏感的應(yīng)用中可能是明顯的。 雖然非確定性意味著無法確定何時執(zhí)行集合,但大多數(shù)GC實現(xiàn)都分享在分配期間執(zhí)行集合傳遞的常見模式。 如果沒有執(zhí)行分配,則大多數(shù)GC保持靜止。 考慮以下情況:
- 1、執(zhí)行相當(dāng)大的一組分配。
- 2、大多數(shù)這些元素(或所有這些元素)被標(biāo)記為不可達(dá)(假設(shè)我們使指向我們不再需要的緩存的引用為空)。
- 3、不執(zhí)行進(jìn)一步的分配。
在這種情況下,大多數(shù)GC不會運行任何進(jìn)一步的集合過程。 換句話說,即使有不可達(dá)的引用可用于回收,回收器也不會回收這些引用。 這些不是嚴(yán)格的泄漏,但仍然導(dǎo)致高于通常的內(nèi)存使用。
Google在他們的JavaScript內(nèi)存分析文檔中提供了這種行為的一個很好的例子,next!!!。
6、Chrome內(nèi)存分析工具概述
Chrome提供了一組很好的工具來分析JavaScript代碼的內(nèi)存使用情況。 有兩個與內(nèi)存相關(guān)的基本視圖:時間軸視圖和配置文件視圖。
- 1、TimeLine
TimeLine對于在代碼中發(fā)現(xiàn)異常內(nèi)存模式至關(guān)重要。 如果我們正在尋找大的泄漏,周期性的跳躍,收縮后不會收縮,就像一個紅旗。 在這個截圖中,我們可以看到泄漏對象的穩(wěn)定增長可能是什么樣子。 即使在大收集結(jié)束后,使用的內(nèi)存總量高于開始時。 節(jié)點計數(shù)也較高。 這些都是代碼中某處泄露的DOM節(jié)點的跡象。
- 2、Profiles
這是你將花費大部分時間看的視圖。 Profiles允許您獲取快照并比較JavaScript代碼的內(nèi)存使用快照。 它還允許您記錄分配的時間。 在每個結(jié)果視圖中,不同類型的列表都可用,但是對于我們的任務(wù)最相關(guān)的是summary(概要)列表和comparison(對照)列表。
summary(概要)列表為我們概述了分配的不同類型的對象及其聚合大小:淺大小(特定類型的所有對象的總和)和保留大小(淺大小加上由于此對象保留的其他對象的大小 )。 它還給了我們一個對象相對于它的GC根(距離)有多遠(yuǎn)的概念。
comparison(對照)給了我們相同的信息,但允許我們比較不同的快照。 這對于查找泄漏是非常有用的。
7、示例:使用Chrome查找泄漏
基本上有兩種類型的泄漏:1、泄漏引起內(nèi)存使用的周期性增加。2、一次發(fā)生的泄漏,并且不會進(jìn)一步增加內(nèi)存。
由于明顯的原因,當(dāng)它們是周期性的時更容易發(fā)現(xiàn)泄漏。這些也是最麻煩的:如果內(nèi)存在時間上增加,這種類型的泄漏將最終導(dǎo)致瀏覽器變慢或停止腳本的執(zhí)行。不是周期性的泄漏可以很容易地發(fā)現(xiàn)。這通常會被忽視。在某種程度上,發(fā)生一次的小泄漏可以被認(rèn)為是優(yōu)化問題。然而,周期性的泄漏是錯誤并且必須解決的。
對于我們的示例,我們將使用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);
}
當(dāng)調(diào)用grow時,它將開始創(chuàng)建div節(jié)點并將它們附加到DOM。它還將分配一個大數(shù)組,并將其附加到全局變量引用的數(shù)組。這將導(dǎo)致使用上述工具可以找到的內(nèi)存的穩(wěn)定增加。
了解內(nèi)存是否周期性增加
Timeline非常有用。 在Chrome中打開示例,打開開發(fā)工具,轉(zhuǎn)到Timeline,選擇Memory,然后點擊錄制按鈕。 然后轉(zhuǎn)到頁面并單擊按鈕開始泄漏內(nèi)存。 一段時間后停止錄制,看看結(jié)果:
此示例將繼續(xù)每秒泄漏內(nèi)存。停止錄制后,在grow函數(shù)中設(shè)置斷點,以停止腳本強制Chrome關(guān)閉頁面。在這個圖像有兩個大的跡象,表明我們正在記錄泄漏。節(jié)點(綠線)和JS堆(藍(lán)線)的圖。節(jié)點正在穩(wěn)步增加,從不減少。這是一個大的警告標(biāo)志。
JS堆也顯示內(nèi)存使用的穩(wěn)定增長。這是很難看到由于垃圾回收器的影響。您可以看到初始內(nèi)存增長的模式,隨后是大幅下降,隨后是增加,然后是尖峰,繼續(xù)記憶的另一下降。 在這種情況下的關(guān)鍵在于事實,在每次內(nèi)存使用后,堆的大小保持大于上一次下降。 換句話說,雖然垃圾收集器正在成功地收集大量的存儲器,但是它還是周期性地泄漏了。
現(xiàn)在確定有泄漏。 讓我們找到它。
-
1、獲取兩個快照
要查找泄漏,我們現(xiàn)在將轉(zhuǎn)到Chrome的開發(fā)工具的profiles部分。要將內(nèi)存使用限制在可管理的級別,請在執(zhí)行此步驟之前重新加載頁面。我們將使用Take Heap Snapshot函數(shù)。
重新加載頁面,并在完成加載后立即獲取堆快照。 我們將使用此快照作為我們的基線。之后,再次點擊最左邊的Profiles按鈕,等待幾秒鐘,并采取第二個快照。捕獲快照后,建議在腳本中設(shè)置斷點,以防止泄漏使用更多內(nèi)存。
Paste_Image.png
有兩種方法可以查看兩個快照之間的分配。 選擇summary(摘要),右側(cè)選擇 Objects allocated between Snapshot 1 and Snapshot 2,或者篩選菜單選擇 Comparison。在這兩種情況下,我們將看到在兩個快照之間分配的對象的列表。
在這種情況下,很容易找到泄漏:他們很大。看看 (string) 的 Size Delta Constructor,8MB,58個新對象。 這看起來很可疑:新對象被分配,但是沒有釋放,占用了8MB。
如果我們打開 (string) Constructor的分配列表,我們將注意到在許多小的分配之間有一些大的分配。大者立即引起我們的注意。如果我們選擇其中的任何一個,我們可以在下面的retainers部分得到一些有趣的東西。
Paste_Image.png
我們看到我們選擇的分配是數(shù)組的一部分。反過來,數(shù)組由全局窗口對象內(nèi)的變量x引用。這給了我們從我們的大對象到其不可收回的根(窗口)的完整路徑 我們發(fā)現(xiàn)我們的潛在泄漏和被引用的地方。
到現(xiàn)在為止還挺好。但我們的例子很容易:大分配,例如在這個例子中的分配不是常態(tài)。幸運的是,我們的例子也泄漏了DOM節(jié)點,它們更小。使用上面的快照很容易找到這些節(jié)點,但在更大的網(wǎng)站,會變得更麻煩。 最新版本的Chrome提供了一個最適合我們工作的附加工具:記錄堆分配功能。 -
2、Record heap allocations查找泄漏
禁用之前設(shè)置的斷點,讓腳本繼續(xù)運行,然后返回Chrome的開發(fā)工具的“個人檔案”部分。現(xiàn)在點擊Record Heap Allocations。當(dāng)工具運行時,您會注意到在頂部的圖中的藍(lán)色尖峰。這些代表分配。每秒大的分配由我們的代碼執(zhí)行。讓它運行幾秒鐘,然后停止它(不要忘記再次設(shè)置斷點,以防止Chrome吃更多的內(nèi)存)。
Paste_Image.png
在此圖像中,您可以看到此工具的殺手锏:選擇一段時間線以查看在該時間段內(nèi)執(zhí)行的分配。我們將選擇設(shè)置為盡可能接近一個大峰值。列表中只顯示了三個構(gòu)造函數(shù):其中一個是與我們的大漏洞((string))相關(guān)的構(gòu)造函數(shù),下一個與DOM分配相關(guān),最后一個是Text構(gòu)造函數(shù)(葉子DOM節(jié)點的構(gòu)造函數(shù) 包含文本)。
從列表中選擇一個 HTMLDivElement constructor,然后選擇Allocation stack。
我們現(xiàn)在知道分配該元素的位置(grow - > createSomeNodes)。如果我們密切注意圖中的每個尖峰,我們將注意到 HTMLDivElement constructor被調(diào)用了許多次。如果我們回到我們的快照比較視圖,我們將注意到這個constructor顯示許多分配,但沒有刪除。 換句話說,它正在穩(wěn)定地分配內(nèi)存,而沒有被GC回收。從而我們知道這些對象被分配的確切位置(createSomeNodes函數(shù))。現(xiàn)在回到代碼,研究它,并修復(fù)漏洞。
-
3、另一個有用的功能
在堆分配結(jié)果視圖中,我們可以選擇Allocation視圖。
Paste_Image.png
這個視圖給了一個與它們相關(guān)的函數(shù)和內(nèi)存分配的列表。我們可以立即看到grow和createSomeNodes。當(dāng)選擇grow時,看看相關(guān)的object constructor。 可以注意到(string),HTMLDivElement和Text泄露了。
這些工具的組合可以大大有助于發(fā)現(xiàn)內(nèi)存泄漏。在生產(chǎn)站點中執(zhí)行不同的分析運行(理想情況下使用非最小化或模糊代碼)。看看你是否能找到比他們應(yīng)該保留更多的泄漏或?qū)ο螅ㄌ崾荆哼@些更難找到)。
要使用此功能,請轉(zhuǎn)到Dev Tools - >設(shè)置并啟用“記錄堆分配堆棧跟蹤”。 在拍攝之前必須這樣做。
8、請深入閱讀
- 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
9、總結(jié)
內(nèi)存泄漏可以并且確實發(fā)生在垃圾回收語言,如JavaScript。這些可以被忽視一段時間,最終他們將肆虐你的網(wǎng)站。因此,內(nèi)存分析工具對于查找內(nèi)存泄漏至關(guān)重要。分析運行應(yīng)該是開發(fā)周期的一部分,特別是對于中型或大型應(yīng)用程序。開始這樣做,為您的用戶提供最好的體驗。
參考原文:https://auth0.com/blog/four-types-of-leaks-in-your-javascript-code-and-how-to-get-rid-of-them/