瞭解 JavaScript 應用程序中的內存泄漏

 檢測和解決內存問題javascript

垃圾回收解放了咱們,它讓咱們可將精力集中在應用程序邏輯(而不是內存管理)上。可是,垃圾收集並不神奇。瞭解它的工做原理,以及如何使它保留本應在好久之前釋放的內存,就能夠實現更快更可靠的應用程序。在本文中,學習一種定位 JavaScript 應用程序中內存泄漏的系統方法、幾種常見的泄漏模式,以及解決這些泄漏的適當方法。html


簡介

當處理 JavaScript 這樣的腳本語言時,很容易忘記每一個對象、類、字符串、數字和方法都須要分配和保留內存。語言和運行時的垃圾回收器隱藏了內存分配和釋放的具體細節。java

許多功能無需考慮內存管理便可實現,但卻忽略了它可能在程序中帶來重大的問題。不當清理的對象可能會存在比預期要長得多的時間。這些對象繼續響應事件和消耗資源。它們可強制瀏覽器從一個虛擬磁盤驅動器分配內存頁,這顯著影響了計算機的速度(在極端的情形中,會致使瀏覽器崩潰)。jquery

內存泄漏指任何對象在您再也不擁有或須要它以後仍然存在。在最近幾年中,許多瀏覽器都改善了在頁面加載過程當中從 JavaScript 回收內存的能力。可是,並非全部瀏覽器都具備相同的運行方式。Firefox 和舊版的 Internet Explorer 都存在過內存泄漏,並且內存泄露一直持續到瀏覽器關閉。web

過去致使內存泄漏的許多經典模式在現代瀏覽器中以再也不致使泄漏內存。可是,現在有一種不一樣的趨勢影響着內存泄漏。許多人正設計用於在沒有硬頁面刷新的單頁中運行的 Web 應用程序。在那樣的單頁中,從應用程序的一個狀態到另外一個狀態時,很容易保留再也不須要或不相關的內存。ajax

在本文中,瞭解對象的基本生命週期,垃圾回收如何肯定一個對象是否被釋放,以及如何評估潛在的泄漏行爲。另外,學習如何使用 Google Chrome 中的 Heap Profiler 來診斷內存問題。一些示例展現瞭如何解決閉包、控制檯日誌和循環帶來的內存泄漏。chrome

您可 下載 本文中使用的示例的源代碼。api


對象生命週期

要了解如何預防內存泄漏,須要瞭解對象的基本生命週期。當建立一個對象時,JavaScript 會自動爲該對象分配適當的內存。從這一刻起,垃圾回收器就會不斷對該對象進行評估,以查看它是否還是有效的對象。瀏覽器

垃圾回收器按期掃描對象,並計算引用了每一個對象的其餘對象的數量。若是一個對象的引用數量爲 0(沒有其餘對象引用過該對象),或對該對象的唯一引用是循環的,那麼該對象的內存便可回收。圖 1 顯示了垃圾回收器回收內存的一個示例。網絡

圖 1. 經過垃圾收集回收內存

展現與各個對象關聯的 root 節點的 4 個步驟。

看到該系統的實際應用會頗有幫助,但提供此功能的工具頗有限。瞭解您的 JavaScript 應用程序佔用了多少內存的一種方式是使用系統工具查看瀏覽器的內存分配。有多個工具可爲您提供當前的使用,並描繪一個進程的內存使用量隨時間變化的趨勢圖。

例如,若是在 Mac OSX 上安裝了 XCode,您能夠啓動 Instruments 應用程序,並將它的活動監視器工具附加到您的瀏覽器上,以進行實時分析。在 Windows® 上,您可使用任務管理器。若是在您使用應用程序的過程當中,發現內存使用量隨時間變化的曲線穩步上升,那麼您就知道存在內存泄漏。

觀察瀏覽器的內存佔用只能很是粗略地顯示 JavaScript 應用程序的實際內存使用。瀏覽器數據不會告訴您哪些對象發生了泄漏,也沒法保證數據與您應用程序的真正內存佔用確實匹配。並且,因爲一些瀏覽器中存在實現問題,DOM 元素(或備用的應用程序級對象)可能不會在頁面中銷燬相應元素時釋放。視頻標記尤其如此,視頻標記須要瀏覽器實現一種更加精細的基礎架構。

人們曾屢次嘗試在客戶端 JavaScript 庫中添加對內存分配的跟蹤。不幸的是,全部嘗試都不是特別可靠。例如,流行的 stats.js 包因爲不許確性而沒法支持。通常而言,嘗試從客戶端維護或肯定此信息存在必定的問題,是由於它會在應用程序中引入開銷且沒法可靠地終止。

