天天看點

深入了解浏覽器垃圾回收機制

1. JavaScript 記憶體管理機制

計算機程式語言都運作在對應的代碼引擎上,其使用記憶體過程可以分為以下三個步驟:

  1. 配置設定所需要的系統記憶體空間;
  2. 使用配置設定到的記憶體進行讀或寫等操作;
  3. 不需要使用記憶體時,将其空間釋放或者歸還。

在 JavaScript 中,當建立變量時,系統會自動給對象配置設定對應的記憶體,來看下面的例子:

var a = 123; // 給數值變量配置設定棧記憶體
var etf = "ARK"; // 給字元串配置設定棧記憶體
// 給對象及其包含的值配置設定堆記憶體
var obj = {
  name: 'tom',
  age: 13
}; 
// 給數組及其包含的值配置設定記憶體
var a = [1, null, "PSAC"]; 
// 給函數(可調用的對象)配置設定記憶體
function sum(a, b){
  return a + b;
}      

當系統發現這些變量不會再被使用時,會通過垃圾回收機制的方式來處理掉這些變量所占用的記憶體,而開發者不用過多關心記憶體問題。不過,在開發過程中也需要注意 JavaScript 的記憶體管理機制,以避免一些不必要的問題。

在 JavaScript 中資料類型分為兩類:簡單類型和引用類型:

  • 基本類型:這些類型在記憶體中會占據固定的記憶體空間,它們的值都儲存在棧空間中,直接可以通過值來通路這些;
  • 引用類型:由于引用類型值大小不固定(比如上面的對象可以添加屬性等),棧記憶體中存放位址指向堆記憶體中的對象,是通過引用來通路的。

棧記憶體中的基本類型,可以通過作業系統直接處理;而堆記憶體中的引用類型,正是由于可以經常變化,大小不固定,是以需要 JavaScript 的引擎通過垃圾回收機制來處理。

所謂的垃圾回收是指:JavaScript代碼運作時,需要配置設定記憶體空間來儲存變量和值。當變量不在參與運作時,就需要系統收回被占用的記憶體空間。

Javascript 具有自動垃圾回收機制,會定期對那些不再使用的變量、對象所占用的記憶體進行釋放,原理就是找到不再使用的變量,然後釋放掉其占用的記憶體。

JavaScript中存在兩種變量:局部變量和全局變量。全局變量的生命周期會持續要頁面解除安裝;而局部變量聲明在函數中,它的生命周期從函數執行開始,直到函數執行結束,在這個過程中,局部變量會在堆或棧中存儲它們的值,當函數執行結束後,這些局部變量不再被使用,它們所占有的空間就會被釋放。不過,當局部變量被外部函數使用時,其中一種情況就是閉包,在函數執行結束後,函數外部的變量依然指向函數内部的局部變量,此時局部變量依然在被使用,是以不會回收。

2. Chrome 記憶體回收機制

Chrome浏覽器的垃圾回收機制如下:

1)第⼀步,通過 GC Root 标記空間中活動對象和⾮活動對象。

⽬前 V8 采⽤的可通路性(reachability)算法來判斷堆中的對象是否是活動對象。這個算法是将⼀些 GC Root 作為初始存活的對象的集合,從 GC Roots 對象出發,周遊 GC Root 中所有對象:

  • 通過 GC Root 周遊到的對象是可通路的(reachable),那麼必須保證這些對象應該在記憶體中保留,可通路的對象稱為活動對象;
  • 通過 GC Roots 沒有周遊到的對象是不可通路的(unreachable),那麼這些不可通路的對象就可能被回收,不可通路的對象稱為⾮活動對象。

在浏覽器環境中,GC Root 有很多,通常包括了以下⼏種:

  • 全局的 window 對象(位于每個 iframe 中);
  • 文檔 DOM 樹,由可以通過周遊文檔到達的所有原生 DOM 節點組成;
  • 存放棧上變量

2)第⼆步,回收⾮活動對象所占據的記憶體。 其實就是在所有的标記完成之後,統⼀清理記憶體中所有被标記為可回收的對象。

3)第三步,做記憶體整理。 ⼀般來說,頻繁回收對象後,記憶體中就會存在⼤量不連續空間,這些不連續的記憶體空間稱為記憶體碎⽚。當記憶體中出現了⼤量的記憶體碎⽚之後,如果需要配置設定較⼤的連續記憶體時,就有可能出現記憶體不⾜的情況,是以最後⼀步需要整理這些記憶體碎⽚。這步其實是可選的,因為有的垃圾回收器不會産⽣記憶體碎⽚,⽐如副垃圾回收器。

