2015年12月10日 星期四

JavaScript 記憶體回收機制

簡介

低級語言,比如C,有低級的記憶體管理基元,像malloc(),free()。另一方面,JavaScript的記憶體基元在變數(物件,字串等等)新增時分配,然後在他們不再被使用時「自動」釋放。後者被稱為垃圾回收。這個「自動」是產生混淆的源頭,並給JavaScript(和其他高級語言)開發者一個印象:他們可以不用考慮記憶體管理。這是錯誤的。
記憶體生命週期

不管什麼程式語言,記憶體生命週期基本一致:  

    分配你所需要的記憶體
    使用它(讀、寫)
    當它不被使用時釋放   ps:和「把大象裝冰箱「一個意思

第一二部分過程在所有語言中都很清晰。最後一步在低級語言中很清晰,但是在像JavaScript等高級語言中,最後一步不清晰。
JavaScript的記憶體分配
值的初始化

為了不讓程式員費心分配記憶體,JavaScript在定義變數時完成記憶體分配。

var n = 123; // 給數值變數分配記憶體
var s = "azerty"; // 給字元型

var o = {
  a: 1,
  b: null
}; // 為物件及其包含變數分配記憶體

var a = [1, null, "abra"]; // 為陣列及其包含變數分配記憶體(就像物件一樣)

function f(a){
  return a + 2;
} // 為函式(可呼叫的物件)分配記憶體

// 函式表達式也能分配一個物件
someElement.addEventListener('click', function(){
  someElement.style.backgroundColor = 'blue';
}, false);

通過函式呼叫的記憶體分配

有些函式呼叫結果是分配物件記憶體:

var d = new Date(); //分配一個Date物件
var e = document.createElement('div'); //分配一個DOM元素

有些方法分配新變數或者新物件:

var s = "azerty";
var s2 = s.substr(0, 3); // s2是一個新字串
//因為字串是不變數,JavaScript可能沒有分配記憶體,但只是存儲了0-3的範圍。

var a = ["ouais ouais", "nan nan"];
var a2 = ["generation", "nan nan"];
var a3 = a.concat(a2); // 新陣列中有連接陣列a和陣列a2中的四個元素。

值的使用

使用值的過程實際上是對分配記憶體進行讀取與寫入的操作。讀取與寫入可能是寫入一個變數或者一個物件的屬性值,甚至傳遞函式的參數。
當記憶體不再需要使用時釋放

大多數記憶體管理的問題都在這個階段。在這裡最艱難的任務是找到「所分配的記憶體確實已經不再需要了」。它往往要求開發人員來確定在程式中哪一塊記憶體不再需要並且釋放它。

高級語言解釋器嵌入了「垃圾回收器」,它的主要工作是跟蹤記憶體的分配和使用,以便當分配的記憶體不再使用時,自動釋放它。這只能是一個近似的過程,因為要知道是否仍然需要某塊記憶體是無法判定的 (無法通過某種算法解決).
垃圾回收

如上文所述自動尋找是否一些記憶體「不再需要」的問題是無法判定的。因此,垃圾回收實現只能有限制的解決一般問題。本節將解釋必要的概念,瞭解主要的垃圾回收算法和它們的侷限性。
引用

垃圾回收算法主要依賴於引用的概念。在記憶體管理的環境中,一個物件如果有訪問另一個物件的權限(隱式或者顯式),叫做一個物件引用另一個物件。例如,一個Javascript物件具有對它 原型 的引用(隱式引用)和對它屬性的引用(顯式引用)。

在這裡,「物件」的概念不僅特指Javascript物件,還包括函式作用域(或者全域詞法作用域)。
引用計數垃圾收集

這是最簡單的垃圾收集算法。此算法把「物件是否不再需要」簡化定義為「物件有沒有其他物件引用到它」。如果沒有引用指向該物件(零引用),物件將被垃圾回收機制回收。
例如

var o = {
  a: {
    b:2
  }
};
// 兩個物件被新增,一個作為另一個的屬性被引用,另一個被分配給變數o
// 很顯然,沒有一個可以被垃圾收集


var o2 = o; // o2變數是第二個對「這個物件」的引用
o = 1; // 現在,「這個物件」的原始引用o被o2替換了

var oa = o2.a; // 引用「這個物件」的a屬性
// 現在,「這個物件」有兩個引用了,一個是o2,一個是oa

o2 = "yo"; // 最初的物件現在已經是零引用了
// 他可以被垃圾回收了
// 然而它的屬性a的物件還在被oa引用,所以還不能回收

oa = null; // a屬性的那個物件現在也是零引用了
// 它可以被垃圾回收了

限制:循環引用

這個簡單的算法有一個限制,就是如果一個物件引用另一個(形成了循環引用),他們可能「不再需要」了,但是他們不會被回收。

function f(){
  var o = {};
  var o2 = {};
  o.a = o2; // o 引用 o2
  o2.a = o; // o2 引用 o

  return "azerty";
}

f();
// 兩個物件被新增,並互相引用,形成了一個循環
// 他們被呼叫之後不會離開函式作用域
// 所以他們已經沒有用了,可以被回收了
// 然而,引用計數算法考慮到他們互相都有至少一次引用,所以他們不會被回收

實際當中的例子

IE 6, 7 對DOM物件進行引用計數回收。對他們來說,一個常見問題就是記憶體洩露:

var div = document.createElement("div");
div.onclick = function(){
  doSomething();
};
// div有了一個引用指向事件處理屬性onclick
// 事件處理也有一個對div的引用可以在函式作用域中被訪問到
// 這個循環引用會導致兩個物件都不會被垃圾回收

標記-清除算法

這個算法把「物件是否不再需要」簡化定義為「物件是否可以獲得」。

這個算法假定設置一個叫做根的物件(在Javascript裡,根是全域物件)。定期的,垃圾回收器將從根開始,找所有從根開始引用的物件,然後找這些物件引用的物件……從根開始,垃圾回收器將找到所有可以獲得的物件和所有不能獲得的物件。

這個算法比前一個要好,因為「有零引用的物件」總是不可獲得的,但是相反卻不一定,參考「循環引用」。

從2012年起,所有現代瀏覽器都使用了標記-清除垃圾回收算法。所有對JavaScript垃圾回收算法的改進都是基於標記-清除算法的改進,並沒有改進標記-清除算法本身和它對「物件是否不再需要」的簡化定義。
循環引用不再是問題了
在上面的示例中,函式呼叫返回之後,兩個物件從全域物件出發無法獲取。因此,他們將會被垃圾回收器回收。

第二個示例同樣,一旦 div 和其事件處理無法從根獲取到,他們將會被垃圾回收器回收

限制: 物件需要明確的不可獲得

儘管這是一個限制,但是很少會被突破,這也就是為什麼在現實中很少人會去關心垃圾回收機制。

沒有留言:

張貼留言