理想的解決方案是瀏覽器供應商在瀏覽器中提供一組工具,幫助您監視內存使用,識別泄漏的對象,以及肯定爲何一個特殊對象仍標記爲保留。

目前,只有 Google Chrome(提供了 Heap Profile)實現了一個內存管理工具做爲它的開發人員工具。我在本文中使用 Heap Profiler 測試和演示 JavaScript 運行時如何處理內存。


分析堆快照

在建立內存泄漏以前,請查看一次適當收集內存的簡單交互。首先建立一個包含兩個按鈕的簡單 HTML 頁面,如清單 1 所示。

清單 1. index.html
<html>
<head>
    <script src="//ajax.googleapis.com/ajax/libs/jquery/1.7.2/jquery.min.js" 
type="text/javascript"></script>
</head>
<body>
    <button id="start_button">Start</button>
    <button id="destroy_button">Destroy</button>
    <script src="assets/scripts/leaker.js" type="text/javascript" 
charset="utf-8"></script>
    <script src="assets/scripts/main.js" type="text/javascript" 
charset="utf-8"></script>
</body>
</html>

包含 jQuery 是爲了確保一種管理事件綁定的簡單語法適合不一樣的瀏覽器,並且嚴格遵照最多見的開發實踐。爲 leaker 類和主要 JavaScript 方法添加腳本標記。在開發環境中,將 JavaScript 文件合併到單個文件中一般是一種更好的作法。出於本示例的用途,將邏輯放在獨立的文件中更容易。

您能夠過濾 Heap Profiler 來僅顯示特殊類的實例。爲了利用該功能,建立一個新類來封裝泄漏對象的行爲,並且這個類很容易在 Heap Profiler 中找到,如清單 2 所示。

清單 2. assets/scripts/leaker.js
var Leaker = function(){};
Leaker.prototype = {
    init:function(){

    }    
};

綁定 Start 按鈕以初始化 Leaker 對象,並將它分配給全局命名空間中的一個變量。還須要將 Destroy 按鈕綁定到一個應清理 Leaker 對象的方法,並讓它爲垃圾收集作好準備,如清單 3 所示。

清單 3. assets/scripts/main.js
$("#start_button").click(function(){
    if(leak !== null || leak !== undefined){
        return;
    }
  leak = new Leaker();
  leak.init();
});

$("#destroy_button").click(function(){
    leak = null;
});

var leak = new Leaker();

如今,您已準備好建立一個對象,在內存中查看它,而後釋放它。

  1. 在 Chrome 中加載索引頁面。

    由於您是直接從 Google 加載 jQuery,因此須要鏈接互聯網來運行該樣例。

  2. 打開開發人員工具,方法是打開 View 菜單並選擇 Develop 子菜單。選擇 Developer Tools 命令。
  3. 轉到 Profiles 選項卡並獲取一個堆快照,如圖 2 所示。
    圖 2. Profiles 選項卡
    Google Chrome 上的 profiles 選項卡的快照。
  4. 將注意力返回到 Web 上,選擇 Start
  5. 獲取另外一個堆快照。
  6. 過濾第一個快照,查找 Leaker 類的實例,找不到任何實例。切換到第二個快照,您應該能找到一個實例,如圖 3 所示。
    圖 3. 快照實例
    Heap Profiler 過濾器頁面的快照
  7. 將注意力返回到 Web 上,選擇 Destroy
  8. 獲取第三個堆快照。
  9. 過濾第三個快照,查找 Leaker 類的實例,找不到任何實例。

    在加載第三個快照時,也可將分析模式從 Summary 切換到 Comparison,並對比第三個和第二個快照。您會看到偏移值 -1(在兩次快照之間釋放了 Leaker 對象的一個實例)。

萬歲!垃圾回收有效的。如今是時候破壞它了。


內存泄漏 1:閉包

一種預防一個對象被垃圾回收的簡單方式是設置一個在回調中引用該對象的間隔或超時。要查看實際應用,可更新 leaker.js 類,如清單 4 所示。

清單 4. assets/scripts/leaker.js
var Leaker = function(){};

Leaker.prototype = {
    init:function(){
        this._interval = null;
        this.start();
    },

    start: function(){
        var self = this;
        this._interval = setInterval(function(){
            self.onInterval();
        }, 100);
    },

    destroy: function(){
        if(this._interval !== null){
            clearInterval(this._interval);          
        }
    },

    onInterval: function(){
        console.log("Interval");
    }
};

