JS中的垃圾回收與內存泄漏

JS中的垃圾回收與內存泄漏

1. 介紹

Javascript具有自動垃圾回收機制(GC:Garbage Collecation),也就是說,執行環境會負責管理代碼執行過程中使用的內存。其原理是:垃圾收集器會定期(週期性)找出那些不在繼續使用的變量,然後釋放其內存。但是這個過程不是實時的,因爲其開銷比較大並且GC時停止響應其他操作,所以垃圾回收器會按照固定的時間間隔週期性的執行。

不再使用的變量也就是生命週期結束的變量,當然只可能是局部變量,全局變量的生命週期直至瀏覽器卸載頁面纔會結束。局部變量只在函數的執行過程中存在,而在這個過程中會爲局部變量在棧或堆上分配相應的空間,以存儲它們的值,然後在函數中使用這些變量,直至函數結束,而閉包中由於內部函數的原因,外部函數並不能算是結束。

還是上代碼說明吧:

function fn1() {
    var obj = {name: 'hanzichi', age: 10};
}
function fn2() {
    var obj = {name:'hanzichi', age: 10};
    return obj;
}

var a = fn1();
var b = fn2();

我們來看代碼是如何執行的。首先定義了兩個function,分別叫做fn1和fn2,當fn1被調用時,進入fn1的環境,會開闢一塊內存存放對象{name: 'hanzichi', age: 10},而當調用結束後,出了fn1的環境,那麼該塊內存會被js引擎中的垃圾回收器自動釋放;在fn2被調用的過程中,返回的對象被全局變量b所指向,所以該塊內存並不會被釋放。

這裏問題就出現了:到底哪個變量是沒有用的?所以垃圾收集器必須跟蹤到底哪個變量沒用,對於不再有用的變量打上標記,以備將來收回其內存。用於標記的無用變量的策略可能因實現而有所區別,通常情況下有兩種實現方式:標記清除引用計數。引用計數不太常用,標記清除較爲常用。

2. 標記清除

js中最常用的垃圾回收方式就是標記清除。當變量進入環境時,例如,在函數中聲明一個變量,就將這個變量標記爲“進入環境”。從邏輯上講,永遠不能釋放進入環境的變量所佔用的內存,因爲只要執行流進入相應的環境,就可能會用到它們。而當變量離開環境時,則將其標記爲“離開環境”。

function test(){
    var a = 10 ;             //被標記 ,進入環境 
    var b = 20 ;             //被標記 ,進入環境
}
test();                     //執行完畢 之後 a、b又被標離開環境,被回收。

垃圾回收器在運行的時候會給存儲在內存中的所有變量都加上標記(當然,可以使用任何標記方式)。然後,它會去掉環境中的變量以及被環境中的變量引用的變量的標記(閉包)。而在此之後再被加上標記的變量將被視爲準備刪除的變量,原因是環境中的變量已經無法訪問到這些變量了。最後,垃圾回收器完成內存清除工作,銷燬那些帶標記的值並回收它們所佔用的內存空間。 到目前爲止,IE9+、Firefox、Opera、Chrome、Safari的js實現使用的都是標記清除的垃圾回收策略或類似的策略,只不過垃圾收集的時間間隔互不相同。

3. 引用計數

引用計數的含義是跟蹤記錄每個值被引用的次數。當聲明瞭一個變量並將一個引用類型值賦給該變量時,則這個值的引用次數就是1。如果同一個值又被賦給另一個變量,則該值的引用次數加1。相反,如果包含對這個值引用的變量又取得了另外一個值,則這個值的引用次數減1。當這個值的引用次數變成0時,則說明沒有辦法再訪問這個值了,因而就可以將其佔用的內存空間回收回來。這樣,當垃圾回收器下次再運行時,它就會釋放那些引用次數爲0的值所佔用的內存。

function test(){
    var a = {} ;         //a的引用次數爲0 
    var b = a ;         //a的引用次數加1,爲1 
    var c =a;           //a的引用次數再加1,爲2
    var b ={};          //a的引用次數減1,爲1
}

Netscape Navigator3是最早使用引用計數策略的瀏覽器,但很快它就遇到一個嚴重的問題:循環引用。循環引用指的是對象A中包含一個指向對象B的指針,而對象B中也包含一個指向對象A的引用。

function fn() {
    var a = {};
    var b = {};
    a.pro = b;
    b.pro = a;
}
fn();

  以上代碼a和b的引用次數都是2,fn()執行完畢後,兩個對象都已經離開環境,在標記清除方式下是沒有問題的,但是在引用計數策略下,因爲a和b的引用次數不爲0,所以不會被垃圾回收器回收內存,如果fn函數被大量調用,就會造成內存泄露。在IE7與IE8上,內存直線上升。