以上就是⼤緻的垃圾回收的流程。⽬前 V8 采⽤了兩個垃圾回收器,主垃圾回收器 -MajorGC 和 副垃圾回收器 -Minor GC (Scavenger)。V8 之是以使⽤了兩個垃圾回收器,主要是受到了代際假說(The Generational Hypothesis)的影響。 代際假說是垃圾回收領域中⼀個重要的術語,它有以下兩個特點:

  • 第⼀個是⼤部分對象都是“朝⽣夕死”的,也就是說⼤部分對象在記憶體中存活的時間很 短,⽐如函數内部聲明的變量,或者塊級作⽤域中的變量,當函數或者代碼塊執⾏結束時,作⽤域中定義的變量就會被銷毀。是以這⼀類對象⼀經配置設定記憶體,很快就變得不可訪 問;
  • 第⼆個是不死的對象,會活得更久,⽐如全局的 window、DOM、Web API 等對象。

其實這兩個特點不僅僅适⽤于 JavaScript,同樣适⽤于⼤多數的動态語⾔,如 Java、Python 等。V8 的垃圾回收政策,就是建⽴在該假說的基礎之上的。

接下來,來看看 V8 是如何實作垃圾回收的。

如果隻使⽤⼀個垃圾回收器,在優化⼤多數新對象的同時,就很難優化到那些⽼對象,是以需要權衡各種場景,根據對象⽣存周期的不同,⽽使⽤不同的算法,以便達到最好的效果。 是以,在 V8 中,會把堆分為新生代和老生代兩個區域,新生代中存放的是生存時間短的對象,老生代中存放生存時間久的對象。

新⽣代通常隻⽀持 1~8M 的容量,⽽⽼⽣代⽀持的容量就⼤很多。對于這兩塊區域,V8分别使⽤兩個不同的垃圾回收器,以便更⾼效地實施垃圾回收:

  • 副垃圾回收器 -Minor GC (Scavenger),主要負責新⽣代的垃圾回收。
  • 主垃圾回收器 -Major GC,主要負責⽼⽣代的垃圾回收。

(1)副垃圾回收器

副垃圾回收器主要負責新⽣代的垃圾回收。通常情況下,⼤多數⼩的對象都會被配置設定到新⽣代,是以說這個區域雖然不⼤,但是垃圾回收還是⽐較頻繁的。 新⽣代中的垃圾資料⽤ Scavenge 算法來處理。所謂 Scavenge 算法,是把新⽣代空間對半劃分為兩個區域,⼀半是對象區域 (from-space),⼀半是空閑區域 (to-space),如下圖所示:

深入了解浏覽器垃圾回收機制

新加⼊的對象都會存放到對象區域,當對象區域快被寫滿時,就需要執⾏⼀次垃圾清理操作。

在垃圾回收過程中,首先要對對象區域中的垃圾做标記;标記完成之後,就進入垃圾清理階段。副垃圾回收器會把這些存活的對象複制到空閑區域中,同時它還會把這些對象有序地排列起來,是以這個複制過程,也就相當于完成了記憶體整理操作,複制後空閑區域就沒有記憶體碎片了:

深入了解浏覽器垃圾回收機制

完成複制後,對象區域與空閑區域進行角色翻轉,也就是原來的對象區域變成空閑區域,原來的空閑區域變成了對象區域。這樣就完成了垃圾對象的回收操作,同時,這種角色翻轉的操作還能讓新生代中的這兩塊區域無限重複使用下去:

深入了解浏覽器垃圾回收機制

不過,副垃圾回收器每次執⾏清理操作時,都需要将存活的對象從對象區域複制到空閑區域,複制操作需要時間成本,如果新⽣區空間設定得太⼤了,那麼每次清理的時間就會過久,是以為了執⾏效率,⼀般新⽣區的空間會被設定得⽐較⼩。

也正是因為新⽣區的空間不⼤,是以很容易被存活的對象裝滿整個區域,副垃圾回收器⼀旦監控對象裝滿了,便執⾏垃圾回收。同時,副垃圾回收器還會采⽤對象晉升政策,也就是移動那些經過兩次垃圾回收依然還存活的對象到⽼⽣代中。

(2)主垃圾回收器

主垃圾回收器主要負責⽼⽣代中的垃圾回收。除了新⽣代中晉升的對象,⼀些⼤的對象會直接被配置設定到⽼⽣代⾥。是以,⽼⽣代中的對象有兩個特點:

  • 對象占⽤空間⼤;
  • 對象存活時間⻓。

