JavaScript 記憶體管理

weixin_33782386發表於2018-11-02

簡介

像 C 家族語言這樣的高階語言一般都有底層的記憶體管理介面,比如 malloc()和free()。另一方面,JavaScript建立變數(物件,字串等)時分配記憶體,並且在不再使用它們時“自動”釋放。 後一個過程稱為垃圾回收。這個“自動”是混亂的根源,並讓JavaScript(和其他高階語言)開發者感覺他們可以不關心記憶體管理。 這是錯誤的。

記憶體生命週期

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

  • 分配你所需要的記憶體
  • 使用分配到的記憶體(讀、寫)
  • 不需要時將其釋放\歸還

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;
window.onload = function(){
  div = document.getElementById("myDivElement");
  div.circularReference = div;
  div.lotsOfData = new Array(10000).join("*");
};

在上面的例子裡,myDivElement 這個 DOM 元素裡的 circularReference 屬性引用了 myDivElement,造成了迴圈引用。如果該屬性沒有顯示移除或者設為 null,引用計數式垃圾收集器將總是且至少有一個引用,並將一直保持在記憶體裡的 DOM 元素,即使其從DOM 樹中刪去了。如果這個 DOM 元素擁有大量的資料 (如上的 lotsOfData 屬性),而這個資料佔用的記憶體將永遠不會被釋放

標記-清除演算法

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

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

這個演算法比前一個要好,因為“有零引用的物件”總是不可獲得的,但是相反卻不一定,參考“迴圈引用”。

從2012年起,所有現代瀏覽器都使用了標記-清除垃圾回收演算法。所有對JavaScript垃圾回收演算法的改進都是基於標記-清除演算法的改進,並沒有改進標記-清除演算法本身和它對“物件是否不再需要”的簡化定義。

迴圈引用不再是問題了

在上面的示例中,函式呼叫返回之後,兩個物件從全域性物件出發無法獲取。因此,他們將會被垃圾回收器回收。第二個示例同樣,一旦 div 和其事件處理無法從根獲取到,他們將會被垃圾回收器回收。

限制: 那些無法從根物件查詢到的物件都將被清除

儘管這是一個限制,但實踐中我們很少會碰到類似的情況,所以開發者不太會去關心垃圾回收機制。

參考:

  • IBM article on "Memory leak patterns in JavaScript" (2007)
  • Kangax article on how to register event handler and avoid memory leaks (2010)
  • Performance

相關文章