在本文中,我們將探討客戶端JavaScript代碼中常見的內(nèi)存泄漏類型。我們還將學(xué)習(xí)如何使用Chrome開發(fā)工具找到它們。繼續(xù)閱讀!
介紹
內(nèi)存泄漏是每個開發(fā)人員最終都必須面對的問題。即使使用內(nèi)存管理的語言,在某些情況下也會泄漏內(nèi)存。泄漏是整個問題的根源:減速,崩潰,高延遲,甚至其他應(yīng)用程序也有問題。
什么是內(nèi)存泄漏?
本質(zhì)上,內(nèi)存泄漏可以定義為由于某種原因未返回到操作系統(tǒng)或空閑內(nèi)存池的應(yīng)用程序不再需要的內(nèi)存。編程語言支持不同的內(nèi)存管理方式。這些方法可以減少內(nèi)存泄漏的機會。但是,某個內(nèi)存是否未使用實際上是無法確定的問題。換句話說,只有開發(fā)人員才能弄清楚是否可以將一塊內(nèi)存返回給操作系統(tǒng)。某些編程語言提供的功能可幫助開發(fā)人員執(zhí)行此操作。其他人則希望開發(fā)人員完全清楚何時未使用內(nèi)存。維基百科上有很多有關(guān)手動和自動內(nèi)存管理的文章。
JavaScript中的內(nèi)存管理
JavaScript是所謂的垃圾收集語言之一。垃圾收集的語言通過定期檢查仍可以從應(yīng)用程序的其他部分“訪問”哪些先前分配的內(nèi)存,來幫助開發(fā)人員管理內(nèi)存。換句話說,垃圾收集語言減少了“仍然需要什么內(nèi)存”管理內(nèi)存的問題。“仍然可以從應(yīng)用程序的其他部分訪問什么內(nèi)存?”。差異是細(xì)微的,但很重要:雖然只有開發(fā)人員才能知道將來是否需要分配的內(nèi)存,但是可以通過算法確定無法訪問的內(nèi)存并將其標(biāo)記為返回操作系統(tǒng)。
非垃圾收集語言通常采用其他技術(shù)來管理內(nèi)存:顯式管理,其中開發(fā)人員在不需要內(nèi)存的情況下顯式告知編譯器;和引用計數(shù),其中使用計數(shù)與每個內(nèi)存塊相關(guān)聯(lián)(當(dāng)計數(shù)達(dá)到零時,它將返回到OS)。這些技術(shù)都有其自身的權(quán)衡(以及潛在的泄漏原因)。
JavaScript泄漏
垃圾收集語言泄漏的主要原因是不需要的引用。要了解什么是不需要的引用,首先我們需要了解垃圾回收器如何確定是否可以訪問內(nèi)存。
標(biāo)記并清掃
大多數(shù)垃圾收集器使用稱為標(biāo)記清除的算法。該算法包括以下步驟:
- 垃圾收集器構(gòu)建“根”列表。根通常是在代碼中保留引用的全局變量。在JavaScript中,“窗口”對象是可以充當(dāng)根的全局變量的示例。窗口對象始終存在,因此垃圾收集器可以認(rèn)為它及其所有子對象始終存在(即,不是垃圾)。
- 檢查所有根并將其標(biāo)記為活動(即不是垃圾)。還對所有兒童進行遞歸檢查。從根可以訪問的所有內(nèi)容均不視為垃圾。
- 現(xiàn)在可以將所有未標(biāo)記為活動的內(nèi)存視為垃圾。收集器現(xiàn)在可以釋放該內(nèi)存并將其返回給OS。
現(xiàn)代垃圾收集器以不同的方式改進了該算法,但是本質(zhì)是相同的:可訪問的內(nèi)存被標(biāo)記為此類,其余的被視為垃圾。
不需要的引用是對開發(fā)人員知道他或她不再需要的內(nèi)存片段的引用,但是由于某種原因它們被保留在活動根目錄樹中。在JavaScript的上下文中,不需要的引用是保留在代碼中某個位置的變量,這些變量將不再使用,并指向原本可以釋放的內(nèi)存。有人會說這些是開發(fā)人員的錯誤。
因此,要了解JavaScript中最常見的泄漏,我們需要知道通常忘記引用的方式。
常見的JavaScript泄漏的三種類型
1:偶然的全局變量
JavaScript的目標(biāo)之一是開發(fā)一種看起來像Java的語言,但足以讓初學(xué)者使用。JavaScript允許的一種方式是處理未聲明變量的方式:對未聲明變量的引用會在全局對象內(nèi)創(chuàng)建一個新變量。對于瀏覽器,全局對象為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)建意外全局變量的另一種方法是this:
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解析模式,以防止意外的全局變量。
關(guān)于全局變量的注釋
即使我們談?wù)摿瞬皇軕岩傻娜肿兞?,但仍然有很多代碼被顯式全局變量所困擾。根據(jù)定義,這些是不可收集的(除非為null或重新分配)。特別是,用于臨時存儲和處理大量信息的全局變量值得關(guān)注。如果必須使用全局變量來存儲大量數(shù)據(jù),請確保在處理完數(shù)據(jù)后將其清空或重新分配。與全局變量相關(guān)的內(nèi)存消耗增加的一個常見原因是[cache(](https://en.wikipedia.org/wiki/Cache_(computing))。緩存存儲重復(fù)使用的數(shù)據(jù)。為使此有效,高速緩存必須為其大小設(shè)置上限。無限增長的高速緩存會導(dǎo)致高內(nèi)存消耗,因為無法收集其內(nèi)容。
2:忘記了計時器或回調(diào)
setInterval在JavaScript中,的使用非常普遍。其他庫提供觀察者和接受回調(diào)的其他工具。這些庫中的大多數(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);
此示例說明了懸掛計時器可能發(fā)生的情況:懸掛計時器引用不再需要的節(jié)點或數(shù)據(jù)。node將來可能會刪除由表示的對象,從而使間隔處理程序內(nèi)的整個塊不再需要。但是,由于間隔仍處于活動狀態(tài),因此無法收集處理程序(需要停止間隔才能發(fā)生)。如果無法收集間隔處理程序,則也不能收集其依賴項。這意味著someResource大概存儲了大量數(shù)據(jù)的,也無法收集。
對于觀察者而言,一旦不再需要它們(或使關(guān)聯(lián)的對象將變得不可訪問)時,進行顯式調(diào)用以將其刪除很重要。在過去,這尤其重要,因為某些瀏覽器(Internet Explorer 6)無法很好地管理循環(huán)引用(有關(guā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)引用。這是觀察者的典型情況,通常會引用可觀察對象(如上例所示)。換句話說,每次將觀察者添加到Internet Explorer中的節(jié)點時,都會導(dǎo)致泄漏。這就是開發(fā)人員開始在節(jié)點之前或在觀察者內(nèi)部使引用為空之前顯式刪除處理程序的原因。如今,現(xiàn)代的瀏覽器(包括Internet Explorer和Microsoft Edge)使用現(xiàn)代的垃圾回收算法,可以檢測這些周期并正確處理它們。換句話說,不必嚴(yán)格要求removeEventListener 在使節(jié)點不可訪問之前。
諸如jQuery之類的框架和庫在處置節(jié)點之前(當(dāng)為此使用它們的特定API時)確實刪除了偵聽器。這是由庫內(nèi)部處理的,并確保即使在有問題的瀏覽器(例如舊的Internet Explorer)下運行時也不會泄漏
3: 超出DOM引用
有時將DOM節(jié)點存儲在數(shù)據(jù)結(jié)構(gòu)中可能很有用。假設(shè)您要快速更新表中幾行的內(nèi)容。將對每個DOM行的引用存儲在字典或數(shù)組中可能是有意義的。發(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è)您<td>在JavaScript代碼中保留了對表(標(biāo)簽)的特定單元格的引用。在將來的某個時候,您決定從DOM中刪除表,但保留對該單元的引用。憑直覺,人們可能會認(rèn)為GC將收集除那個細(xì)胞以外的所有東西。實際上,這不會發(fā)生:單元格是該表的子節(jié)點,子代保留對其父代的引用。換句話說,JavaScript代碼對表格單元的引用導(dǎo)致整個表格保留在內(nèi)存中。保留對DOM元素的引用時,請仔細(xì)考慮這一點。
4:閉包
JavaScript開發(fā)的一個關(guān)鍵方面是閉包:閉包:從父作用域捕獲變量的匿名函數(shù)。流星開發(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(theThing從上一次調(diào)用到replaceThing)。已經(jīng)有些混亂了,是吧?重要的是,一旦為同一父作用域中的閉包創(chuàng)建了作用域,就將共享該作用域。在這種情況下,為閉包創(chuàng)建的作用域由someMethod共享unused。unused有參考o(jì)riginalThing。即使unused從未使用過,someMethod也可以通過使用theThing。并且由于與someMethod共享閉包范圍unused,即使unused從未使用過,其對的引用originalThing強制其保持活動狀態(tài)(阻止其收集)。當(dāng)此代碼段重復(fù)運行時,可以觀察
垃圾收集器的不直觀行為
盡管垃圾收集器很方便,但是它們還是有自己的權(quán)衡。這些折衷之一是不確定性。換句話說,GC是不可預(yù)測的。通常無法確定何時進行收集。這意味著在某些情況下,正在使用的內(nèi)存超過了程序?qū)嶋H需要的內(nèi)存。在其他情況下,短暫??赡茉谔貏e敏感的應(yīng)用中很明顯。盡管不確定性意味著無法確定何時執(zhí)行收集,但是大多數(shù)GC實現(xiàn)在分配過程中共享執(zhí)行收集遍歷的通用模式。如果不執(zhí)行分配,則大多數(shù)GC保持靜止。請考慮以下情形:
- 執(zhí)行大量分配。
- 這些元素中的大多數(shù)(或所有元素)都標(biāo)記為不可訪問(假設(shè)我們將指向不再需要的緩存的引用無效)。
- 不再執(zhí)行任何分配。
在這種情況下,大多數(shù)GC將不再運行任何進一步的收集過程。換句話說,即使有不可達(dá)的引用可用于收集,收集器也不會主張這些引用。這些并不是嚴(yán)格的泄漏,但仍會導(dǎo)致內(nèi)存使用率高于正常水平。
Chrome內(nèi)存分析工具概述
Chrome提供了一組不錯的工具來分析JavaScript代碼的內(nèi)存使用情況。有兩個與內(nèi)存相關(guān)的基本視圖:時間軸視圖和配置文件視圖。

時間線視圖對于發(fā)現(xiàn)代碼中的異常內(nèi)存模式至關(guān)重要。萬一我們正在尋找大的泄漏,周期性跳躍的收縮幅度不如收集后增長的幅度大。在此屏幕截圖中,我們可以看到泄漏對象穩(wěn)定增長的樣子。即使在結(jié)束大集合之后,使用的內(nèi)存總量也比開始時高。節(jié)點數(shù)也更高。這些都是代碼中DOM節(jié)點泄漏的跡象。

這是您將大部分時間用于查看的視圖。使用配置文件視圖,您可以獲取快照并比較JavaScript代碼的內(nèi)存使用情況快照。它還允許您記錄時間的分配。在每個結(jié)果視圖中,都可以使用不同類型的列表,但是與我們的任務(wù)最相關(guān)的是摘要列表和比較列表。
摘要視圖為我們概述了分配的不同對象類型及其聚集大?。簻\大?。ㄌ囟愋偷乃袑ο蟮目偤停┖捅A舸笮。\大小加上由于該對象而保留的其他對象的大?。?)。它還為我們提供了一個對象相對于其GC根(距離)的距離的概念。
比較列表為我們提供了相同的信息,但允許我們比較不同的快照。這對于發(fā)現(xiàn)泄漏特別有用。
參考
4 Types of Memory Leaks in JavaScript and How to Get Rid Of Them