在本文中娩梨,我們將探討客戶端JavaScript代碼中常見的內存泄漏類型铣鹏。 我們還將學習如何使用Chrome開發(fā)工具找到它們透典。
1滤奈、介紹
內存泄漏是每個開發(fā)人員都要面臨的問題摆昧。 即使使用內存管理的語言,也存在內存泄漏的情況僵刮。 內存泄漏是導致遲緩据忘,崩潰鹦牛,高延遲的根本原因,甚至會導致其他應用問題勇吊。
2曼追、什么是內存泄露
實質上,內存泄漏可以定義為應用程序不再需要的內存汉规,因為某種原因其不會返回到操作系統(tǒng)或可用內存池礼殊。編程語言有不同的管理內存的方式。這些方法可以減少泄漏內存的機會针史。然而晶伦,某一塊內存是否未被使用實際上是一個不可判定的問題。 換句話說啄枕,只有開發(fā)人員才能明確是否可以將一塊內存返回到操作系統(tǒng)婚陪。 某些編程語言提供了幫助開發(fā)人員執(zhí)行此操作的功能。
3频祝、JavaScript的內存管理
JavaScript是垃圾回收語言之一泌参。 垃圾回收語言通過定期檢查哪些先前分配的內存是否“可達”來幫助開發(fā)人員管理內存。 換句話說常空,垃圾回收語言將管理內存的問題從“什么內存仍可用沽一? 到“什么內存仍可達?”漓糙。區(qū)別是微妙的铣缠,但重要的是:雖然只有開發(fā)人員知道將來是否需要一塊分配的內存,但是不可達的內存可以通過算法確定并標記為返回到操作系統(tǒng)昆禽。
非垃圾回收的語言通常使用其他技術來管理內存:顯式管理蝗蛙,開發(fā)人員明確告訴編譯器何時不需要一塊內存; 和引用計數(shù),其中使用計數(shù)與存儲器的每個塊相關聯(lián)(當計數(shù)達到零時为狸,其被返回到OS)歼郭。
4遗契、JavaScript的內存泄露
垃圾回收語言泄漏的主要原因是不需要的引用辐棒。要理解什么不需要的引用,首先我們需要了解垃圾回收器如何確定一塊內存是否“可達”牍蜂。
垃圾回收語言泄漏的主要原因是不需要的引用漾根。
Mark-and-sweep
大多數(shù)垃圾回收器使用稱為標記和掃描的算法。該算法由以下步驟組成:
- 1鲫竞、垃圾回收器構建一個“根”列表辐怕。根通常是在代碼中保存引用的全局變量。在JavaScript中从绘,“window”對象是可以充當根的全局變量的示例寄疏。窗口對象總是存在的是牢,所以垃圾回收器可以考慮它和它的所有的孩子總是存在(即不是垃圾)。
- 2陕截、所有根被檢查并標記為活動(即不是垃圾)驳棱。所有孩子也被遞歸檢查。從根可以到達的一切都不被認為是垃圾农曲。
- 3社搅、所有未標記為活動的內存塊現(xiàn)在可以被認為是垃圾∪楣妫回收器現(xiàn)在可以釋放該內存并將其返回到操作系統(tǒng)形葬。
現(xiàn)代垃圾回收器以不同的方式改進了該算法,但本質是相同的:可訪問的內存段被標記暮的,其余被垃圾回收笙以。不需要的引用是開發(fā)者知道它不再需要,但由于某種原因冻辩,保存在活動根的樹內部的內存段的引用源织。 在JavaScript的上下文中,不需要的引用是保存在代碼中某處的變量微猖,它不再被使用谈息,并指向可以被釋放的一塊內存。 有些人會認為這些都是開發(fā)者的錯誤凛剥。所以要了解哪些是JavaScript中最常見的漏洞侠仇,我們需要知道在哪些方式引用通常被忽略。
5犁珠、四種常見的JavaScript 內存泄漏
-
1逻炊、意外的全局變量
JavaScript背后的目標之一是開發(fā)一種看起來像Java的語言,容易被初學者使用犁享。 JavaScript允許的方式之一是處理未聲明的變量:對未聲明的變量的引用在全局對象內創(chuàng)建一個新的變量余素。 在瀏覽器的情況下,全局對象是窗口炊昆。 換一種說法:
function foo(arg) {
bar = "this is a hidden global variable";
}
事實上:
function foo(arg) {
window.bar = "this is an explicit global variable";
}
如果bar應該只在foo函數(shù)的范圍內保存對變量的引用桨吊,并且您忘記使用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文件的開頭还绘。 這使得能夠更嚴格地解析JavaScript以防止意外的全局變量捐晶。
即使我們討論了不可預測的全局變量瓷叫,但是仍有一些明確的全局變量產生的垃圾樟插。這些是根據(jù)定義不可回收的(除非被取消或重新分配)。特別地捧颅,用于臨時存儲和處理大量信息的全局變量是令人關注的棺榔。 如果必須使用全局變量來存儲大量數(shù)據(jù),請確保將其置空或在完成后重新分配它隘道。與全局變量有關的增加的內存消耗的一個常見原因是高速緩存)症歇。緩存存儲重復使用的數(shù)據(jù)。 為了有效率谭梗,高速緩存必須具有其大小的上限忘晤。 無限增長的緩存可能會導致高內存消耗,因為緩存內容無法被回收激捏。
-
2设塔、被遺忘的計時器或回調函數(shù)
setInterval的使用在JavaScript中是很常見的。大多數(shù)這些庫在它們自己的實例變得不可達之后远舅,使得對回調的任何引用不可達闰蛔。在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ū)間處理器內部的整個塊不需要了蚤吹。 但是例诀,處理程序(因為時間間隔仍處于活動狀態(tài))無法回收(需要停止時間間隔才能發(fā)生)。 如果無法回收間隔處理程序裁着,則也無法回收其依賴項繁涂。 這意味著someResource,它可能存儲大小的數(shù)據(jù)二驰,也不能被回收扔罪。
對于觀察者的情況,重要的是進行顯式調用桶雀,以便在不再需要它們時刪除它們(或者相關對象即將無法訪問)矿酵。 在過去,以前特別重要背犯,因為某些瀏覽器(Internet Explorer 6)不能管理循環(huán)引用(參見下面的更多信息)坏瘩。 現(xiàn)在盅抚,一旦觀察到的對象變得不可達漠魏,即使沒有明確刪除監(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.
關于對象觀察者和循環(huán)引用:
觀察者和循環(huán)引用曾經是JavaScript開發(fā)者的禍根。 這是由于Internet Explorer的垃圾回收器中的錯誤(或設計決策)禁熏。舊版本的Internet Explorer無法檢測DOM節(jié)點和JavaScript代碼之間的循環(huán)引用壤巷。這是一個典型的觀察者,通常保持對可觀察者的引用(如上例所示)瞧毙。換句話說胧华,每當觀察者被添加到Internet Explorer中的一個節(jié)點時,它就會導致泄漏宙彪。這是開發(fā)人員在節(jié)點或在觀察者中引用之前明確刪除處理程序的原因矩动。 現(xiàn)在,現(xiàn)代瀏覽器(包括Internet Explorer和Microsoft Edge)使用現(xiàn)代垃圾回收算法释漆,可以檢測這些周期并正確處理它們悲没。 換句話說,在使節(jié)點不可達之前男图,不必嚴格地調用removeEventListener示姿。框架和庫(jQuery)在處理節(jié)點之前刪除偵聽器(當為其使用特定的API時)逊笆。這是由庫內部處理栈戳,并確保不產生泄漏,即使運行在有問題的瀏覽器难裆,如舊的Internet Explorer荧琼。
-
3、脫離 DOM 的引用
有時差牛,將DOM節(jié)點存儲在數(shù)據(jù)結構中可能很有用命锄。 假設要快速更新表中多行的內容。 在字典或數(shù)組中存儲對每個DOM行的引用可能是有意義的偏化。 當發(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樹內的內部或葉節(jié)點的引用有關骗污。 假設您在JavaScript代碼中保留對表的特定單元格(<td>標記)的引用。 在將來的某個時候沈条,您決定從DOM中刪除表需忿,但保留對該單元格的引用。 直觀地,可以假設GC將回收除了該單元之外的所有東西屋厘。 在實踐中涕烧,這不會發(fā)生:單元格是該表的子節(jié)點,并且子級保持對其父級的引用汗洒。 換句話說议纯,從JavaScript代碼對表單元格的引用導致整個表保留在內存中。 在保持對DOM元素的引用時仔細考慮這一點溢谤。
-
4瞻凤、閉包
JavaScript開發(fā)的一個關鍵方面是閉包:從父作用域捕獲變量的匿名函數(shù)。 Meteor開發(fā)人員發(fā)現(xiàn)了一個特定的情況世杀,由于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);
這個片段做了一件事:每次replaceThing被調用,theThing獲取一個新的對象玫坛,其中包含一個大數(shù)組和一個新的閉包(someMethod)结笨。同時,unused變量保持一個閉包湿镀,該閉包具有對originalThing的引用(來自之前對replaceThing的調用的Thing)炕吸。已經有點混亂了,是嗎勉痴?重要的是赫模,一旦為同一父作用域中的閉包創(chuàng)建了作用域,則該作用域是共享的蒸矛。在這種情況下瀑罗,為閉包someMethod創(chuàng)建的作用域由unused共享。unused的引用了originalThing雏掠。即使unused未使用斩祭,可以通過theThing使用someMethod。由于someMethod與unused共享閉包范圍乡话,即使未使用摧玫,它對originalThing的引用強制它保持活動(防止其收集)。當此代碼段重復運行時绑青,可以觀察到內存使用量的穩(wěn)定增加诬像。這在GC運行時不會變小。實質上闸婴,創(chuàng)建一個閉包的鏈接列表(其根以theThing變量的形式)坏挠,并且這些閉包的范圍中的每一個都包含對大數(shù)組的間接引用,導致相當大的泄漏邪乍。
Meteor的博文解釋了如何修復此種問題降狠。在replaceThing的最后添加originalThing = null对竣。
垃圾回收器的不直觀行為:
雖然垃圾回收器很方便,但他們有自己的一套權衡喊熟。 這些權衡之一是非確定性柏肪。 換句話說姐刁,GC是不可預測的芥牌。 通常不可能確定何時執(zhí)行回收。 這意味著在某些情況下聂使,正在使用比程序實際需要的更多的內存壁拉。 在其他情況下,短暫停頓在特別敏感的應用中可能是明顯的柏靶。 雖然非確定性意味著無法確定何時執(zhí)行集合弃理,但大多數(shù)GC實現(xiàn)都分享在分配期間執(zhí)行集合傳遞的常見模式。 如果沒有執(zhí)行分配屎蜓,則大多數(shù)GC保持靜止痘昌。 考慮以下情況:
- 1、執(zhí)行相當大的一組分配炬转。
- 2辆苔、大多數(shù)這些元素(或所有這些元素)被標記為不可達(假設我們使指向我們不再需要的緩存的引用為空)。
- 3扼劈、不執(zhí)行進一步的分配驻啤。
在這種情況下,大多數(shù)GC不會運行任何進一步的集合過程荐吵。 換句話說骑冗,即使有不可達的引用可用于回收,回收器也不會回收這些引用先煎。 這些不是嚴格的泄漏贼涩,但仍然導致高于通常的內存使用。
Google在他們的JavaScript內存分析文檔中提供了這種行為的一個很好的例子薯蝎,next!!!磁携。
6、Chrome內存分析工具概述
Chrome提供了一組很好的工具來分析JavaScript代碼的內存使用情況良风。 有兩個與內存相關的基本視圖:時間軸視圖和配置文件視圖谊迄。
- 1、TimeLine
TimeLine對于在代碼中發(fā)現(xiàn)異常內存模式至關重要烟央。 如果我們正在尋找大的泄漏统诺,周期性的跳躍,收縮后不會收縮疑俭,就像一個紅旗粮呢。 在這個截圖中,我們可以看到泄漏對象的穩(wěn)定增長可能是什么樣子。 即使在大收集結束后啄寡,使用的內存總量高于開始時豪硅。 節(jié)點計數(shù)也較高。 這些都是代碼中某處泄露的DOM節(jié)點的跡象挺物。
- 2懒浮、Profiles
這是你將花費大部分時間看的視圖。 Profiles允許您獲取快照并比較JavaScript代碼的內存使用快照识藤。 它還允許您記錄分配的時間砚著。 在每個結果視圖中,不同類型的列表都可用痴昧,但是對于我們的任務最相關的是summary(概要)列表和comparison(對照)列表稽穆。
summary(概要)列表為我們概述了分配的不同類型的對象及其聚合大小:淺大懈献(特定類型的所有對象的總和)和保留大猩嘞狻(淺大小加上由于此對象保留的其他對象的大小 )。 它還給了我們一個對象相對于它的GC根(距離)有多遠的概念豪娜。
comparison(對照)給了我們相同的信息餐胀,但允許我們比較不同的快照。 這對于查找泄漏是非常有用的侵歇。
7骂澄、示例:使用Chrome查找泄漏
基本上有兩種類型的泄漏:1、泄漏引起內存使用的周期性增加惕虑。2坟冲、一次發(fā)生的泄漏,并且不會進一步增加內存溃蔫。
由于明顯的原因健提,當它們是周期性的時更容易發(fā)現(xiàn)泄漏。這些也是最麻煩的:如果內存在時間上增加伟叛,這種類型的泄漏將最終導致瀏覽器變慢或停止腳本的執(zhí)行私痹。不是周期性的泄漏可以很容易地發(fā)現(xiàn)。這通常會被忽視统刮。在某種程度上紊遵,發(fā)生一次的小泄漏可以被認為是優(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);
}
當調用grow時学搜,它將開始創(chuàng)建div節(jié)點并將它們附加到DOM娃善。它還將分配一個大數(shù)組,并將其附加到全局變量引用的數(shù)組瑞佩。這將導致使用上述工具可以找到的內存的穩(wěn)定增加聚磺。
了解內存是否周期性增加
Timeline非常有用。 在Chrome中打開示例炬丸,打開開發(fā)工具瘫寝,轉到Timeline,選擇Memory御雕,然后點擊錄制按鈕矢沿。 然后轉到頁面并單擊按鈕開始泄漏內存滥搭。 一段時間后停止錄制酸纲,看看結果:
此示例將繼續(xù)每秒泄漏內存。停止錄制后瑟匆,在grow函數(shù)中設置斷點闽坡,以停止腳本強制Chrome關閉頁面。在這個圖像有兩個大的跡象愁溜,表明我們正在記錄泄漏疾嗅。節(jié)點(綠線)和JS堆(藍線)的圖。節(jié)點正在穩(wěn)步增加冕象,從不減少代承。這是一個大的警告標志。
JS堆也顯示內存使用的穩(wěn)定增長渐扮。這是很難看到由于垃圾回收器的影響论悴。您可以看到初始內存增長的模式,隨后是大幅下降墓律,隨后是增加膀估,然后是尖峰,繼續(xù)記憶的另一下降耻讽。 在這種情況下的關鍵在于事實察纯,在每次內存使用后,堆的大小保持大于上一次下降针肥。 換句話說饼记,雖然垃圾收集器正在成功地收集大量的存儲器,但是它還是周期性地泄漏了慰枕。
現(xiàn)在確定有泄漏具则。 讓我們找到它。
-
1捺僻、獲取兩個快照
要查找泄漏乡洼,我們現(xiàn)在將轉到Chrome的開發(fā)工具的profiles部分崇裁。要將內存使用限制在可管理的級別,請在執(zhí)行此步驟之前重新加載頁面束昵。我們將使用Take Heap Snapshot函數(shù)拔稳。
重新加載頁面,并在完成加載后立即獲取堆快照锹雏。 我們將使用此快照作為我們的基線巴比。之后,再次點擊最左邊的Profiles按鈕礁遵,等待幾秒鐘轻绞,并采取第二個快照。捕獲快照后佣耐,建議在腳本中設置斷點政勃,以防止泄漏使用更多內存。
有兩種方法可以查看兩個快照之間的分配兼砖。 選擇summary(摘要)奸远,右側選擇 Objects allocated between Snapshot 1 and Snapshot 2,或者篩選菜單選擇 Comparison讽挟。在這兩種情況下懒叛,我們將看到在兩個快照之間分配的對象的列表。
在這種情況下耽梅,很容易找到泄漏:他們很大薛窥。看看 (string) 的 Size Delta Constructor眼姐,8MB诅迷,58個新對象。 這看起來很可疑:新對象被分配妥凳,但是沒有釋放竟贯,占用了8MB。
如果我們打開 (string) Constructor的分配列表逝钥,我們將注意到在許多小的分配之間有一些大的分配屑那。大者立即引起我們的注意。如果我們選擇其中的任何一個艘款,我們可以在下面的retainers部分得到一些有趣的東西持际。
我們看到我們選擇的分配是數(shù)組的一部分。反過來哗咆,數(shù)組由全局窗口對象內的變量x引用蜘欲。這給了我們從我們的大對象到其不可收回的根(窗口)的完整路徑 我們發(fā)現(xiàn)我們的潛在泄漏和被引用的地方。
到現(xiàn)在為止還挺好晌柬。但我們的例子很容易:大分配姥份,例如在這個例子中的分配不是常態(tài)郭脂。幸運的是,我們的例子也泄漏了DOM節(jié)點澈歉,它們更小展鸡。使用上面的快照很容易找到這些節(jié)點,但在更大的網站埃难,會變得更麻煩莹弊。 最新版本的Chrome提供了一個最適合我們工作的附加工具:記錄堆分配功能。 -
2涡尘、Record heap allocations查找泄漏
禁用之前設置的斷點忍弛,讓腳本繼續(xù)運行,然后返回Chrome的開發(fā)工具的“個人檔案”部分】汲現(xiàn)在點擊Record Heap Allocations细疚。當工具運行時,您會注意到在頂部的圖中的藍色尖峰座泳。這些代表分配惠昔。每秒大的分配由我們的代碼執(zhí)行幕与。讓它運行幾秒鐘挑势,然后停止它(不要忘記再次設置斷點,以防止Chrome吃更多的內存)啦鸣。
在此圖像中潮饱,您可以看到此工具的殺手锏:選擇一段時間線以查看在該時間段內執(zhí)行的分配。我們將選擇設置為盡可能接近一個大峰值诫给。列表中只顯示了三個構造函數(shù):其中一個是與我們的大漏洞((string))相關的構造函數(shù)香拉,下一個與DOM分配相關,最后一個是Text構造函數(shù)(葉子DOM節(jié)點的構造函數(shù) 包含文本)中狂。
從列表中選擇一個 HTMLDivElement constructor凫碌,然后選擇Allocation stack。
我們現(xiàn)在知道分配該元素的位置(grow - > createSomeNodes)胃榕。如果我們密切注意圖中的每個尖峰盛险,我們將注意到 HTMLDivElement constructor被調用了許多次。如果我們回到我們的快照比較視圖勋又,我們將注意到這個constructor顯示許多分配苦掘,但沒有刪除。 換句話說楔壤,它正在穩(wěn)定地分配內存鹤啡,而沒有被GC回收。從而我們知道這些對象被分配的確切位置(createSomeNodes函數(shù))《紫現(xiàn)在回到代碼递瑰,研究它祟牲,并修復漏洞。
-
3抖部、另一個有用的功能
在堆分配結果視圖中疲眷,我們可以選擇Allocation視圖。
這個視圖給了一個與它們相關的函數(shù)和內存分配的列表您朽。我們可以立即看到grow和createSomeNodes狂丝。當選擇grow時,看看相關的object constructor哗总。 可以注意到(string)几颜,HTMLDivElement和Text泄露了。
這些工具的組合可以大大有助于發(fā)現(xiàn)內存泄漏讯屈。在生產站點中執(zhí)行不同的分析運行(理想情況下使用非最小化或模糊代碼)蛋哭。看看你是否能找到比他們應該保留更多的泄漏或對象(提示:這些更難找到)涮母。
要使用此功能谆趾,請轉到Dev Tools - >設置并啟用“記錄堆分配堆棧跟蹤”。 在拍攝之前必須這樣做叛本。
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、總結
內存泄漏可以并且確實發(fā)生在垃圾回收語言来候,如JavaScript跷叉。這些可以被忽視一段時間,最終他們將肆虐你的網站营搅。因此云挟,內存分析工具對于查找內存泄漏至關重要。分析運行應該是開發(fā)周期的一部分转质,特別是對于中型或大型應用程序园欣。開始這樣做,為您的用戶提供最好的體驗休蟹。
參考原文:https://auth0.com/blog/four-types-of-leaks-in-your-javascript-code-and-how-to-get-rid-of-them/