JavaScript 中 4 種常見的記憶體洩露陷阱
瞭解 JavaScript 的記憶體洩露和解決方式!
在這篇文章中我們將要探索客戶端 JavaScript 程式碼中常見的一些記憶體洩漏的情況,並且學習如何使用 Chrome 的開發工具來發現他們。讀一讀吧!
介紹
記憶體洩露是每個開發者最終都不得不面對的問題。即便使用自動記憶體管理的語言,你還是會碰到一些記憶體洩漏的情況。記憶體洩露會導致一系列問題,比如:執行緩慢,崩潰,高延遲,甚至一些與其他應用相關的問題。
什麼是記憶體洩漏
本質上來講,記憶體洩露是當一塊記憶體不再被應用程式使用的時候,由於某種原因,這塊記憶體沒有返還給作業系統或者空閒記憶體池的現象。程式語言使用不同的方式來管理記憶體。這些方式可能會減少記憶體洩露的機會。然而,某一塊具體的記憶體是否被使用實際上是一個不可判定問題(undecidable problem)。換句話說,只有開發者可以搞清楚一塊記憶體是否應該被作業系統回收。某些程式語言提供了幫助開發者來處理這件事情的特性。而其它的程式語言需要開發者明確知道記憶體的使用情況。維基百科上有幾篇寫的不錯的講述手動 和自動記憶體管理的文章。
Javascript 的記憶體管理
Javascript 是那些被稱作垃圾回收語言當中的一員。垃圾回收語言通過週期性地檢查那些之前被分配出去的記憶體是否可以從應用的其他部分訪問來幫助開發者管理記憶體。換句話說,垃圾回收語言將記憶體管理的問題從“什麼樣的記憶體是仍然被使用的?”簡化成為“什麼樣的記憶體仍然可以從應用程式的其他部分訪問?”。兩者的區別是細微的,但是很重要:開發者只需要知道一塊已分配的記憶體是否會在將來被使用,而不可訪問的記憶體可以通過演算法確定並標記以便返還給作業系統。
非垃圾回收語言通常使用其他的技術來管理記憶體,包括:顯式記憶體管理,程式設計師顯式地告訴編譯器在何時不再需要某塊記憶體;引用計數,一個計數器關聯著每個記憶體塊(當計數器的計數變為0的時候,這塊記憶體就被作業系統回收)。這些技術都有它們的折中考慮(也就是說都有潛在的記憶體洩漏風險)。
Javascript 中的記憶體洩露
引起垃圾收集語言記憶體洩露的主要原因是不必要的引用。想要理解什麼是不必要的引用,首先我們需要理解垃圾收集器是怎樣確定一塊記憶體能否被訪問的。
Mark-and-sweep
大多數的垃圾收集器(簡稱 GC)使用一個叫做 mark-and-sweep 的演算法。這個演算法由以下的幾個步驟組成:
垃圾收集器建立了一個“根節點”列表。根節點通常是那些引用被保留在程式碼中的全域性變數。對於 Javascript 而言,“Window” 物件就是一個能作為根節點的全域性變數例子。window 物件是一直都存在的(即:不是垃圾)。所有根節點都是檢查過的並且被標記為活動的(即:不是垃圾)。所有的子節點也都被遞迴地檢查過。每塊可以從根節點訪問的記憶體都不會被視為垃圾。 所有沒有被標記為垃圾的記憶體現在可以被當做垃圾,而垃圾收集器也可以釋放這些記憶體並將它們返還給作業系統。現代垃圾收集器使用不同的方式來改進這些演算法,但是它們都有相同的本質:可以訪問的記憶體塊被標記為非垃圾而其餘的就被視為垃圾。
不必要的引用就是那些程式設計師知道這塊記憶體已經沒用了,但是出於某種原因這塊記憶體依然存在於活躍的根節點發出的節點樹中。在 Javascript 的環境中,不必要的引用是某些不再被使用的程式碼中的變數。這些變數指向了一塊本來可以被釋放的記憶體。一些人認為這是程式設計師的失誤。
所以想要理解什麼是 Javascript 中最常見的記憶體洩露,我們需要知道在什麼情況下會出現不必要的引用。
3 種常見的 Javascript 記憶體洩露
1: 意外的全域性變數
Javascript 語言的設計目標之一是開發一種類似於 Java 但是對初學者十分友好的語言。體現 JavaScript 寬容性的一點表現在它處理未宣告變數的方式上:一個未宣告變數的引用會在全域性物件中建立一個新的變數。在瀏覽器的環境下,全域性物件就是 window,也就是說:
1 2 3 4 |
function foo(arg) { bar = "this is a hidden global variable"; } |
實際上是:
1 2 3 4 |
function foo(arg) { window.bar = "this is an explicit global variable"; } |
如果 bar 是一個應該指向 foo 函式作用域內變數的引用,但是你忘記使用 var 來宣告這個變數,這時一個全域性變數就會被建立出來。在這個例子中,一個簡單的字串洩露並不會造成很大的危害,但這無疑是錯誤的。
另外一種偶然建立全域性變數的方式如下:
1 2 3 4 5 6 7 8 9 |
function foo() { this.variable = "potential accidental global"; } // Foo called on its own, this points to the global object (window) // rather than being undefined. // 函式自身發生了呼叫,this 指向全域性物件(window),(譯者注:這時候會為全域性物件 window 新增一個 variable 屬性)而不是 undefined。 foo(); |
為了防止這種錯誤的發生,可以在你的 JavaScript 檔案開頭新增
'use strict';
語句。這個語句實際上開啟瞭解釋 JavaScript 程式碼的嚴格模式,這種模式可以避免建立意外的全域性變數。
全域性變數的注意事項
儘管我們在討論那些隱蔽的全域性變數,但是也有很多程式碼被明確的全域性變數汙染的情況。按照定義來講,這些都是不會被回收的變數(除非設定 null 或者被重新賦值)。特別需要注意的是那些被用來臨時儲存和處理一些大量的資訊的全域性變數。如果你必須使用全域性變數來儲存很多的資料,請確保在使用過後將它設定為 null 或者將它重新賦值。常見的和全域性變數相關的引發記憶體消耗增長的原因就是快取。快取儲存著可複用的資料。為了讓這種做法更高效,必須為快取的容量規定一個上界。由於快取不能被及時回收的緣故,快取無限制地增長會導致很高的記憶體消耗。
2: 被遺漏的定時器和回撥函式
在 JavaScript 中 setInterval 的使用十分常見。其他的庫也經常會提供觀察者和其他需要回撥的功能。這些庫中的絕大部分都會關注一點,就是當它們本身的例項被銷燬之前銷燬所有指向回撥的引用。在 setInterval 這種情況下,一般情況下的程式碼是這樣的:
1 2 3 4 5 6 7 8 9 |
var someResource = getData(); setInterval(function() { var node = document.getElementById('Node'); if(node) { // Do stuff with node and someResource. node.innerHTML = JSON.stringify(someResource)); } }, 1000); |
這個例子說明了搖晃的定時器會發生什麼:引用節點或者資料的定時器已經沒用了。那些表示節點的物件在將來可能會被移除掉,所以將整個程式碼塊放在週期處理函式中並不是必要的。然而,由於周期函式一直在執行,處理函式並不會被回收(只有周期函式停止執行之後才開始回收記憶體)。如果週期處理函式不能被回收,它的依賴程式也同樣無法被回收。這意味著一些資源,也許是一些相當大的資料都也無法被回收。
下面舉一個觀察者的例子,當它們不再被需要的時候(或者關聯物件將要失效的時候)顯式地將他們移除是十分重要的。在以前,尤其是對於某些瀏覽器(IE6)是一個至關重要的步驟,因為它們不能很好地管理迴圈引用(下面的程式碼描述了更多的細節)。現在,當觀察者物件失效的時候便會被回收,即便 listener 沒有被明確地移除,絕大多數的瀏覽器可以或者將會支援這個特性。儘管如此,在物件被銷燬之前移除觀察者依然是一個好的實踐。示例如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
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 開發者踩坑。以前在 IE 瀏覽器的垃圾回收器上會導致一個 bug(或者說是瀏覽器設計上的問題)。舊版本的 IE 瀏覽器不會發現 DOM 節點和 JavaScript 程式碼之間的迴圈引用。這是一種觀察者的典型情況,觀察者通常保留著一個被觀察者的引用(正如上述例子中描述的那樣)。換句話說,在 IE 瀏覽器中,每當一個觀察者被新增到一個節點上時,就會發生一次記憶體洩漏。這也就是開發者在節點或者空的引用被新增到觀察者中之前顯式移除處理方法的原因。目前,現代的瀏覽器(包括 IE 和 Microsoft Edge)都使用了可以發現這些迴圈引用並正確的處理它們的現代化垃圾回收演算法。換言之,嚴格地講,在廢棄一個節點之前呼叫 removeEventListener 不再是必要的操作。
像是 jQuery 這樣的框架和庫(當使用一些特定的 API 時候)都在廢棄一個結點之前移除了 listener 。它們在內部就已經處理了這些事情,並且保證不會產生記憶體洩露,即便程式執行在那些問題很多的瀏覽器中,比如老版本的 IE。
3: DOM 之外的引用
有些情況下將 DOM 結點儲存到資料結構中會十分有用。假設你想要快速地更新一個表格中的幾行,如果你把每一行的引用都儲存在一個字典或者陣列裡面會起到很大作用。如果你這麼做了,程式中將會保留同一個結點的兩個引用:一個引用存在於 DOM 樹中,另一個被保留在字典中。如果在未來的某個時刻你決定要將這些行移除,則需要將所有的引用清除。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
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 執行時的實現細節。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
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
)的物件。同時,沒有用到的那個變數持有一個引用了 originalThing
(replaceThing
呼叫之前的 theThing
)閉包。哈,是不是已經有點暈了?關鍵的問題是每當在同一個父作用域下建立閉包作用域的時候,這個作用域是被共享的。在這種情況下,someMethod
的閉包作用域和 unused
的作用域是共享的。unused
持有一個 originalThing
的引用。儘管 unused
從來沒有被使用過,someMethod
可以在 theThing
之外被訪問。而且 someMethod
和 unused
共享了閉包作用域,即便 unused
從來都沒有被使用過,它對 originalThing
的引用還是強制它保持活躍狀態(阻止它被回收)。當這段程式碼重複執行時,將可以觀察到記憶體消耗穩定地上漲,並且不會因為 GC 的存在而下降。本質上來講,建立了一個閉包連結串列(根節點是 theThing
形式的變數),而且每個閉包作用域都持有一個對大陣列的間接引用,這導致了一個巨大的記憶體洩露。
這是一種人為的實現方式。可以想到一個能夠解決這個問題的不同的閉包實現,就像 Metero 的部落格裡面說的那樣。
垃圾收集器的直觀行為
儘管垃圾收集器是便利的,但是使用它們也需要有一些利弊權衡。其中之一就是不確定性。也就是說,GC 的行為是不可預測的。通常情況下都不能確定什麼時候會發生垃圾回收。這意味著在一些情形下,程式會使用比實際需要更多的記憶體。有些的情況下,在很敏感的應用中可以觀察到明顯的卡頓。儘管不確定性意味著你無法確定什麼時候垃圾回收會發生,不過絕大多數的 GC 實現都會在記憶體分配時遵從通用的垃圾回收過程模式。如果沒有記憶體分配發生,大部分的 GC 都會保持靜默。考慮以下的情形:
- 大量記憶體分配發生時。
- 大部分(或者全部)的元素都被標記為不可達(假設我們講一個指向無用快取的引用置 null 的時候)。
- 沒有進一步的記憶體分配發生。
這個情形下,GC 將不會執行任何進一步的回收過程。也就是說,儘管有不可達的引用可以觸發回收,但是收集器並不要求回收它們。嚴格的說這些不是記憶體洩露,但仍然導致高於正常情況的記憶體空間使用。
Google 在它們的 JavaScript 記憶體分析文件中提供一個關於這個行為的優秀例子,見示例#2.
Chrome 記憶體分析工具簡介
Chrome 提供了一套很好的工具用來分析 JavaScript 的記憶體適用。這裡有兩個與記憶體相關的重要檢視:timeline 檢視和 profiles 檢視。
Timeline view
timeline 檢視是我們用於發現不正常記憶體模式的必要工具。當我們尋找嚴重的記憶體洩漏時,記憶體回收發生後產生的週期性的不會消減的記憶體跳躍式增長會被一面紅旗標記。在這個截圖裡面我們可以看到,這很像是一個穩定的物件記憶體洩露。即便最後經歷了一個很大的記憶體回收,它佔用的記憶體依舊比開始時多得多。節點數也比開始要高。這些都是程式碼中某處 DOM 節點記憶體洩露的標誌。
Profiles 檢視
你將會花費大部分的時間在觀察這個檢視上。profiles 檢視讓你可以對 JavaScript 程式碼執行時的記憶體進行快照,並且可以比較這些記憶體快照。它還讓你可以記錄一段時間內的記憶體分配情況。在每一個結果檢視中都可以展示不同型別的列表,但是對我們的任務最有用的是 summary 列表和 comparison 列表。
summary 檢視提供了不同型別的分配物件以及它們的合計大小:shallow size (一個特定型別的所有物件的總和)和 retained size (shallow size 加上保留此物件的其它物件的大小)。distance 顯示了物件到達 GC 根(校者注:最初引用的那塊記憶體,具體內容可自行搜尋該術語)的最短距離。
comparison 檢視提供了同樣的資訊但是允許對比不同的快照。這對於找到洩露很有幫助。
舉例: 使用 Chrome 來發現記憶體洩露
有兩個重要型別的記憶體洩露:引起記憶體週期性增長的洩露和只發生一次且不引起更進一步記憶體增長的洩露。顯而易見的是,尋找週期性的記憶體洩漏是更簡單的。這些也是最麻煩的事情:如果記憶體會按時增長,洩露最終將導致瀏覽器變慢或者停止執行指令碼。很明顯的非週期性大量記憶體洩露可以很容易的在其他記憶體分配中被發現。但是實際情況並不如此,往往這些洩露都是不足以引起注意的。這種情況下,小的非週期性記憶體洩露可以被當做一個優化點。然而那些週期性的記憶體洩露應該被視為 bug 並且必須被修復。
為了舉例,我們將會使用 Chrome 的文件中提供的一個例子。完整的程式碼在下面可以找到:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
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,選擇記憶體並且點選記錄按鈕。然後去到那個頁面點選按鈕開始記憶體洩露。一段時間後停止記錄,然後觀察結果:
這個例子中每秒都會發生一次記憶體洩露。記錄停止後,在 grow 函式中設定一個斷點來防止 Chrome 強制關閉這個頁面。
在圖中有兩個明顯的標誌表明我們正在洩漏記憶體。節點的圖表(綠色的線)和 JS 堆記憶體(藍色的線)。節點數穩定地增長並且從不減少。這是一個明顯的警告標誌。
JS 堆記憶體表現出穩定的記憶體用量增長。由於垃圾回收器的作用,這很難被發現。你能看到一個初始記憶體的增長的圖線,緊接著有一個很大的回落,接著又有一段增長然後出現了一個峰值,接著又是一個回落。這個情況的關鍵是在於一個事實,即每次記憶體用量回落時候,堆記憶體總是比上一次回落後的記憶體佔用量更多。也就是說,儘管垃圾收集器成功地回收了很多的記憶體,還是有一部分記憶體週期性的洩露了。
我們現在確定程式中有一個洩露,讓我們一起找到它。
拍兩張快照
為了找到這個記憶體洩漏,我們將使用 Chrome 開發者工具紅的 profiles 選項卡。為了保證記憶體的使用在一個可控制的範圍內,在做這一步之前重新整理一下頁面。我們將使用 Take Heap Snapshot 功能。
重新整理頁面,在頁面載入結束後為堆記憶體捕獲一個快照。我們將要使用這個快照作為我們的基準。然後再次點選按鈕,等幾秒,然後再拍一個快照。拍完照後,推薦的做法是在指令碼中設定一個斷點來停止它的執行,防止更多的記憶體洩露。
有兩個方法來檢視兩個快照之間的記憶體分配情況,其中一種方法需要選擇 Summary 然後在右面選取在快照1和快照2之間分配的物件,另一種方法,選擇 Comparison 而不是 Summary。兩種方法下,我們都將會看到一個列表,列表中展示了在兩個快照之間分配的物件。
本例中,我們很容易就可以找到記憶體洩露:它們很明顯。看一下(string)建構函式的 Size Delta。58個物件佔用了8 MB 記憶體。這看起來很可疑:新的物件被建立,但是沒有被釋放導致了8 MB 的記憶體消耗。
如果我們開啟(string)建構函式分配列表,我們會注意到在很多小記憶體分配中摻雜著的幾個大量的記憶體分配。這些情況立即引起了我們的注意。如果我們選擇它們當中的任意一個,我們將會在下面的 retainer 選項卡中得到一些有趣的結果。
我們發現我們選中的記憶體分配資訊是一個陣列的一部分。相應地,陣列被變數 x 在全域性 window 物件內部引用。這給我們指引了一條從我們的大物件到不會被回收的根節點(window)的完整的路徑。我們也就找到了潛在的洩漏點以及它在哪裡被引用。
到現在為止,一切都很不錯。但是我們的例子太簡單了:像例子中這樣大的記憶體分配並不是很常見。幸運的是我們的例子中還存在著細小的 DOM 節點記憶體洩漏。使用上面的記憶體快照可以很容易地找到這些節點,但是在更大的站點中,事情變得複雜起來。最近,新的 Chrome 的版本中提供了一個附加的工具,這個工具十分適合我們的工作,這就是堆記憶體分配記錄(Record Heap Allocations)功能
通過記錄堆記憶體分配來發現記憶體洩露
取消掉你之前設定的斷點讓指令碼繼續執行,然後回到開發者工具的 Profiles 選項卡。現在點選 Record Heap Allocations。當工具執行時候你將注意到圖表頂部的藍色細線。這些代表著記憶體分配。我們的程式碼導致每秒鐘都有一個大的記憶體分配發生。讓它執行幾秒然後讓程式停止(不要忘記在此設定斷點來防止 Chrome 吃掉過多的記憶體)。
在這張圖中你能看到這個工具的殺手鐗:選擇時間線中的一片來觀察在這段時間片中記憶體分配發生在什麼地方。我們將時間片設定的儘量與藍色線接近。只有三個建構函式在這個列表中顯示出來:一個是與我們的大洩露有關的(string),一個是和 DOM 節點的記憶體分配相關的,另一個是 Text 建構函式(DOM 節點中的文字建構函式)。
從列表中選擇一個 HTMLDivElement 建構函式然後選擇一個記憶體分配堆疊。
啊哈!我們現在知道那些元素在什麼地方被分配了(grow -> createSomeNodes)。如果我們集中精神觀察影像中的每個藍色線,還會注意到 HTMLDivElement 的建構函式被呼叫了很多次。如果我們回到快照 comparison 檢視就不難發現這個建構函式分配了很多次記憶體但是沒有從未釋放它們。也就是說,它不斷地分配記憶體空間,但卻沒有允許 GC 回收它們。種種跡象表明這是一個洩露,加上我們確切地知道這些物件被分配到了什麼地方(createSomeNodes 函式)。現在應該去研究程式碼,並修復這個洩漏。
其他有用的特性
在堆記憶體分配結果檢視中我們可以使用比 Summary 更好的 Allocation 檢視。
這個檢視為我們呈現了一個函式的列表,同時也顯示了與它們相關的記憶體分配情況。我們能立即看到 grow 和 createSomeNodes 凸顯了出來。當選擇 grow 我們看到了與它相關的物件建構函式被呼叫的情況。我們注意到了(string),HTMLDivElement 和 Text 而現在我們已經知道是物件的建構函式被洩露了。
這些工具的組合對找到洩漏有很大幫助。和它們一起工作。為你的生產環境站點做不同的分析(最好用沒有最小化或混淆的程式碼)。看看你能不能找到那些比正常情況消耗更多記憶體的物件吧(提示:這些很難被找到)。
如果要使用 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
結論
在垃圾回收語言中,如 JavaScript,確實會發生記憶體洩露。一些情況下我們都不會意識到這些洩露,最終它們將會帶來毀滅性的災難。正是由於這個原因,使用記憶體分析工具來發現記憶體洩露是十分重要的。執行分析工具應該成為開發週期中的一部分,特別是對於中型或大型應用來講。現在就開始這麼做,儘可能地為你的使用者提供最好的體驗。動手吧!