如今,當重複 上一節 中的第 1-9 步時,您應在第三個快照中看到,Leaker 對象被持久化,而且該間隔會永遠繼續運行。那麼發生了什麼?在一個閉包中引用的任何局部變量都會被該閉包保留,只要該閉包存在就永遠保留。要確保對 setInterval 方法的回調在訪問 Leaker 實例的範圍時執行,須要將 this 變量分配給局部變量 self,這個變量用於從閉包內觸發 onInterval。當 onInterval 觸發時,它就可以訪問Leaker 對象中的任何實例變量(包括它自身)。可是,只要事件偵聽器存在,Leaker 對象就不會被垃圾回收。

要解決此問題,可在清空所存儲的 leaker 對象引用以前,觸發添加到該對象的 destroy 方法,方法是更新 Destroy 按鈕的單擊處理程序,如清單 5 所示。

清單 5. assets/scripts/main.js
$("#destroy_button").click(function(){
    leak.destroy();
    leak = null;
});

銷燬對象和對象全部權

一種不錯的作法是,建立一個標準方法來負責讓一個對象有資格被垃圾回收。destroy 功能的主要用途是,集中清理該對象完成的具備如下後果的操做的職責:

  • 阻止它的引用計數降低到 0(例如,刪除存在問題的事件偵聽器和回調,並從任何服務取消註冊)。
  • 使用沒必要要的 CPU 週期,好比間隔或動畫。

destroy 方法經常是清理一個對象的必要步驟,但在大多數狀況下它還不夠。在理論上,在銷燬相關實例後,保留對已銷燬對象的引用的其餘對象可調用自身之上的方法。由於這種情形可能會產生不可預測的結果,因此僅在對象即將無用時調用 destroy 方法,這相當重要。

通常而言,destroy 方法最佳使用是在一個對象有一個明確的全部者來負責它的生命週期時。此情形經常存在於分層系統中,好比 MVC 框架中的視圖或控制器,或者一個畫布呈現系統的場景圖。


內存泄漏 2:控制檯日誌

一種將對象保留在內存中的不太明顯的方式是將它記錄到控制檯中。清單 6 更新了 Leaker 類,顯示了此方式的一個示例。

清單 6. assets/scripts/leaker.js
var Leaker = function(){};

Leaker.prototype = {
    init:function(){
        console.log("Leaking an object: %o", this);
    },

    destroy: function(){

    }      
};

可採起如下步驟來演示控制檯的影響。

  1. 登陸到索引頁面。
  2. 單擊 Start
  3. 轉到控制檯並確認 Leaking 對象已被跟蹤。
  4. 單擊 Destroy
  5. 回到控制檯並鍵入 leak,以記錄全局變量當前的內容。此刻該值應爲空。
  6. 獲取另外一個堆快照並過濾 Leaker 對象。

    您應留下一個 Leaker 對象。

  7. 回到控制檯並清除它。
  8. 建立另外一個堆配置文件。

    在清理控制檯後,保留 leaker 的配置文件應已清除。

控制檯日誌記錄對整體內存配置文件的影響多是許多開發人員都未想到的極其重大的問題。記錄錯誤的對象能夠將大量數據保留在內存中。注意,這也適用於:

  • 在用戶鍵入 JavaScript 時,在控制檯中的一個交互式會話期間記錄的對象。
  • 由 console.log 和 console.dir 方法記錄的對象。

內存泄漏 3:循環

在兩個對象彼此引用且彼此保留時,就會產生一個循環,如圖 4 所示。

圖 4. 建立一個循環的引用

該圖中的一個藍色 root 節點鏈接到兩個綠色框,顯示了它們之間的一個鏈接

清單 7 顯示了一個簡單的代碼示例。

清單 7. assets/scripts/leaker.js
var Leaker = function(){};

Leaker.prototype = {
    init:function(name, parent){
        this._name = name;
        this._parent = parent;
        this._child = null;
        this.createChildren();
    },

    createChildren:function(){
        if(this._parent !== null){
            // Only create a child if this is the root
            return;
        }
        this._child = new Leaker();
        this._child.init("leaker 2", this);
    },

    destroy: function(){

    }
};

Root 對象的實例化能夠修改,如清單 8 所示。

清單 8. assets/scripts/main.js
leak = new Leaker(); 
leak.init("leaker 1", null);

若是在建立和銷燬對象後執行一次堆分析,您應該會看到垃圾收集器檢測到了這個循環引用,並在您選擇 Destroy 按鈕時釋放了內存。

可是,若是引入了第三個保留該子對象的對象,該循環會致使內存泄漏。例如,建立一個 registry 對象,如清單 9 所示。

清單 9. assets/scripts/registry.js
var Registry = function(){};

