深入理解JavaScript程序中內(nèi)存泄漏
垃圾回收解放了我們,它讓我們可將精力集中在應(yīng)用程序邏輯(而不是內(nèi)存管理)上。但是,垃圾收集并不神奇。了解它的工作原理,以及如何使它保留本應(yīng)在很久以前釋放的內(nèi)存,就可以實(shí)現(xiàn)更快更可靠的應(yīng)用程序。在本文中,學(xué)習(xí)一種定位 JavaScript 應(yīng)用程序中內(nèi)存泄漏的系統(tǒng)方法、幾種常見的泄漏模式,以及解決這些泄漏的適當(dāng)方法。
一、簡介
當(dāng)處理 JavaScript 這樣的腳本語言時,很容易忘記每個對象、類、字符串、數(shù)字和方法都需要分配和保留內(nèi)存。語言和運(yùn)行時的垃圾回收器隱藏了內(nèi)存分配和釋放的具體細(xì)節(jié)。
許多功能無需考慮內(nèi)存管理即可實(shí)現(xiàn),但卻忽略了它可能在程序中帶來重大的問題。不當(dāng)清理的對象可能會存在比預(yù)期要長得多的時間。這些對象繼續(xù)響應(yīng)事件和消耗資源。它們可強(qiáng)制瀏覽器從一個虛擬磁盤驅(qū)動器分配內(nèi)存頁,這顯著影響了計(jì)算機(jī)的速度(在極端的情形中,會導(dǎo)致瀏覽器崩潰)。
內(nèi)存泄漏指任何對象在您不再擁有或需要它之后仍然存在。在最近幾年中,許多瀏覽器都改善了在頁面加載過程中從 JavaScript 回收內(nèi)存的能力。但是,并不是所有瀏覽器都具有相同的運(yùn)行方式。Firefox 和舊版的 Internet Explorer 都存在過內(nèi)存泄漏,而且內(nèi)存泄露一直持續(xù)到瀏覽器關(guān)閉。
過去導(dǎo)致內(nèi)存泄漏的許多經(jīng)典模式在現(xiàn)代瀏覽器中以不再導(dǎo)致泄漏內(nèi)存。但是,如今有一種不同的趨勢影響著內(nèi)存泄漏。許多人正設(shè)計(jì)用于在沒有硬頁面刷新的單頁中運(yùn)行的 Web 應(yīng)用程序。在那樣的單頁中,從應(yīng)用程序的一個狀態(tài)到另一個狀態(tài)時,很容易保留不再需要或不相關(guān)的內(nèi)存。
在本文中,了解對象的基本生命周期,垃圾回收如何確定一個對象是否被釋放,以及如何評估潛在的泄漏行為。另外,學(xué)習(xí)如何使用 Google Chrome 中的 Heap Profiler 來診斷內(nèi)存問題。一些示例展示了如何解決閉包、控制臺日志和循環(huán)帶來的內(nèi)存泄漏。
二、對象生命周期
要了解如何預(yù)防內(nèi)存泄漏,需要了解對象的基本生命周期。當(dāng)創(chuàng)建一個對象時,JavaScript 會自動為該對象分配適當(dāng)?shù)膬?nèi)存。從這一刻起,垃圾回收器就會不斷對該對象進(jìn)行評估,以查看它是否仍是有效的對象。
垃圾回收器定期掃描對象,并計(jì)算引用了每個對象的其他對象的數(shù)量。如果一個對象的引用數(shù)量為 0(沒有其他對象引用過該對象),或?qū)υ搶ο蟮奈┮灰檬茄h(huán)的,那么該對象的內(nèi)存即可回收。圖 1 顯示了垃圾回收器回收內(nèi)存的一個示例。
圖 1. 通過垃圾收集回收內(nèi)存
看到該系統(tǒng)的實(shí)際應(yīng)用會很有幫助,但提供此功能的工具很有限。了解您的 JavaScript 應(yīng)用程序占用了多少內(nèi)存的一種方式是使用系統(tǒng)工具查看瀏覽器的內(nèi)存分配。有多個工具可為您提供當(dāng)前的使用,并描繪一個進(jìn)程的內(nèi)存使用量隨時間變化的趨勢圖。
例如,如果在 Mac OSX 上安裝了 XCode,您可以啟動 Instruments 應(yīng)用程序,并將它的活動監(jiān)視器工具附加到您的瀏覽器上,以進(jìn)行實(shí)時分析。在 Windows® 上,您可以使用任務(wù)管理器。如果在您使用應(yīng)用程序的過程中,發(fā)現(xiàn)內(nèi)存使用量隨時間變化的曲線穩(wěn)步上升,那么您就知道存在內(nèi)存泄漏。
觀察瀏覽器的內(nèi)存占用只能非常粗略地顯示 JavaScript 應(yīng)用程序的實(shí)際內(nèi)存使用。瀏覽器數(shù)據(jù)不會告訴您哪些對象發(fā)生了泄漏,也無法保證數(shù)據(jù)與您應(yīng)用程序的真正內(nèi)存占用確實(shí)匹配。而且,由于一些瀏覽器中存在實(shí)現(xiàn)問題,DOM 元素(或備用的應(yīng)用程序級對象)可能不會在頁面中銷毀相應(yīng)元素時釋放。視頻標(biāo)記尤為如此,視頻標(biāo)記需要瀏覽器實(shí)現(xiàn)一種更加精細(xì)的基礎(chǔ)架構(gòu)。
人們曾多次嘗試在客戶端 JavaScript 庫中添加對內(nèi)存分配的跟蹤。不幸的是,所有嘗試都不是特別可靠。例如,流行的 stats.js 包由于不準(zhǔn)確性而無法支持。一般而言,嘗試從客戶端維護(hù)或確定此信息存在一定的問題,是因?yàn)樗鼤趹?yīng)用程序中引入開銷且無法可靠地終止。
理想的解決方案是瀏覽器供應(yīng)商在瀏覽器中提供一組工具,幫助您監(jiān)視內(nèi)存使用,識別泄漏的對象,以及確定為什么一個特殊對象仍標(biāo)記為保留。
目前,只有 Google Chrome(提供了 Heap Profile)實(shí)現(xiàn)了一個內(nèi)存管理工具作為它的開發(fā)人員工具。我在本文中使用 Heap Profiler 測試和演示 JavaScript 運(yùn)行時如何處理內(nèi)存。
三、分析堆快照
在創(chuàng)建內(nèi)存泄漏之前,請查看一次適當(dāng)收集內(nèi)存的簡單交互。首先創(chuàng)建一個包含兩個按鈕的簡單 HTML 頁面,如清單 1 所示。
清單 1. index.html
<html> <head> <script src="http://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 是為了確保一種管理事件綁定的簡單語法適合不同的瀏覽器,而且嚴(yán)格遵守最常見的開發(fā)實(shí)踐。為 leaker 類和主要 JavaScript 方法添加腳本標(biāo)記。在開發(fā)環(huán)境中,將 JavaScript 文件合并到單個文件中通常是一種更好的做法。出于本示例的用途,將邏輯放在獨(dú)立的文件中更容易。
您可以過濾 Heap Profiler 來僅顯示特殊類的實(shí)例。為了利用該功能,創(chuàng)建一個新類來封裝泄漏對象的行為,而且這個類很容易在 Heap Profiler 中找到,如清單 2 所示。
清單 2. assets/scripts/leaker.js
var Leaker = function(){}; Leaker.prototype = { init:function(){ } };
綁定 Start 按鈕以初始化 Leaker 對象,并將它分配給全局命名空間中的一個變量。還需要將 Destroy 按鈕綁定到一個應(yīng)清理 Leaker 對象的方法,并讓它為垃圾收集做好準(zhǔn)備,如清單 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();
現(xiàn)在,您已準(zhǔn)備好創(chuàng)建一個對象,在內(nèi)存中查看它,然后釋放它。
1)、在 Chrome 中加載索引頁面。因?yàn)槟侵苯訌?Google 加載 jQuery,所以需要連接互聯(lián)網(wǎng)來運(yùn)行該樣例。
2)、打開開發(fā)人員工具,方法是打開 View 菜單并選擇 Develop 子菜單。選擇 Developer Tools 命令。
3)、轉(zhuǎn)到 Profiles 選項(xiàng)卡并獲取一個堆快照,如圖 2 所示。
圖 2. Profiles 選項(xiàng)卡
4)、將注意力返回到 Web 上,選擇 Start。
5)、獲取另一個堆快照。
6)、過濾第一個快照,查找 Leaker 類的實(shí)例,找不到任何實(shí)例。切換到第二個快照,您應(yīng)該能找到一個實(shí)例,如圖 3 所示。
圖 3. 快照實(shí)例
7)、將注意力返回到 Web 上,選擇 Destroy。
8)、獲取第三個堆快照。
9)、過濾第三個快照,查找 Leaker 類的實(shí)例,找不到任何實(shí)例。在加載第三個快照時,也可將分析模式從 Summary 切換到 Comparison,并對比第三個和第二個快照。您會看到偏移值 -1(在兩次快照之間釋放了 Leaker 對象的一個實(shí)例)。
萬歲!垃圾回收有效的。現(xiàn)在是時候破壞它了。
四、內(nèi)存泄漏1:閉包
一種預(yù)防一個對象被垃圾回收的簡單方式是設(shè)置一個在回調(diào)中引用該對象的間隔或超時。要查看實(shí)際應(yīng)用,可更新 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"); } };
現(xiàn)在,當(dāng)重復(fù) 上一節(jié) 中的第 1-9 步時,您應(yīng)在第三個快照中看到,Leaker 對象被持久化,并且該間隔會永遠(yuǎn)繼續(xù)運(yùn)行。那么發(fā)生了什么?在一個閉包中引用的任何局部變量都會被該閉包保留,只要該閉包存在就永遠(yuǎn)保留。要確保對 setInterval 方法的回調(diào)在訪問 Leaker 實(shí)例的范圍時執(zhí)行,需要將 this 變量分配給局部變量 self,這個變量用于從閉包內(nèi)觸發(fā) onInterval。當(dāng) onInterval 觸發(fā)時,它就能夠訪問Leaker 對象中的任何實(shí)例變量(包括它自身)。但是,只要事件偵聽器存在,Leaker 對象就不會被垃圾回收。
要解決此問題,可在清空所存儲的 leaker 對象引用之前,觸發(fā)添加到該對象的 destroy 方法,方法是更新 Destroy 按鈕的單擊處理程序,如清單 5 所示。
清單 5. assets/scripts/main.js
$("#destroy_button").click(function(){ leak.destroy(); leak = null; });
五、銷毀對象和對象所有權(quán)
一種不錯的做法是,創(chuàng)建一個標(biāo)準(zhǔn)方法來負(fù)責(zé)讓一個對象有資格被垃圾回收。destroy 功能的主要用途是,集中清理該對象完成的具有以下后果的操作的職責(zé):
1、阻止它的引用計(jì)數(shù)下降到 0(例如,刪除存在問題的事件偵聽器和回調(diào),并從任何服務(wù)取消注冊)。
2、使用不必要的 CPU 周期,比如間隔或動畫。
destroy 方法常常是清理一個對象的必要步驟,但在大多數(shù)情況下它還不夠。在理論上,在銷毀相關(guān)實(shí)例后,保留對已銷毀對象的引用的其他對象可調(diào)用自身之上的方法。因?yàn)檫@種情形可能會產(chǎn)生不可預(yù)測的結(jié)果,所以僅在對象即將無用時調(diào)用 destroy 方法,這至關(guān)重要。
一般而言,destroy 方法最佳使用是在一個對象有一個明確的所有者來負(fù)責(zé)它的生命周期時。此情形常常存在于分層系統(tǒng)中,比如 MVC 框架中的視圖或控制器,或者一個畫布呈現(xiàn)系統(tǒng)的場景圖。
六、內(nèi)存泄漏 2:控制臺日志
一種將對象保留在內(nèi)存中的不太明顯的方式是將它記錄到控制臺中。清單 6 更新了 Leaker 類,顯示了此方式的一個示例。
清單 6. assets/scripts/leaker.js
var Leaker = function(){}; Leaker.prototype = { init:function(){ console.log("Leaking an object: %o", this); }, destroy: function(){ } };
可采取以下步驟來演示控制臺的影響。
- 登錄到索引頁面。
- 單擊 Start。
- 轉(zhuǎn)到控制臺并確認(rèn) Leaking 對象已被跟蹤。
- 單擊 Destroy。
- 回到控制臺并鍵入 leak,以記錄全局變量當(dāng)前的內(nèi)容。此刻該值應(yīng)為空。
- 獲取另一個堆快照并過濾 Leaker 對象。您應(yīng)留下一個 Leaker 對象。
- 回到控制臺并清除它。
- 創(chuàng)建另一個堆配置文件。在清理控制臺后,保留 leaker 的配置文件應(yīng)已清除。
控制臺日志記錄對總體內(nèi)存配置文件的影響可能是許多開發(fā)人員都未想到的極其重大的問題。記錄錯誤的對象可以將大量數(shù)據(jù)保留在內(nèi)存中。注意,這也適用于:
1)、在用戶鍵入 JavaScript 時,在控制臺中的一個交互式會話期間記錄的對象。
2)、由 console.log 和 console.dir 方法記錄的對象。
七、內(nèi)存泄漏 3:循環(huán)
在兩個對象彼此引用且彼此保留時,就會產(chǎn)生一個循環(huán),如圖 4 所示。
圖 4. 創(chuàng)建一個循環(huán)的引用
該圖中的一個藍(lán)色 root 節(jié)點(diǎn)連接到兩個綠色框,顯示了它們之間的一個連接
清單 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 對象的實(shí)例化可以修改,如清單 8 所示。
清單 8. assets/scripts/main.js
leak = new Leaker(); leak.init("leaker 1", null);
如果在創(chuàng)建和銷毀對象后執(zhí)行一次堆分析,您應(yīng)該會看到垃圾收集器檢測到了這個循環(huán)引用,并在您選擇 Destroy 按鈕時釋放了內(nèi)存。
但是,如果引入了第三個保留該子對象的對象,該循環(huán)會導(dǎo)致內(nèi)存泄漏。例如,創(chuàng)建一個 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 類是讓其他對象向它注冊,然后從注冊表中刪除自身的對象的簡單示例。盡管這個特殊的類與注冊表毫無關(guān)聯(lián),但這是事件調(diào)度程序和通知系統(tǒng)中的一種常見模式。
將該類導(dǎo)入 index.html 頁面中,放在 leaker.js 之前,如清單 10 所示。
清單 10. index.html
<script src="assets/scripts/registry.js" type="text/javascript"
charset="utf-8"></script>
更新 Leaker 對象,以向注冊表對象注冊該對象本身(可能用于有關(guān)一些未實(shí)現(xiàn)事件的通知)。這創(chuàng)建了一個來自要保留的 leaker 子對象的 root 節(jié)點(diǎn)備用路徑,但由于該循環(huán),父對象也將保留,如清單 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 以設(shè)置注冊表,并將對注冊表的一個引用傳遞給 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();
現(xiàn)在,當(dāng)執(zhí)行堆分析時,您應(yīng)看到每次選擇 Start 按鈕時,會創(chuàng)建并保留 Leaker 對象的兩個新實(shí)例。圖 5 顯示了對象引用的流程。
圖 5. 由于保留引用導(dǎo)致的內(nèi)存泄漏
從表面上看,它像一個不自然的示例,但它實(shí)際上非常常見。更加經(jīng)典的面向?qū)ο罂蚣苤械氖录陕犉鞒3W裱愃茍D 5 的模式。這種類型的模式也可能與閉包和控制臺日志導(dǎo)致的問題相關(guān)聯(lián)。
盡管有多種方式來解決此類問題,但在此情況下,最簡單的方式是更新 Leaker 類,以在銷毀它時銷毀它的子對象。對于本示例,更新destroy 方法(如清單 13 所示)就足夠了。
清單 13. assets/scripts/leaker.js
destroy: function(){ if(this._child !== null){ this._child.destroy(); } this._registry.remove(this); }
有時,兩個沒有足夠緊密關(guān)系的對象之間也會存在循環(huán),其中一個對象管理另一個對象的生命周期。在這樣的情況下,在這兩個對象之間建立關(guān)系的對象應(yīng)負(fù)責(zé)在自己被銷毀時中斷循環(huán)。
結(jié)束語
即使 JavaScript 已被垃圾回收,仍然會有許多方式會將不需要的對象保留在內(nèi)存中。目前大部分瀏覽器都已改進(jìn)了內(nèi)存清理功能,但評估您應(yīng)用程序內(nèi)存堆的工具仍然有限(除了使用 Google Chrome)。通過從簡單的測試案例開始,很容易評估潛在的泄漏行為并確定是否存在泄漏。
不經(jīng)過測試,就不可能準(zhǔn)確度量內(nèi)存使用。很容易使循環(huán)引用占據(jù)對象曲線圖中的大部分區(qū)域。Chrome 的 Heap Profiler 是一個診斷內(nèi)存問題的寶貴工具,在開發(fā)時定期使用它也是一個不錯的選擇。在預(yù)測對象曲線圖中要釋放的具體資源時請?jiān)O(shè)定具體的預(yù)期,然后進(jìn)行驗(yàn)證。任何時候當(dāng)您看到不想要的結(jié)果時,請仔細(xì)調(diào)查。
在創(chuàng)建對象時要計(jì)劃該對象的清理工作,這比在以后將一個清理階段移植到應(yīng)用程序中要容易得多。常常要計(jì)劃刪除事件偵聽器,并停止您創(chuàng)建的間隔。如果認(rèn)識到了您應(yīng)用程序中的內(nèi)存使用,您將得到更可靠且性能更高的應(yīng)用程序。
相關(guān)文章
ThingJS粒子特效一鍵實(shí)現(xiàn)雨雪效果
在做3D項(xiàng)目時,我們經(jīng)常需要模擬下雨,下雪的天氣,有時也會模擬噴泉、著火等效果。這些效果需要使用名為粒子系統(tǒng)(particle)的技術(shù)來實(shí)現(xiàn)。使用ThingJS可以快速編寫粒子效果,本文就來看看如何實(shí)現(xiàn)2021-05-05使用JavaScript實(shí)現(xiàn)構(gòu)建一個動態(tài)數(shù)據(jù)可視化儀表板
現(xiàn)代Web開發(fā)中,JavaScript不僅是網(wǎng)頁交互的核心,而且已經(jīng)成為實(shí)現(xiàn)復(fù)雜前端功能的重要工具,本文將展示如何使用JavaScript構(gòu)建一個動態(tài)數(shù)據(jù)可視化儀表板,需要的可以參考下2024-02-02uni-app跨端自定義指令實(shí)現(xiàn)按鈕權(quán)限操作
實(shí)現(xiàn)uni-app自定義指令按鈕權(quán)限需要涉及到對于vue.config.js新增loader配置,基礎(chǔ)正則知識,webpack的loader開發(fā)和調(diào)試,以及npm本地調(diào)試和發(fā)布,接下來就從了解這些前置知識開始,需要的朋友可以參考下2023-01-01JavaScript實(shí)現(xiàn)點(diǎn)擊按鈕字體放大、縮小
字體可以調(diào)節(jié)大小,極大了滿足了用戶體驗(yàn)度,接下來通過本文給大家介紹JavaScript實(shí)現(xiàn)點(diǎn)擊按鈕字體放大、縮小實(shí)例代碼,需要的朋友參考下吧2016-02-02JavaScript實(shí)現(xiàn)刪除數(shù)組重復(fù)元素的5種常用高效算法總結(jié)
這篇文章主要介紹了JavaScript實(shí)現(xiàn)刪除數(shù)組重復(fù)元素的5種常用高效算法,結(jié)合實(shí)例形式總結(jié)分析了javascript刪除數(shù)組重復(fù)元素的幾種常見操作技巧,需要的朋友可以參考下2018-01-01JavaScript數(shù)據(jù)結(jié)構(gòu)鏈表知識詳解
存儲有序的元素集合,但不同于數(shù)組,鏈表中的元素在內(nèi)存中不是連續(xù)放置的。每個元素由一個存儲元素本身的節(jié)點(diǎn)和一個指向下一個元素的引用(也稱指針或鏈接)組成。下面通過本文給大家詳細(xì)介紹下,需要的朋友參考下2016-11-11javascript實(shí)現(xiàn)移動端 HTML5 圖片上傳預(yù)覽和壓縮功能示例
這篇文章主要介紹了javascript實(shí)現(xiàn)移動端 HTML5 圖片上傳預(yù)覽和壓縮功能,結(jié)合實(shí)例形式分析了javascript移動端 HTML5 圖片上傳預(yù)覽和壓縮功能具體實(shí)現(xiàn)方法與操作注意事項(xiàng),需要的朋友可以參考下2020-05-05微信小程序 函數(shù)防抖 解決重復(fù)點(diǎn)擊消耗性能問題實(shí)現(xiàn)代碼
這篇文章主要介紹了微信小程序使用函數(shù)防抖解決重復(fù)點(diǎn)擊消耗性能問題實(shí)現(xiàn)代碼,文中通過示例代碼介紹的非常詳細(xì),對大家的學(xué)習(xí)或者工作具有一定的參考學(xué)習(xí)價(jià)值,需要的朋友可以參考下2019-09-09