在 JavaScript 中,因爲垃圾回收是自動進行的,因此人們在編碼時可能不太會注意這方面。但事實是,一些 webapp 在使用一段時間後,會出現卡頓的現象,特別是那些單頁應用,包括 WebView 方式的手機 app 。這個現象在傳統的「單擊 - 刷新」類型的頁面中並不明顯,由於頁面刷新以後,全部沒有被回收的垃圾對象也會被清除,可是在單頁應用中,若是沒有手動去點瀏覽器的刷新按鈕,那麼就算是很小的內存泄露,隨着頁面停留時間的增加,累積的泄露會愈來愈多,在手機上的感受就更明顯了。javascript
因此這裏想討論一下內存泄露是如何發生的,以及如何去避免。java
開門見山,通常有兩種方式的垃圾回收機制,一個是「引用計數」,當一個對象被引用的次數爲 0 時,該對象就能夠被回收;另外一個是「標記清除」,當一個對象不能再被訪問到時,對該對象進行標記,等下一輪 GC 事件發生時,這些對象就會被清除。從 2012 年起,全部的現代瀏覽器都是基於「標記清除」的回收算法,因此,若是須要兼容更早的瀏覽器,可能須要作更多的事。GC 的時機由 JS 引擎決定,須要知道的事,當 GC 進行時,主線程會被阻塞,這個時間能夠經過 Chrome 的 Timeline 工具看到,最少也會超過 10 ms 吧。web
在 Chrome 中能夠很直觀方便地看到垃圾回收事件的執行。打開 Chrome 的 Timeline,只須要勾選「Memory」就能夠了,而且在左邊的 View 中選中第二個。算法
而後單擊放大鏡下面的圓點,這時候 Chrome 會開始記錄內存分配、繪製等事件,等你打開一張頁面,好比百度吧,再單擊這個圓點(如今應該是紅色的了),就會看到一條藍色的折線。不一樣頁面不同,但幾乎都會有一個忽然降低的地方,好比下圖中 1200 ms 左邊的地方,單擊它,就能在下方顯示 GC 事件所用的時間,以及它回收了多少內存。瀏覽器
若是你看到本身網站的這條藍色折線是呈上升趨勢,在不斷的 GC 後,內存仍是在上升,就極有多是發生了內存泄露,須要排查一下代碼。閉包
這裏的問題在於「循環引用」,若是對象 a 的屬性引用了 b,而 b 的屬性引用了 a,因爲引擎只有在變量的引用次數爲 0 的狀況下才會回收,這裏 a 和 b 的引用次數至少有 1,因此就算它們所在的函數執行完了,這兩個變量仍是沒法被回收掉。app
function foo() { var a = {}, b = {}; a.attr = b; b.attr = a; } foo();
當 foo 函數執行不少次以後,就會有不少個沒法被回收的 a 和 b 存在。webapp
實際狀況多是這樣的:函數
function foo() { var text = document.getElementById('input-text'); text.onfocus = function() { text.value = ''; } } foo();
意思是,當光標移到輸入框時,清空原有的內容。考慮 text 變量和 foo 裏面的匿名函數,text 的 onfocus 屬性引用了匿名函數,而該匿名函數引用了 text 變量(循環了),因此當 foo 執行結束後,這兩個對象因爲引用次數大於 0 而沒法被回收。工具
對於這種狀況,只須要在 foo 的末尾對 text 變量置空就能夠了。
text = null;
若是你用 Chrome 運行這個例子的話,會看到藍線仍是降到初始的高度了,由於 Chrome 是基於「標記清除」的算法來回收內存的,因此不會有「循環引用」的問題。
對於標記清除,心中要想象一個樹,每一個頁面都存在一個根,每當一個函數執行,就會生成一個節點。天然,嵌套的函數調用就會有子節點。通常狀況下(沒有閉包),當函數執行完時,內部的變量都是沒法被其餘代碼訪問的,因此它就被標記爲「沒法被訪問」。GC 時,JS 引擎統一對全部這些狀態的對象進行回收。
介紹兩個概念。Shallow Size,表示該對象自己佔用的內存。Retained Size,表示釋放該對象後能獲得的內存大小。什麼意思?好比上圖綠色的 #3,這個綠色的面積就是 Shallow Size。釋放 #3 後,#4 和 #5 也會被釋放,因此 Retained Size 就是 #三、#四、#5 的總大小。
在「標記清除」算法中,難點是如何判斷一個對象已是「沒法被訪問」了。
若是用樹去分析垃圾回收,會發現其實咱們須要作的事情不多,由於當一個函數執行完以後,它連帶的對象都會被清除。就算有閉包,當引用該閉包的函數執行完時,這些閉包也一樣會被標記。
那麼在哪裏會發生內存泄露呢?看這個例子。
var btn = document.getElementById('btn'); btn.onclick = function() { var fragment = document.createElement('div'); }
它表示每單擊一次按鈕,就建立一個 <div>
,它沒有引用任何對象,可是回調結束以後,這個空的 <div>
是不會被回收的。
var content = document.getElementById('content'); content.innerHTML = '<button id="button">click</button>'; var button = document.getElementById('button'); button.addEventListener('click', function() {}); content.innerHTML = '';
這段代碼事後,雖然 <button>
從 DOM 中移除了,因爲它的監聽器還在,因此沒法被 GC 回收。
要避免這種狀況就是經過 removeEventListener 將回調函數去掉。
若是使用 setInterval,那麼它引用到的變量的上下文會保留下來。
function foo() { var name = 'tom', title = 'Hero'; window.setInterval(function() { alert(name); }, 1000); } foo();
這裏的狀況時,每隔 1 秒彈框一次。第一,雖然只用到了 name,但 name 所在的上下文都沒法被釋放,包括 title 。第二,因爲定時器一直在執行,因此這個上下文是不會被釋放的。固然,有時候這是業務要求,也談不上內存泄露了,只不過要注意的是,若是真的有不必的定時器,請調用 clearInterval 把它去掉吧。
另外一方面,你不用爲了僅僅避免內存泄露對 setTimeout 調用 clearTimeout 。它是不會形成內存泄露的,除非是別的什麼緣由,好比說,在 setTimeout 中遞歸調用了當前定時器,這至關於模擬 setInterval,能夠與 setInterval 作相似處理。
在平時的一些開發過程當中,我發現雖然在 Chrome 中發生了 GC 事件,而且內存也降得很低,若是用 Profile 工具 Take Heap Snapshot 的話,也不會以爲有內存泄露發生。但在手機上(WebView)的確會存在越用越卡的現象,這點可能要根據不一樣的環境來分析,但文中提到的關鍵兩個地方就是:解除引用,以及解除監聽的事件。
若是本身的代碼中能作到這兩點的話,可能卡頓是由別的問題引發的,而不是內存泄露。