Registry.prototype = {
    init:function(){
        this._subscribers = [];
    },

    add:function(subscriber){
        if(this._subscribers.indexOf(subscriber) >= 0){
            // Already registered so bail out
            return;
        }
        this._subscribers.push(subscriber);
    },

    remove:function(subscriber){
        if(this._subscribers.indexOf(subscriber) < 0){
            // Not currently registered so bail out
            return;
        }
              this._subscribers.splice(
                  this._subscribers.indexOf(subscriber), 1
              );
    }
};

registry 類是讓其餘對象向它註冊,而後從註冊表中刪除自身的對象的簡單示例。儘管這個特殊的類與註冊表毫無關聯,但這是事件調度程序和通知系統中的一種常見模式。

將該類導入 index.html 頁面中,放在 leaker.js 以前,如清單 10 所示。

清單 10. index.html
<script src="assets/scripts/registry.js" type="text/javascript" 
charset="utf-8"></script>

更新 Leaker 對象,以向註冊表對象註冊該對象自己(可能用於有關一些未實現事件的通知)。這建立了一個來自要保留的 leaker 子對象的 root 節點備用路徑,但因爲該循環,父對象也將保留,如清單 11 所示。

清單 11. assets/scripts/leaker.js
var Leaker = function(){};
Leaker.prototype = {

    init:function(name, parent, registry){
        this._name = name;
        this._registry = registry;
        this._parent = parent;
        this._child = null;
        this.createChildren();
        this.registerCallback();
    },

    createChildren:function(){
        if(this._parent !== null){
            // Only create child if this is the root
            return;
        }
        this._child = new Leaker();
        this._child.init("leaker 2", this, this._registry);
    },

    registerCallback:function(){
        this._registry.add(this);
    },

    destroy: function(){
        this._registry.remove(this);
    }
};

最後,更新 main.js 以設置註冊表,並將對註冊表的一個引用傳遞給 leaker 父對象,如清單 12 所示。

清單 12. assets/scripts/main.js
      $("#start_button").click(function(){
  var leakExists = !(
          window["leak"] === null || window["leak"] === undefined
      );
  if(leakExists){
      return;
  }
  leak = new Leaker();
  leak.init("leaker 1", null, registry);
});

$("#destroy_button").click(function(){
    leak.destroy();
    leak = null;
});

registry = new Registry();
registry.init();

如今,當執行堆分析時,您應看到每次選擇 Start 按鈕時,會建立並保留 Leaker 對象的兩個新實例。圖 5 顯示了對象引用的流程。

圖 5. 因爲保留引用致使的內存泄漏

3 個方框顯示了 root 節點與父和子對象之間的 3 個不一樣路徑

從表面上看,它像一個不天然的示例,但它實際上很是常見。更加經典的面向對象框架中的事件偵聽器經常遵循相似圖 5 的模式。這種類型的模式也可能與閉包和控制檯日誌致使的問題相關聯。

儘管有多種方式來解決此類問題,但在此狀況下,最簡單的方式是更新 Leaker 類,以在銷燬它時銷燬它的子對象。對於本示例,更新destroy 方法(如清單 13 所示)就足夠了。

清單 13. assets/scripts/leaker.js
destroy: function(){
    if(this._child !== null){
        this._child.destroy();            
    }
    this._registry.remove(this);
}

有時,兩個沒有足夠緊密關係的對象之間也會存在循環,其中一個對象管理另外一個對象的生命週期。在這樣的狀況下,在這兩個對象之間創建關係的對象應負責在本身被銷燬時中斷循環。


結束語

即便 JavaScript 已被垃圾回收,仍然會有許多方式會將不須要的對象保留在內存中。目前大部分瀏覽器都已改進了內存清理功能,但評估您應用程序內存堆的工具仍然有限(除了使用 Google Chrome)。經過從簡單的測試案例開始,很容易評估潛在的泄漏行爲並肯定是否存在泄漏。

不通過測試,就不可能準確度量內存使用。很容易使循環引用佔據對象曲線圖中的大部分區域。Chrome 的 Heap Profiler 是一個診斷內存問題的寶貴工具,在開發時按期使用它也是一個不錯的選擇。在預測對象曲線圖中要釋放的具體資源時請設定具體的預期,而後進行驗證。任什麼時候候當您看到不想要的結果時,請仔細調查。

在建立對象時要計劃該對象的清理工做,這比在之後將一個清理階段移植到應用程序中要容易得多。經常要計劃刪除事件偵聽器,並中止您建立的間隔。若是認識到了您應用程序中的內存使用,您將獲得更可靠且性能更高的應用程序。

 


參考資料

學習

得到產品和技術

討論

  • developerWorks 社區:查看開發人員推進的博客、論壇、羣組和維基,並與其餘 developerWorks 用戶交流。

轉自:http://www.ibm.com/developerworks/cn/web/wa-jsmemory/

相關文章
相關標籤/搜索