JavaScript 中 4 種常見的內存泄露陷阱

瞭解 JavaScript 的內存泄露和解決方式!node

在這篇文章中咱們將要探索客戶端 JavaScript 代碼中常見的一些內存泄漏的狀況,而且學習如何使用 Chrome 的開發工具來發現他們。讀一讀吧!程序員

介紹

內存泄露是每一個開發者最終都不得不面對的問題。即使使用自動內存管理的語言,你仍是會碰到一些內存泄漏的狀況。內存泄露會致使一系列問題,好比:運行緩慢,崩潰,高延遲,甚至一些與其餘應用相關的問題。算法

什麼是內存泄漏

本質上來說,內存泄露是當一塊內存再也不被應用程序使用的時候,因爲某種緣由,這塊內存沒有返還給操做系統或者空閒內存池的現象。編程語言使用不一樣的方式來管理內存。這些方式可能會減小內存泄露的機會。然而,某一塊具體的內存是否被使用其實是一個不可斷定問題(undecidable problem)。換句話說,只有開發者能夠搞清楚一塊內存是否應該被操做系統回收。某些編程語言提供了幫助開發者來處理這件事情的特性。而其它的編程語言須要開發者明確知道內存的使用狀況。維基百科上有幾篇寫的不錯的講述手動 和自動內存管理的文章。編程

Javascript 的內存管理

Javascript 是那些被稱做垃圾回收語言當中的一員。垃圾回收語言經過週期性地檢查那些以前被分配出去的內存是否能夠從應用的其餘部分訪問來幫助開發者管理內存。換句話說,垃圾回收語言將內存管理的問題從「什麼樣的內存是仍然被使用的?」簡化成爲「什麼樣的內存仍然能夠從應用程序的其餘部分訪問?」。二者的區別是細微的,可是很重要:開發者只須要知道一塊已分配的內存是否會在未來被使用,而不可訪問的內存能夠經過算法肯定並標記以便返還給操做系統。數組

非垃圾回收語言一般使用其餘的技術來管理內存,包括:顯式內存管理,程序員顯式地告訴編譯器在什麼時候再也不須要某塊內存;引用計數,一個計數器關聯着每一個內存塊(當計數器的計數變爲0的時候,這塊內存就被操做系統回收)。這些技術都有它們的折中考慮(也就是說都有潛在的內存泄漏風險)。瀏覽器

Javascript 中的內存泄露

引發垃圾收集語言內存泄露的主要緣由是沒必要要的引用。想要理解什麼是沒必要要的引用,首先咱們須要理解垃圾收集器是怎樣肯定一塊內存可否被訪問的。緩存

Mark-and-sweep

大多數的垃圾收集器(簡稱 GC)使用一個叫作 mark-and-sweep 的算法。這個算法由如下的幾個步驟組成:數據結構

垃圾收集器創建了一個「根節點」列表。根節點一般是那些引用被保留在代碼中的全局變量。對於 Javascript 而言,「Window」 對象就是一個能做爲根節點的全局變量例子。window 對象是一直都存在的(即:不是垃圾)。全部根節點都是檢查過的而且被標記爲活動的(即:不是垃圾)。全部的子節點也都被遞歸地檢查過。每塊能夠從根節點訪問的內存都不會被視爲垃圾。 全部沒有被標記爲垃圾的內存如今能夠被當作垃圾,而垃圾收集器也能夠釋放這些內存並將它們返還給操做系統。現代垃圾收集器使用不一樣的方式來改進這些算法,可是它們都有相同的本質:能夠訪問的內存塊被標記爲非垃圾而其他的就被視爲垃圾。閉包

沒必要要的引用就是那些程序員知道這塊內存已經沒用了,可是出於某種緣由這塊內存依然存在於活躍的根節點發出的節點樹中。在 Javascript 的環境中,沒必要要的引用是某些再也不被使用的代碼中的變量。這些變量指向了一塊原本能夠被釋放的內存。一些人認爲這是程序員的失誤。app

因此想要理解什麼是 Javascript 中最多見的內存泄露,咱們須要知道在什麼狀況下會出現沒必要要的引用。

3 種常見的 Javascript 內存泄露

1: 意外的全局變量

Javascript 語言的設計目標之一是開發一種相似於 Java 可是對初學者十分友好的語言。體現 JavaScript 寬容性的一點表如今它處理未聲明變量的方式上:一個未聲明變量的引用會在全局對象中建立一個新的變量。在瀏覽器的環境下,全局對象就是 window,也就是說:

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.

// 函數自身發生了調用,this 指向全局對象(window),(譯者注:這時候會爲全局對象 window 添加一個 variable 屬性)而不是 undefined。

 

foo();

 

爲了防止這種錯誤的發生,能夠在你的 JavaScript 文件開頭添加 'use strict'; 語句。這個語句實際上開啓瞭解釋 JavaScript 代碼的嚴格模式,這種模式能夠避免建立意外的全局變量。

全局變量的注意事項

