JavaScript垃圾回收機制


一、垃圾回收的必要性

  下面這段話引自《JavaScript權威指南(第四版)》

  由於字符串、對象和數組沒有固定大小,所有當他們的大小已知時,才能對他們進行動態的存儲分配。JavaScript程序每次創建字符串、數組或對象時,解釋器都必須分配內存來存儲那個實體。只要像這樣動態地分配了內存,最終都要釋放這些內存以便他們能夠被再用,否則,JavaScript的解釋器將會消耗完系統中所有可用的內存,造成系統崩潰。

  這段話解釋了為什么需要系統需要垃圾回收,JS不像C/C++,他有自己的一套垃圾回收機制(Garbage Collection)。JavaScript的解釋器可以檢測到何時程序不再使用一個對象了,當他確定了一個對象是無用的時候,他就知道不再需要這個對象,可以把它所占用的內存釋放掉了。例如:

var a = "before";
var b = "override a";
var a = b; //重寫a

  這段代碼運行之后,“before”這個字符串失去了引用(之前是被a引用),系統檢測到這個事實之后,就會釋放該字符串的存儲空間以便這些空間可以被再利用。

 

二、垃圾回收原理淺析

  現在各大瀏覽器通常用采用的垃圾回收有兩種方法:標記清除、引用計數。

1、標記清除

  這是javascript中最常用的垃圾回收方式。當變量進入執行環境是,就標記這個變量為“進入環境”。從邏輯上講,永遠不能釋放進入環境的變量所占用的內存,因為只要執行流進入相應的環境,就可能會用到他們。當變量離開環境時,則將其標記為“離開環境”。
  垃圾收集器在運行的時候會給存儲在內存中的所有變量都加上標記。然后,它會去掉環境中的變量以及被環境中的變量引用的標記。而在此之后再被加上標記的變量將被視為准備刪除的變量,原因是環境中的變量已經無法訪問到這些變量了。最后。垃圾收集器完成內存清除工作,銷毀那些帶標記的值,並回收他們所占用的內存空間。

關於這一塊,建議讀讀Tom大叔的幾篇文章,關於作用域鏈的一些知識詳解,讀完差不多就知道了,哪些變量會被做標記。

2、引用計數

  另一種不太常見的垃圾回收策略是引用計數。引用計數的含義是跟蹤記錄每個值被引用的次數。當聲明了一個變量並將一個引用類型賦值給該變量時,則這個值的引用次數就是1。相反,如果包含對這個值引用的變量又取得了另外一個值,則這個值的引用次數就減1。當這個引用次數變成0時,則說明沒有辦法再訪問這個值了,因而就可以將其所占的內存空間給收回來。這樣,垃圾收集器下次再運行時,它就會釋放那些引用次數為0的值所占的內存。
        
但是用這種方法存在着一個問題,下面來看看代碼:

function problem() {
    var objA = new Object();
    var objB = new Object();

    objA.someOtherObject = objB;
    objB.anotherObject = objA;
}

  在這個例子中,objA和objB通過各自的屬性相互引用;也就是說這兩個對象的引用次數都是2。在采用引用計數的策略中,由於函數執行之后,這兩個對象都離開了作用域,函數執行完成之后,objA和objB還將會繼續存在,因為他們的引用次數永遠不會是0。這樣的相互引用如果說很大量的存在就會導致大量的內存泄露。
        
  我們知道,IE中有一部分對象並不是原生JavaScript對象。例如,其BOM和DOM中的對象就是使用C++以COM(Component Object 
Model,組件對象)對象的形式實現的,而COM對象的垃圾回收器就是采用的引用計數的策略。因此,即使IE的Javascript引擎使用標記清除的策略來實現的,但JavaScript訪問的COM對象依然是基於引用計數的策略的。說白了,只要IE中涉及COM對象,就會存在循環引用的問題。看看下面的這個簡單的例子:

