垃圾回收解放了我們,它讓我們將精力集中在應用程式邏輯(而不是記憶體管理)上。但是,垃圾收集並不神奇。了解它的工作原理,以及如何使它保留本應在很久以前釋放的內存,就可以實現更快更可靠的應用程式。在本文中,學習一種定位 JavaScript 應用程式中記憶體洩漏的系統方法、幾種常見的洩漏模式,以及解決這些洩漏的適當方法。
一、簡介
當處理 JavaScript 這樣的腳本語言時,很容易忘記每個物件、類別、字串、數字和方法都需要分配和保留記憶體。語言和運行時的垃圾回收器隱藏了記憶體分配和釋放的具體細節。
許多功能無需考慮記憶體管理即可實現,但卻忽略了它可能在程式中帶來重大的問題。不當清理的物件可能會存在比預期要長得多的時間。這些物件繼續回應事件和消耗資源。它們可強制瀏覽器從一個虛擬磁碟機分配記憶體頁,這顯著影響了電腦的速度(在極端的情況中,會導致瀏覽器崩潰)。
記憶體洩漏是指任何物件在您不再擁有或需要它之後仍然存在。在最近幾年中,許多瀏覽器都改善了在頁面載入過程中從 JavaScript 回收記憶體的能力。但是,並不是所有瀏覽器都有相同的運作方式。 Firefox 和舊版的 Internet Explorer 都存在過記憶體洩漏,記憶體外洩一直持續到瀏覽器關閉。
過去導致記憶體洩漏的許多經典模式在現代瀏覽器中以不再導致洩漏記憶體。但是,如今有一種不同的趨勢影響記憶體洩漏。許多人正設計用於在沒有硬頁面刷新的單頁中運行的 Web 應用程式。在那樣的單頁中,從應用程式的一個狀態到另一個狀態時,很容易保留不再需要或不相關的記憶體。
在本文中,了解物件的基本生命週期,垃圾回收如何確定一個物件是否被釋放,以及如何評估潛在的洩漏行為。另外,學習如何使用 Google Chrome 中的 Heap Profiler 來診斷記憶體問題。一些範例展示如何解決閉包、控制台日誌和循環帶來的記憶體洩漏。
二、物件生命週期
要了解如何預防記憶體洩漏,需要了解物件的基本生命週期。當建立物件時,JavaScript 會自動為該物件分配適當的記憶體。從這一刻起,垃圾回收器就會不斷對該物件進行評估,以查看它是否仍是有效的物件。
垃圾回收器定期掃描對象,並計算引用了每個對象的其他對象的數量。如果一個物件的參考數量為 0(沒有其他物件引用過該物件),或對該物件的惟一引用是循環的,那麼該物件的記憶體即可回收。圖 1 顯示了垃圾回收器回收記憶體的一個範例。
圖 1. 透過垃圾收集回收記憶體
看到該系統的實際應用會很有幫助,但提供此功能的工具很有限。了解您的 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 選項卡
4)、將注意力回到 Web 上,選擇 Start。
5)、取得另一個堆快照。
6)、過濾第一個快照,找出 Leaker 類別的實例,找不到任何實例。切換到第二個快照,您應該可以找到一個實例,如圖 3 所示。
圖 3. 快照實例
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 功能的主要用途是,集中清理该对象完成的具有以下后果的操作的职责:
1、阻止它的引用计数下降到 0(例如,删除存在问题的事件侦听器和回调,并从任何服务取消注册)。
2、使用不必要的 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)、在用户键入 JavaScript 时,在控制台中的一个交互式会话期间记录的对象。
2)、由 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
charset="utf-8">
更新 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. 由于保留引用导致的内存泄漏
从表面上看,它像一个不自然的示例,但它实际上非常常见。更加经典的面向对象框架中的事件侦听器常常遵循类似图 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 是一个诊断内存问题的宝贵工具,在开发时定期使用它也是一个不错的选择。在预测对象曲线图中要释放的具体资源时请设定具体的预期,然后进行验证。任何时候当您看到不想要的结果时,请仔细调查。
在创建对象时要计划该对象的清理工作,这比在以后将一个清理阶段移植到应用程序中要容易得多。常常要计划删除事件侦听器,并停止您创建的间隔。如果认识到了您应用程序中的内存使用,您将得到更可靠且性能更高的应用程序。