儘管咱們在討論那些隱蔽的全局變量,可是也有不少代碼被明確的全局變量污染的狀況。按照定義來說,這些都是不會被回收的變量(除非設置 null 或者被從新賦值)。特別須要注意的是那些被用來臨時存儲和處理一些大量的信息的全局變量。若是你必須使用全局變量來存儲不少的數據,請確保在使用事後將它設置爲 null 或者將它從新賦值。常見的和全局變量相關的引起內存消耗增加的緣由就是緩存。緩存存儲着可複用的數據。爲了讓這種作法更高效,必須爲緩存的容量規定一個上界。因爲緩存不能被及時回收的緣故,緩存無限制地增加會致使很高的內存消耗。

2: 被遺漏的定時器和回調函數

在 JavaScript 中 setInterval 的使用十分常見。其餘的庫也常常會提供觀察者和其餘須要回調的功能。這些庫中的絕大部分都會關注一點,就是當它們自己的實例被銷燬以前銷燬全部指向回調的引用。在 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);

 

這個例子說明了搖晃的定時器會發生什麼:引用節點或者數據的定時器已經沒用了。那些表示節點的對象在未來可能會被移除掉,因此將整個代碼塊放在週期處理函數中並非必要的。然而,因爲周期函數一直在運行,處理函數並不會被回收(只有周期函數中止運行以後纔開始回收內存)。若是週期處理函數不能被回收,它的依賴程序也一樣沒法被回收。這意味着一些資源,也許是一些至關大的數據都也沒法被回收。

下面舉一個觀察者的例子,當它們再也不被須要的時候(或者關聯對象將要失效的時候)顯式地將他們移除是十分重要的。在之前,尤爲是對於某些瀏覽器(IE6)是一個相當重要的步驟,由於它們不能很好地管理循環引用(下面的代碼描述了更多的細節)。如今,當觀察者對象失效的時候便會被回收,即使 listener 沒有被明確地移除,絕大多數的瀏覽器能夠或者將會支持這個特性。儘管如此,在對象被銷燬以前移除觀察者依然是一個好的實踐。示例以下:

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 樹中,另外一個被保留在字典中。若是在將來的某個時刻你決定要將這些行移除,則須要將全部的引用清除。

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)的對象。同時,沒有用到的那個變量持有一個引用了 originalThingreplaceThing 調用以前的 theThing)閉包。哈,是否是已經有點暈了?關鍵的問題是每當在同一個父做用域下建立閉包做用域的時候,這個做用域是被共享的。在這種狀況下,someMethod 的閉包做用域和 unused 的做用域是共享的。unused 持有一個 originalThing 的引用。儘管 unused 歷來沒有被使用過,someMethod 能夠在 theThing 以外被訪問。並且 someMethod 和 unused 共享了閉包做用域,即使 unused 歷來都沒有被使用過,它對 originalThing 的引用仍是強制它保持活躍狀態(阻止它被回收)。當這段代碼重複運行時,將能夠觀察到內存消耗穩定地上漲,而且不會由於 GC 的存在而降低。本質上來說,建立了一個閉包鏈表(根節點是 theThing 形式的變量),並且每一個閉包做用域都持有一個對大數組的間接引用,這致使了一個巨大的內存泄露。

這是一種人爲的實現方式。能夠想到一個可以解決這個問題的不一樣的閉包實現,就像 Metero 的博客裏面說的那樣。

垃圾收集器的直觀行爲

儘管垃圾收集器是便利的,可是使用它們也須要有一些利弊權衡。其中之一就是不肯定性。也就是說,GC 的行爲是不可預測的。一般狀況下都不能肯定何時會發生垃圾回收。這意味着在一些情形下,程序會使用比實際須要更多的內存。有些的狀況下,在很敏感的應用中能夠觀察到明顯的卡頓。儘管不肯定性意味着你沒法肯定何時垃圾回收會發生,不過絕大多數的 GC 實現都會在內存分配時聽從通用的垃圾回收過程模式。若是沒有內存分配發生,大部分的 GC 都會保持靜默。考慮如下的情形:

  1. 大量內存分配發生時。

  2. 大部分(或者所有)的元素都被標記爲不可達(假設咱們講一個指向無用緩存的引用置 null 的時候)。

  3. 沒有進一步的內存分配發生。

這個情形下,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 的文檔中提供的一個例子。完整的代碼在下面能夠找到:

var x = [];

 

function createSomeNodes() {

    var div,

        i = 100,

        frag = document.createDocumentFragment();

    for (;i &gt; 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,確實會發生內存泄露。一些狀況下咱們都不會意識到這些泄露,最終它們將會帶來毀滅性的災難。正是因爲這個緣由,使用內存分析工具來發現內存泄露是十分重要的。運行分析工具應該成爲開發週期中的一部分,特別是對於中型或大型應用來說。如今就開始這麼作,儘量地爲你的用戶提供最好的體驗。動手吧!

相關文章
相關標籤/搜索