我們知道,IE中有一部分對象並不是原生js對象。例如,其內存泄露DOM和BOM中的對象就是使用C++以COM對象的形式實現的,而COM對象的垃圾回收機制採用的就是引用計數策略。因此,即使IE的js引擎採用標記清除策略來實現,但js訪問的COM對象依然是基於引用計數策略的。換句話說,只要在IE中涉及COM對象,就會存在循環引用的問題。

var element = document.getElementById("some_element");
var myObject = new Object();
myObject.e = element;
element.o = myObject;

  這個例子在一個DOM元素(element)與一個原生js對象(myObject)之間創建了循環引用。其中,變量myObject有一個屬性e指向element對象;而變量element也有一個屬性o回指myObject。由於存在這個循環引用,即使例子中的DOM從頁面中移除,它也永遠不會被回收。

舉個栗子:

  • 黃色是指直接被 js變量所引用,在內存裏
  • 紅色是指間接被 js變量所引用,如上圖,refB 被 refA 間接引用,導致即使 refB 變量被清空,也是不會被回收的
  • 子元素 refB 由於 parentNode 的間接引用,只要它不被刪除,它所有的父元素(圖中紅色部分)都不會被刪除

另一個例子:

window.onload=function outerFunction(){
    var obj = document.getElementById("element");
    obj.onclick=function innerFunction(){};
};

這段代碼看起來沒什麼問題,但是obj引用了document.getElementById('element'),而document.getElementById('element')的onclick方法會引用外部環境中的變量,自然也包括obj,是不是很隱蔽啊。(在比較新的瀏覽器中在移除Node的時候已經會移除其上的event了,但是在老的瀏覽器,特別是ie上會有這個bug)

解決辦法:

最簡單的方式就是自己手工解除循環引用,比如剛纔的函數可以這樣

myObject.element = null;
element.o = null;

window.onload=function outerFunction(){
    var obj = document.getElementById("element");
    obj.onclick=function innerFunction(){};
    obj=null;
};

將變量設置爲null意味着切斷變量與它此前引用的值之間的連接。當垃圾回收器下次運行時,就會刪除這些值並回收它們佔用的內存。

要注意的是,IE9+並不存在循環引用導致Dom內存泄露問題,可能是微軟做了優化,或者Dom的回收方式已經改變

4. 內存管理

4.1 什麼時候觸發垃圾回收?

垃圾回收器週期性運行,如果分配的內存非常多,那麼回收工作也會很艱鉅,確定垃圾回收時間間隔就變成了一個值得思考的問題。IE6的垃圾回收是根據內存分配量運行的,當環境中存在256個變量、4096個對象、64k的字符串任意一種情況的時候就會觸發垃圾回收器工作,看起來很科學,不用按一段時間就調用一次,有時候會沒必要,這樣按需調用不是很好嗎?但是如果環境中就是有這麼多變量等一直存在,現在腳本如此複雜,很正常,那麼結果就是垃圾回收器一直在工作,這樣瀏覽器就沒法兒玩兒了。

微軟在IE7中做了調整,觸發條件不再是固定的,而是動態修改的,初始值和IE6相同,如果垃圾回收器回收的內存分配量低於程序佔用內存的15%,說明大部分內存不可被回收,設的垃圾回收觸發條件過於敏感,這時候把臨街條件翻倍,如果回收的內存高於85%,說明大部分內存早就該清理了,這時候把觸發條件置回。這樣就使垃圾回收工作職能了很多

4.2 合理的GC方案

1. 基礎方案

Javascript引擎基礎GC方案是(simple GC):mark and sweep(標記清除),即:

  1. 遍歷所有可訪問的對象。
  2. 回收已不可訪問的對象。

2. GC的缺陷

和其他語言一樣,javascript的GC策略也無法避免一個問題:GC時,停止響應其他操作,這是爲了安全考慮。而Javascript的GC在100ms甚至以上,對一般的應用還好,但對於JS遊戲,動畫對連貫性要求比較高的應用,就麻煩了。這就是新引擎需要優化的點:避免GC造成的長時間停止響應。

3. GC優化策略

David大叔主要介紹了2個優化方案,而這也是最主要的2個優化方案了:

  1. 分代回收(Generation GC) 這個和Java回收策略思想是一致的,也是V8所主要採用的。目的是通過區分“臨時”與“持久”對象;多回收“臨時對象”區(young generation),少回收“持久對象”區(tenured generation),減少每次需遍歷的對象,從而減少每次GC的耗時。如圖:這裏需要補充的是:對於tenured generation對象,有額外的開銷:把它從young generation遷移到tenured generation,另外,如果被引用了,那引用的指向也需要修改。 這裏主要內容可以參考深入淺出Node中關於內存的介紹,很詳細~
  2. 增量GC 這個方案的思想很簡單,就是“每次處理一點,下次再處理一點,如此類推”。如圖:這種方案,雖然耗時短,但中斷較多,帶來了上下文切換頻繁的問題。 因爲每種方案都其適用場景和缺點,因此在實際應用中,會根據實際情況選擇方案。 比如:低 (對象/s) 比率時,中斷執行GC的頻率,simple GC更低些;如果大量對象都是長期“存活”,則分代處理優勢也不大。

