簡介
當處理 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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
<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
1 2 3 4 5 6 |
var Leaker = function(){}; Leaker.prototype = { init:function(){ } }; |
繫結 Start 按鈕以初始化Leaker
物件,並將它分配給全域性名稱空間中的一個變數。還需要將 Destroy 按鈕繫結到一個應清理Leaker物件的方法,並讓它為垃圾收集做好準備,如清單 3 所示。
清單 3. assets/scripts/main.js
1 2 3 4 5 6 7 8 9 10 11 12 13 |
$("#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(); |
現在,您已準備好建立一個物件,在記憶體中檢視它,然後釋放它。
- 在 Chrome 中載入索引頁面。因為您是直接從 Google 載入 jQuery,所以需要連線網際網路來執行該樣例。
- 開啟開發人員工具,方法是開啟 View 選單並選擇 Develop 子選單。選擇Developer Tools命令。
- 轉到Profiles選項卡並獲取一個堆快照,如圖 2 所示。
圖 2. Profiles 選項卡
- 將注意力返回到 Web 上,選擇Start。
- 獲取另一個堆快照。
- 過濾第一個快照,查詢Leaker類的例項,找不到任何例項。切換到第二個快照,您應該能找到一個例項,如圖 3 所示。
圖 3. 快照例項
- 將注意力返回到 Web 上,選擇Destroy。
- 獲取第三個堆快照。
- 過濾第三個快照,查詢Leaker類的例項,找不到任何例項。在載入第三個快照時,也可將分析模式從 Summary 切換到 Comparison,並對比第三個和第二個快照。您會看到偏移值 -1(在兩次快照之間釋放了Leaker物件的一個例項)。
萬歲!垃圾回收有效的。現在是時候破壞它了。
記憶體洩漏 1:閉包
一種預防一個物件被垃圾回收的簡單方式是設定一個在回撥中引用該物件的間隔或超時。要檢視實際應用,可更新 leaker.js 類,如清單 4 所示。
清單 4. assets/scripts/leaker.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
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
1 2 3 4 |
$("#destroy_button").click(function(){ leak.destroy(); leak = null; }); |
銷燬物件和物件所有權
一種不錯的做法是,建立一個標準方法來負責讓一個物件有資格被垃圾回收。destroy 功能的主要用途是,集中清理該物件完成的具有以下後果的操作的職責:
- 阻止它的引用計數下降到 0(例如,刪除存在問題的事件偵聽器和回撥,並從任何服務取消註冊)。
- 使用不必要的 CPU 週期,比如間隔或動畫。
destroy方法常常是清理一個物件的必要步驟,但在大多數情況下它還不夠。在理論上,在銷燬相關例項後,保留對已銷燬物件的引用的其他物件可呼叫自身之上的方法。因為這種情形可能會產生不可預測的結果,所以僅在物件即將無用時呼叫 destroy 方法,這至關重要。
一般而言,destroy 方法最佳使用是在一個物件有一個明確的所有者來負責它的生命週期時。此情形常常存在於分層系統中,比如 MVC 框架中的檢視或控制器,或者一個畫布呈現系統的場景圖。
記憶體洩漏 2:控制檯日誌
一種將物件保留在記憶體中的不太明顯的方式是將它記錄到控制檯中。清單 6 更新了Leaker類,顯示了此方式的一個示例。
清單 6. assets/scripts/leaker.js
1 2 3 4 5 6 7 8 9 10 11 |
var Leaker = function(){}; Leaker.prototype = { init:function(){ console.log("Leaking an object: %o", this); }, destroy: function(){ } }; |
可採取以下步驟來演示控制檯的影響。
- 登入到索引頁面。
- 單擊Start。
- 轉到控制檯並確認 Leaking 物件已被跟蹤。
- 單擊Destroy。
- 回到控制檯並鍵入leak,以記錄全域性變數當前的內容。此刻該值應為空。
- 獲取另一個堆快照並過濾 Leaker 物件。您應留下一個Leaker物件。
- 回到控制檯並清除它。
- 建立另一個堆配置檔案。在清理控制檯後,保留 leaker 的配置檔案應已清除。
控制檯日誌記錄對總體記憶體配置檔案的影響可能是許多開發人員都未想到的極其重大的問題。記錄錯誤的物件可以將大量資料保留在記憶體中。注意,這也適用於:
- 在使用者鍵入 JavaScript 時,在控制檯中的一個互動式會話期間記錄的物件。
- 由console.log和console.dir方法記錄的物件。
記憶體洩漏 3:迴圈
在兩個物件彼此引用且彼此保留時,就會產生一個迴圈,如圖 4 所示。
圖 4. 建立一個迴圈的引用
清單 7 顯示了一個簡單的程式碼示例。
清單 7. assets/scripts/leaker.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 |
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
1 2 |
leak = new Leaker(); leak.init("leaker 1", null); |
如果在建立和銷燬物件後執行一次堆分析,您應該會看到垃圾收集器檢測到了這個迴圈引用,並在您選擇 Destroy 按鈕時釋放了記憶體。
但是,如果引入了第三個保留該子物件的物件,該迴圈會導致記憶體洩漏。例如,建立一個registry物件,如清單 9 所示。
清單 9. assets/scripts/registry.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 |
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
1 2 |
<script src="assets/scripts/registry.js" type="text/javascript" charset="utf-8"></script> |
更新Leaker物件,以向登錄檔物件註冊該物件本身(可能用於有關一些未實現事件的通知)。這建立了一個來自要保留的 leaker 子物件的 root 節點備用路徑,但由於該迴圈,父物件也將保留,如清單 11 所示。
清單 11. assets/scripts/leaker.js
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 |
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
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
$("#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
1 2 3 4 5 6 |
destroy: function(){ if(this._child !== null){ this._child.destroy(); } this._registry.remove(this); } |
有時,兩個沒有足夠緊密關係的物件之間也會存在迴圈,其中一個物件管理另一個物件的生命週期。在這樣的情況下,在這兩個物件之間建立關係的物件應負責在自己被銷燬時中斷迴圈。
結束語
即使 JavaScript 已被垃圾回收,仍然會有許多方式會將不需要的物件保留在記憶體中。目前大部分瀏覽器都已改進了記憶體清理功能,但評估您應用程式記憶體堆的工具仍然有限(除了使用 Google Chrome)。通過從簡單的測試案例開始,很容易評估潛在的洩漏行為並確定是否存在洩漏。
不經過測試,就不可能準確度量記憶體使用。很容易使迴圈引用佔據物件曲線圖中的大部分割槽域。Chrome 的 Heap Profiler 是一個診斷記憶體問題的寶貴工具,在開發時定期使用它也是一個不錯的選擇。在預測物件曲線圖中要釋放的具體資源時請設定具體的預期,然後進行驗證。任何時候當您看到不想要的結果時,請仔細調查。
在建立物件時要計劃該物件的清理工作,這比在以後將一個清理階段移植到應用程式中要容易得多。常常要計劃刪除事件偵聽器,並停止您建立的間隔。如果認識到了您應用程式中的記憶體使用,您將得到更可靠且效能更高的應用程式。
下載
描述 | 名字 | 大小 |
---|---|---|
文章原始碼 | JavascriptMemoryManagementSource.zip | 4KB |
參考資料
學習
- Chrome Developer Tools: Heap Profiling:藉助此教程學習如何使用 Heap Profiler 揭示您的應用程式中的記憶體洩漏。
- “JavaScript 中的記憶體洩漏模式”(developerWorks,2007 年 4 月):瞭解 JavaScript 中的迴圈引用的基本知識,以及為什麼它們會在某些瀏覽器中引發問題。
- “查詢記憶體洩漏”:瞭解即使在不瞭解原始碼的情況下也可以輕鬆地診斷洩漏的方式。
- “JavaScript 記憶體洩漏”:瞭解有關記憶體洩漏的原因和檢測的更多資訊。
- “avaScript and the Document Object Model”(developerWorks,2002 年 7 月):瞭解 JavaScript 的 DOM 方法,以及如何構建一個可以讓使用者新增備註和和編輯備註內容的網頁。
- A re-introduction to JavaScript:更詳細地瞭解 JavaScript 及其特性。
- developerWorks Web 開發專區:查詢涉及各種基於 Web 的解決方案的文章。訪問Web 開發技術庫,查閱豐富的技術文章,以及技巧、教程、標準和 IBM 紅皮書。
- developerWorks 技術活動和網路廣播:隨時關注這些會議中的技術。
- developerWorks 點播演示:觀看豐富的演示,包括面向初學者的產品安裝和設定,以及為經驗豐富的開發人員提供的高階功能。
- Twitter 上的 developerWorks:立即加入以關注 developerWorks 推文。
獲得產品和技術
- 開發人員頻道:獲取 Google Chrome 版本以及最新的 Developer Tools 版本。
- IBM 產品評估版:下載或瀏覽 IBM SOA 沙盒中的線上教程,親自使用來自 DB2、Lotus、Rational、Tivoli 和 WebSphere 的應用程式開發工具和中介軟體產品。
討論
- developerWorks 社群:檢視開發人員推動的部落格、論壇、群組和維基,並與其他 developerWorks 使用者交流。