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