var element = document.getElementById("some_element");
var myObj =new Object();
myObj.element = element;
element.someObject = myObj;

  上面這個例子中,在一個DOM元素(element)與一個原生JavaScript對象(myObj)之間建立了循環引用。其中,變量myObj有一個名為element的屬性指向element;而變量element有一個名為someObject的屬性回指到myObj。由於循環引用,即使將例子中的DOM從頁面中移除,內存也永遠不會回收。
        
  不過上面的問題也不是不能解決,我們可以手動切斷他們的循環引用。

myObj.element = null;
element.someObject =null;

  這樣寫代碼的話就可以解決循環引用的問題了,也就防止了內存泄露的問題。

 

三、減少JavaScript中的垃圾回收

  首先,最明顯的,new關鍵字就意味着一次內存分配,例如 new Foo()。最好的處理方法是:在初始化的時候新建對象,然后在后續過程中盡量多的重用這些創建好的對象。

另外還有以下三種內存分配表達式(可能不像new關鍵字那么明顯了):

  • {} (創建一個新對象)
  • [] (創建一個新數組)
  • function() {…} (創建一個新的方法,注意:新建方法也會導致垃圾收集!!)

1、對象object優化

  為了最大限度的實現對象的重用,應該像避使用new語句一樣避免使用{}來新建對象。

  {“foo”:”bar”}這種方式新建的帶屬性的對象,常常作為方法的返回值來使用,可是這將會導致過多的內存創建,因此最好的解決辦法是:每一次函數調用完成之后,將需要返回的數據放入一個全局的對象中,並返回此全局對象。如果使用這種方式,就意味着每一次方法調用都會導致全局對象內容的修改,這有可能會導致錯誤的發生。因此,一定要對此全局對象的使用進行詳細的注釋和說明。

  有一種方式能夠保證對象(確保對象prototype上沒有屬性)的重復利用,那就是遍歷此對象的所有屬性,並逐個刪除,最終將對象清理為一個空對象。

  cr.wipe(obj)方法就是為此功能而生,代碼如下: 

// 刪除obj對象的所有屬性,高效的將obj轉化為一個嶄新的對象!
cr.wipe = function (obj) {
    for (var p in obj) {
         if (obj.hasOwnProperty(p))
            delete obj[p];
    }
};        

  有些時候,你可以使用cr.wipe(obj)方法清理對象,再為obj添加新的屬性,就可以達到重復利用對象的目的。雖然通過清空一個對象來獲取“新對象”的做法,比簡單的通過{}來創建對象要耗時一些,但是在實時性要求很高的代碼中,這一點短暫的時間消耗,將會有效的減少垃圾堆積,並且最終避免垃圾回收暫停,這是非常值得的!

2、數組array優化

  將[]賦值給一個數組對象,是清空數組的捷徑(例如: arr = [];),但是需要注意的是,這種方式又創建了一個新的空對象,並且將原來的數組對象變成了一小片內存垃圾!實際上,將數組長度賦值為0(arr.length = 0)也能達到清空數組的目的,並且同時能實現數組重用,減少內存垃圾的產生。

 

3、方法function優化

  方法一般都是在初始化的時候創建,並且此后很少在運行時進行動態內存分配,這就使得導致內存垃圾產生的方法,找起來就不是那么容易了。但是從另一角度來說,這更便於我們尋找了,因為只要是動態創建方法的地方,就有可能產生內存垃圾。例如:將方法作為返回值,就是一個動態創建方法的實例。

  在游戲的主循環中,setTimeout或requestAnimationFrame來調用一個成員方法是很常見的,例如:

setTimeout(
    (function(self) {                    
      return function () {
              self.tick();
    };
})(this), 16)

  每過16毫秒調用一次this.tick(),嗯,乍一看似乎沒什么問題,但是仔細一琢磨,每一次調用都返回了一個新的方法對象,這就導致了大量的方法對象垃圾!

  為了解決這個問題,可以將作為返回值的方法保存起來,例如:

// at startup
this.tickFunc = (
    function(self) {
      return function() {
                self.tick();
      };
    }
)(this);