由于⽼⽣代的對象⽐較⼤,若要在⽼⽣代中使⽤ Scavenge 算法進⾏垃圾回收,複制這些⼤的對象将會花費⽐較多的時間,從⽽導緻回收執⾏效率不⾼,同時還會浪費⼀半的空間。是以,主垃圾回收器是采⽤**标記 - 清除(Mark-Sweep)**的算法進⾏垃圾回收的。

那麼,标記 - 清除算法是如何⼯作的呢?

1)首先是标記過程階段。 标記階段就是從一組根元素開始,遞歸周遊這組根元素,在這個周遊過程中,能到達的元素稱為活動對象,沒有到達的元素就可以判斷為垃圾資料。

2)接下來是垃圾清除過程。它和副垃圾回收器的垃圾清除過程完全不同,主垃圾回收器會直接将标記為垃圾的資料清理掉。

可以了解這個過程是清除掉下圖中紅⾊标記資料的過程,參考下圖:

深入了解浏覽器垃圾回收機制

對垃圾資料進⾏标記,然後清除,這就是标記 - 清除算法,不過對⼀塊記憶體多次執⾏标記 - 清除算法後,會産⽣⼤量不連續的記憶體碎⽚。⽽碎⽚過多會導緻⼤對象⽆法配置設定到⾜夠的連續記憶體,于是⼜引⼊了另外⼀種算法——标記 - 整理(Mark-Compact)。

這個算法的标記過程仍然與标記 - 清除算法⾥的是⼀樣的,先标記可回收對象,但後續步驟不是直接對可回收對象進⾏清理,⽽是讓所有存活的對象都向⼀端移動,然後直接清理掉這⼀端之外的記憶體。可以參考下圖:

深入了解浏覽器垃圾回收機制

3. 避免垃圾回收

雖然浏覽器可以進行垃圾自動回收,但是當代碼比較複雜時,垃圾回收所帶來的代價比較大,是以應該盡量減少垃圾回收:

  • 對數組進行優化:在清空一個數組時,最簡單的方法就是給其指派為[ ],但是與此同時會建立一個新的空對象,我們可以将數組的長度設定為0,以此來達到清空數組的目的。
  • 對​

    ​object​

    ​進行優化:對象盡量複用,對于不再使用的對象,就将其設定為null,盡快被回收。
  • 對函數進行優化:在循環中的函數表達式,如果可以複用,盡量放在函數的外面。

4. 記憶體洩漏與優化

記憶體洩漏是指在 JavaScript 中,已經配置設定堆記憶體位址的對象由于長時間未釋放或者無法釋放,造成了長期占用記憶體,使記憶體浪費,最終會導緻運作的應用響應速度變慢以及最終崩潰的情況。這種就是記憶體洩漏,在日常開發和使用浏覽器過程中記憶體洩漏的場景:

  • 過多的緩存未釋放;
  • 閉包太多未釋放;
  • 定時器或者回調太多未釋放;
  • 太多無效的 DOM 未釋放;
  • 全局變量太多未被發現。

以上這些現象會在開發或者使用中造成記憶體洩漏,以至于浏覽器卡頓、不響應、頁面打不開等問題産生。遇到這些場景需要注意:

(1)減少不必要的全局變量,使用嚴格模式避免意外建立全局變量。

function foo() {
    // 全局變量=> window.bar
    this.bar = '預設this指向全局';
    // 沒有聲明變量,實際上是全局變量=>window.bar
    bar = '全局變量'; 
}
foo();      

這段代碼中,函數内部綁定了太多的 this 變量,this 下的屬性預設都是綁定到 window 上的屬性,均為全局變量。

(2)在使用完資料後,及時解除引用(閉包中的變量,DOM 引用,定時器清除)。

var someResource = getData();
setInterval(function() {
    var node = document.getElementById('Node');
    if(node) {
        node.innerHTML = JSON.stringify(someResource));
        // 定時器也沒有清除,可以清除掉
    }
    // node、someResource 存儲了大量資料,無法回收
}, 1000);      

上面代碼中就缺少清除 setInterval 的代碼,如果循環函數有對外部變量的引用的話,那麼這個變量會被一直留在記憶體中,而無法被回收。

(3)組織好代碼邏輯,避免死循環等造成浏覽器卡頓、崩潰的問題。

對于一些比較占用記憶體的對象提供手工釋放記憶體的方法,請看下面代碼:

var leakArray = [];
exports.clear = function () {
    leakArray = [];
}      

繼續閱讀