5. vue中的內存泄漏問題

5.1 泄漏點

  1. DOM/BOM 對象泄漏
  2. script 中存在對DOM/BOM 對象的引用導致
  3. js 對象泄漏
  4. 通常由閉包導致,比如事件處理回調,導致DOM對象和腳本中對象雙向引用,這個時常見的泄漏原因

5.2 代碼關注點

  1. DOM中的 addEventLisner 函數及派生的事件監聽, 比如 Jquery 中的 on 函數, vue 組件實例的 $on 函數,第三方庫中的初始化函數
  2. 其它BOM對象的事件監聽, 比如websocket 實例的on 函數
  3. 避免不必要的函數引用
  4. 如果使用 render 函數,避免在html標籤中綁定DOM/BOM 事件

5.3 如何處理

  1. 如果在mounted/created 鉤子中綁定了 DOM/BOM 對象中的事件,需要在 beforeDestroy 中做對應解綁處理
  2. 如果在mounted/created 鉤子中使用了第三方庫初始化,需要在beforeDestroy 中做對應銷燬處理
  3. 如果組件中使用了定時器,需要在beforeDestroy中做對應銷燬處理
  4. 模板中不要使用表達式來綁定到特定的處理函數,這個邏輯應該放在處理函數中?
  5. 如果在mounted/created 鉤子中使用了$on,需要在beforeDestroy 中做對應解綁$off處理
  6. 某些組件在模板中使用事件綁定可能會出現泄漏,使用$on 替換模板中的綁定

5.4 在vue組件中處理addEventListener

created/mounted 生命期鉤子函數中定義事件響應函數爲對象實例的方法,使用 => 函數來綁定作用域 調用 addEventListener 添加事件監聽後在 beforeDestroy 中調用 removeEventListener 移除對應的事件監聽,注意前面定義的響應函數方法需要作爲第二個參數傳入 然後用 delete 從對象實例移除定義的響應方法,或者將屬性設置爲 null/undefined 爲了準確移除監聽,不要使用匿名函數或者已有的函數的綁定來直接作爲事件監聽函數 具體例子請參考如下代碼

mounted() {
    const box = document.getElementById('time-line')
    this.width = box.offsetWidth
    this.resizefun = () => {
      this.width = box.offsetWidth
    }
    window.addEventListener('resize', this.resizefun)
  },
  beforeDestroy() {
    window.removeEventListener('resize', this.resizefun)
    this.resizefun = null
  }

5.5 觀察者模式引起的內存泄漏

在spa應用中使用觀察者模式的時候如果給觀察者註冊了被觀察的方法,而沒有在離開組件的時候及時移除,可能造成重複註冊而內存泄漏;

舉個栗子: 進入組件的時候ob.addListener("enter", _func),如果離開組件beforeDestroy的時候沒有ob.removeListener("enter", _func),就會導致內存泄漏

更詳細的栗子參考:德州撲克栗子

5.6 上下文綁定引起的內存泄漏

有時候使用 bind/apply/call 上下文綁定方法的時候,會有內存泄漏的隱患。

var ClassA = function(name) {
  this.name = name
  this.func = null
}

var a = new ClassA("a")
var b = new ClassA("b")

b.func = bind(function() {
  console.log("I am " + this.name)
}, a)

b.func()    // 輸出: I am a

a = null        // 釋放a
//b = null;        // 釋放b
//b.func = null;        // 釋放b.func

function bind(func, self) {        //模擬上下文綁定
  return function() {
    return func.apply(self)
  }
}

使用chrome dev tool > memory > profiles 查看內存中ClassA的實例數,發現有兩個實例,a和b。雖然a設置成null了,但是b的方法中bind的閉包上下文self綁定了a,因此雖然a釋放,但是b/b.func沒有釋放,閉包的self一直存在並保持對a的引用。


網上的帖子大多深淺不一,甚至有些前後矛盾,在下的文章都是學習過程中的總結,如果發現錯誤,歡迎留言指出~

如果你也熱愛分享知識,那就加入我們把,QQ羣號:431679880 ~

參考: 跟我學習javascript的垃圾回收機制與內存管理 App之性能優化 Vue Web App 內存泄漏-調試和分析 搞定JavaScript內存泄漏 推介閱讀: 雅虎網站頁面性能優化的34條黃金守則 用 Chrome 開發者工具分析 javascript 的內存回收(GC) JS內存泄漏排查方法——Chrome Profiles

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章