// in the tick() function
setTimeout(this.tickFunc, 16);

  相比於每次都新建一個方法對象,這種方式在每一幀當中重用了相同的方法對象。這種方式的優勢是顯而易見的,而這種思想也可以應用在任何以方法為返回值或者在運行時創建方法的情況當中。

 

4、高級技術

  從根本上來說,javascript本身就是圍繞着垃圾收集來設計的。隨着我們工作的進行,避免內存垃圾變得越來越困難。因為很多方便實用的Javascript庫方法也會產生一些新的對象。對於這些庫方法產生的垃圾,我們束手無策,只能重新翻看文檔,並且檢查方法的返回值。例如,數組的slice方法返回一個新的數組(在不修改原數組的基礎上,截取出一部分作為新數組),字符串的substr方法返回一個新的字符串(在不修改原字符串的基礎上,截取出一部分字符串作為返回值)等等。

  調用這些庫方法,將會創建內存垃圾,而你能做的,只有避免調用這些方法,或者用不創建系統垃圾的方式重寫這些方法(有點極端啦~)。

  例如,在Construct 2引擎中,從數組中利用下標來刪除一個元素,是經常進行的操作。最初我們是用下面這種方式來實現的:

var sliced = arr.slice(index + 1);
arr.length = index;
arr.push.apply(arr, sliced);

  然而,slice方法會返回一個新的數組對象(數組中的元素是原數組中刪掉的部分),並且會通過arr.push.apply方法將元素重新復制回原數組,但是在此操作之后,該數組就成為了一片內存垃圾。由於這是我們引擎中的垃圾產生的熱點代碼(使用頻率非常很高),因此我們利用了迭代的方式重寫了上述代碼:

for (var i = index, len = arr.length – 1; i < len; i++)
    arr[i] = arr[i + 1];
arr.length = len;

  顯然,重寫大量的庫函數是非常痛苦的,因此你必須仔細權衡方法的易用性和內存垃圾產生情況。如果產生大量內存垃圾的方法在動畫的每一幀中被多次調用,你可能就會興高采烈的重寫庫函數啦。

  在遞歸函數中,通過{}構造空對象,並在遞歸過程中傳遞數據,雖然是很方便的。但是更好的方式是:利用一個單獨的數組對象作為堆棧,在遞歸過程中對數組進行push和pop操作。更進一步,不要調用array的pop方法(pop將會使得array的最后一個元素將會變成內存垃圾),而應該使用一個索引來記錄數組的最后一個元素的位置,在pop時簡單的將索引減一即可;類似的,將索引加1來代替array的push操作,只有當索引對應的元素不存在時,才執行真正的push為數組加入一個新元素。

  另外,在任何時候,都應該避免使用向量對象(例如:包含x和y屬性的vector2對象)。有些方法將向量對象作為方法返回值,既可以支持返回值的再次修改,又能夠將需要的屬性一次性返回,使用起來非常方便。但是有時候在一幀動畫中,創建了成百上千個這樣的向量對象,從而導致嚴重的垃圾回收性能問題,也是非常常見的。因此最好將這些方法分離成具有獨立職責的功能個體,例如:利用getX()和getY()方法(返回具體數據)代替getPosition()方法(返回一個vector2對象)。

 

四、總結

  在Javascript中,徹底避免垃圾回收是非常困難的。垃圾回收機制與實時軟件(例如:游戲)的實時性要求,從根本上就是對立的。

  但是,為了減少內存垃圾,我們還是可以對javascript代碼進行徹底檢查,有些代碼中存在明顯的產生過多內存垃圾的問題代碼,這些正是我們需要檢查並且完善的。

  我認為,只要我們投入更多的精力和關注,實現實時的、低垃圾收集的javascript應用還是很有可能的。畢竟,對於可交互性要求較高的游戲或應用來說,實時性和低垃圾收集,兩者都是至關重要。

 

五、參考資料

  1. 人人FED

  2. Jeff Wong

  3. 《JavaScript權威指南(第四版)》


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM