【譯】JavaScript的工作原理:記憶體管理和4種常見的記憶體洩漏

妙堂傳道者發表於2018-12-19

該系列的第一篇文章重點介紹了引擎,執行時和呼叫堆疊的概述。第二篇文章深入剖析了Google的V8 JavaScript引擎,並提供了關於如何編寫更好的JavaScript程式碼的一些提示。

在第三篇文章中,我們將討論另一個越來越被開發人員忽視的關鍵主題,因為日常使用的程式語言(記憶體管理)越來越成熟和複雜。我們還會提供一些關於如何處理記憶體洩漏的技巧。

概述

類似與C這種程式語言,提供了從底層來管理記憶體的方法,比如malloc()和free()。開發人員可以通過它們,來處理作業系統的分配記憶體,或釋放記憶體到作業系統中。
在JavaScript當中,當物件或字串等被建立時,JavaScript會申請和分配記憶體;當物件或字元不再被使用時,它們就會被自動釋放,這個過程被稱為垃圾處理。正是這種自動看似自動回收的認識讓JavaScript開發者誤以為他們不用關心記憶體管理,這是一個很大的錯誤
即使使用高階語言,開發者也應該理解記憶體管理(即便是基礎),有時自動記憶體管理也會有一些問題(例如bug或者垃圾回收實現的侷限性等等),所以開發者必須要明白它們,才能夠妥善的處理。

記憶體生命週期

無論你使用什麼語言,記憶體的生命週期大體是相同的:

【譯】JavaScript的工作原理:記憶體管理和4種常見的記憶體洩漏
這兒描述一下,在每一個生命週期發生的事情:

  • 分配記憶體——記憶體是由作業系統分配,執行程式使用它,在底層語言當中(如C),這是需要一個顯示的操作,作為開發人員需要處理的,在高階語言當中,這個操作被隱藏了。
  • 使用記憶體——這是你的程式實際使用之前分配的記憶體。讀取和寫入操作發生在您在程式碼中使用分配變數的時候。
  • 釋放記憶體——當你不需要使用的時候,應該釋放記憶體,以便它可以變為空閒並再次可用。 與分配記憶體操作一樣,這個操作在底層語言中是可以直接呼叫的。

有關呼叫堆疊和記憶體堆的概念的概述,您可以閱讀本系列第一篇文章

什麼是記憶體

在開始討論JavaScript的記憶體之前,我們先短暫的討論一下相關概念和記憶體是怎麼工作的。
在硬體層面之上,電腦的記憶體是由大量的觸發器,每個觸發器都包含一些電晶體並且能夠儲存一個bit。單個觸發器可通過唯一識別符號進行定址,這樣就可以讀取並覆蓋它們。因此,從概念上講,我們可以將整個計算機記憶體看作是我們可以讀寫的bit陣列。
從人類角度來說,我們不擅長用bit來完成我們現實中思想和演算法,我們把它們組織成更大的部分,它們一起可以用來表示數字。 8位(位元位)稱為1個位元組(byte)。除位元組外,還有單詞(word)(有時是16,有時是32位)。

很多東西都儲存在這個記憶體中:

  • 所有程式使用的所有變數和其他資料。
  • 程式的程式碼,包括作業系統的程式碼。 編譯器和作業系統一起工作,為您處理大部分記憶體管理,但我們還是建議能夠明白下層發生了什麼。

編譯程式碼時,編譯器可以預先檢查原始資料型別並提前計算它們需要多少記憶體。然後將所需的記憶體分配給呼叫堆疊空間中的程式。分配這些變數的空間稱為堆疊空間,因為隨著函式的呼叫,它們的記憶體將被新增到現有記憶體之上。當它們終止時,它們以LIFO(後進先出)順序被移除。例如,請考慮以下宣告:

int n; // 4位元組
int x [4]; // 4個元素的陣列,每個4個位元組
double m; // 8個位元組
複製程式碼

編譯器可以立即看到程式碼需要:
4 + 4×4 + 8 = 28個位元組。

這就是它如何處理整數和雙精度的當前大小。大約20年前,整數通常是2個位元組,並且是雙4位元組。您的程式碼不應該依賴於此時基本資料型別的大小。

編譯器將插入與作業系統進行互動的程式碼,以在堆疊中請求必要的位元組數,以便儲存變數。

在上面的例子中,編譯器知道每個變數的確切記憶體地址。事實上,只要我們寫入變數n,就會在內部翻譯成類似“記憶體地址4127963”的內容。

注意,如果我們試圖在這裡訪問x[4],我們將訪問與m關聯的資料。這是因為我們正在訪問陣列中不存在的元素 - 它比陣列中最後一個實際分配的元素x[3]更遠了4個位元組,並且可能最終讀取(或覆蓋)m中的一些位。這兒就會有bug了。

【譯】JavaScript的工作原理:記憶體管理和4種常見的記憶體洩漏
當函式呼叫其他函式時,每個函式在呼叫時都會獲得自己的堆疊塊。它保留了它所有的區域性變數,同時還有一個程式計數器,記錄它在執行時的位置。當功能完成時,其儲存器塊再次可用於其他目的。

動態分配記憶體

不幸的是,當我們在編譯時有時不知道變數需要多少記憶體時,假設我們想要做如下的事情:

int n=readInput();//使用者的輸入
...
//常見一個長度為n的陣列
複製程式碼

在編譯時,編譯器不知道陣列需要多少記憶體,因為它由使用者提供的值決定。
因此,它不能為堆疊上的變數分配空間。
相反,我們的程式需要在執行時明確要求作業系統提供適當的空間。
該記憶體是從堆空間分配的。 下表總結了靜態和動態記憶體分配之間的區別:

【譯】JavaScript的工作原理:記憶體管理和4種常見的記憶體洩漏
為了充分理解動態記憶體分配是如何工作的,我們需要在指標上花費更多時間,這可能與本文的主題偏離太多。
如果您有興趣瞭解更多資訊,請在評論中告訴我們,我們可以在以後的文章中詳細介紹指標。

JavaScript分配記憶體

現在我們將解釋第一步(分配記憶體),以及它如何在JavaScript中工作。 JavaScript減輕了開發人員處理記憶體分配的責任-JavaScript自身宣告的時候就分配記憶體,然後賦值。

var n = 374; // 為數字分配記憶體
var s = 'sessionstack'; // 為字串分配記憶體 
var o = {
  a: 1,
  b: null
}; // 為物件和它的值分配記憶體
var a = [1, null, 'str'];  // (類似物件) 為陣列和它的值分配記憶體

function f(a) {
  return a + 3;
} // 為函式分配記憶體 (這是一個可呼叫的方法物件)
// 函式表示式也會分配記憶體
someElement.addEventListener('click', function() {
  someElement.style.backgroundColor = 'blue';
}, false);
複製程式碼

一些函式呼叫也會導致物件分配:

var d = new Date(); // 為日期物件分配記憶體
var e = document.createElement('div'); // 為DOM元素分配記憶體
複製程式碼

方法可以分配新的值或物件:

var s1 = 'sessionstack';
var s2 = s1.substr(0, 3); // s2 是一個新的字串
// 由於字串是不可改變的, 
// JavaScript 可能決定不分配記憶體, 
// 僅僅只儲存 [0, 3] 這個範圍.
var a1 = ['str1', 'str2'];
var a2 = ['str3', 'str4'];
var a3 = a1.concat(a2); 
// 新的物件有四個元素,它是由a1和a2連線而成
複製程式碼

在JavaScript中使用記憶體

基本上在JavaScript中使用分配的記憶體意味著讀取和寫入。
這可以通過讀取或寫入變數或物件屬性的值,或者甚至將引數傳遞給函式來完成。

當記憶體不再需要時釋放

大部分記憶體管理問題都是在這個階段出現的。

確定何時不再需要使用分配的記憶體是最困難的。它通常需要開發人員確定程式中的哪個地方不再需要這些記憶體,並將其釋放。

高階語言嵌入了一個名為垃圾收集器的軟體,其工作是跟蹤記憶體分配和使用情況,以便找到何時不再需要分配的記憶體,在這種情況下,它會自動釋放它。

不幸的是,這個過程是一個大概,因為知道是否需要某些記憶體的一般問題是不可判定的(不能由演算法解決)。

大多數垃圾收集器通過收集不能再訪問的記憶體來工作,例如,指向它的所有變數都超出了範圍。然而,這隻可以收集的一組記憶體空間的近似值,因為在任何時候記憶體位置可能仍然有一個指向它的變數,但它將不會再被訪問。

垃圾收集

由於發現某些記憶體是否“不再需要”的事實是不可判定的,所以垃圾收集實現了對一般問題的解決方案的限制。本節將解釋理解主要垃圾收集演算法及其侷限性的必要概念。

記憶體引用

垃圾收集演算法所依賴的主要概念是參考之一。

在記憶體管理的上下文中,如果一個物件可以訪問後者(可以是隱式或顯式的),則稱該物件引用另一個物件。例如,JavaScript物件具有對其原型(隱式引用)及其屬性值(顯式引用)的引用。

在這種情況下,“物件”的概念擴充套件到比常規JavaScript物件更廣泛的範圍,並且還包含函式範圍(或全域性詞法範圍)。

詞法範圍定義瞭如何在巢狀函式中解析變數名稱:即使父函式已返回,內部函式也包含父函式的作用域。

引用計數法垃圾收集

這是最簡單的垃圾收集演算法。如果指向它引用數時零,則該物件被視為“垃圾可收集的” 。
看下下面的程式碼:

var o1 = {
  o2: {
    x: 1
  }
};
// 兩個物件被建立. 
// 'o2'作為'o1'的屬性被引用.
// 不能夠被當做可回收的

var o3 = o1; //'o3'是第二個有引用的,它被指向了'o1' . 
                                                       
o1 = 1;      //現在,最初在'o1'中的物件有一個引用,由'o3'變數體現出來

var o4 = o3.o2; // 引用到'o2'作為屬性的物件.
                // 這個物件現在有兩個引用:一個作為屬性. 
                // 另一個變成了 'o4' 的值

o3 = '374'; // 現在這個最初的'o1'變成了零引用了,他可以被垃圾回收
            //然而,'o2'變數仍然作為'o4'變數的屬性,他不能被釋放

o4 = null; // 現在對於'o2',沒有地方應用它了,他可以被垃圾回收
複製程式碼

迴圈引用的問題

在迴圈引用方面存在限制。在以下示例中,建立了兩個物件並相互引用,從而建立了一個迴圈。在函式呼叫之後它們將超出範圍,因此它們實際上是無用的並且可以被釋放。但是,引用計數演算法認為,由於兩個物件中的每一個至少被引用一次,因此兩者都不能被垃圾收集。

function f() {
  var o1 = {};
  var o2 = {};
  o1.p = o2; // o1 引用 o2
  o2.p = o1; // o2 引用 o1. 形成了迴圈.
}

f();
複製程式碼

標記和掃描演算法

為了確定是否需要一個物件,該演算法確定物件是否可以獲得。

標記和掃描演算法通過以下3個步驟:

  1. root:一般來說,root是在程式碼中引用的全域性變數。例如,在JavaScript中,可以充當root的全域性變數是“window”物件。Node.js中的相同物件稱為“global”。垃圾收集器構建了所有root的完整列表。
  2. 然後演算法檢查所有root和它們的子節點,並將它們標記為活動(意思是,它們不是垃圾)。root無法訪問的任何內容都將被標記為垃圾。
  3. 最後,垃圾收集器釋放所有未標記為活動的記憶體塊,並將該記憶體返回給作業系統。
    【譯】JavaScript的工作原理:記憶體管理和4種常見的記憶體洩漏
    此演算法優於前一個演算法,因為“物件具有零引用”導致此物件無法訪問。正如我們在週期中看到的那樣,情況正好相反。

截至2012年,所有現代瀏覽器都提供了標記 - 清除垃圾收集器。在過去幾年中,在JavaScript垃圾收集(生成/增量/併發/並行垃圾收集)領域所做的所有改進都是該演算法的實現改進(標記和清除),不僅不是對垃圾收集演算法本身的改進,也不是判斷一個物件是否可及作為目標。

本文中,您可以更詳細地閱讀跟蹤垃圾收集,其中還包括標記和清除及其優化。

迴圈引用不再是問題

在上面的第一個示例中,在函式呼叫返回之後,兩個物件不再被從全域性物件可到達的內容引用。因此,垃圾收集器將無法訪問它們。

【譯】JavaScript的工作原理:記憶體管理和4種常見的記憶體洩漏
儘管物件之間存在引用,但它們無法從根目錄訪問。

垃圾收集器的反常行為

雖然垃圾收集器很方便,但它們有自己的權衡取捨。其中之一是非決定論。換句話說,GC是不可預測的。您無法確定何時會執行收集。這意味著在某些情況下,程式會使用更多實際需要的記憶體。在其他情況下,在特別敏感的應用中,短暫停頓可能會很明顯。儘管非確定性意味著無法確定何時執行集合,但大多數GC的實現都是在分配期間執行集合過程這種常見模式。如果沒有執行分配,則大多數GC保持空閒。請考慮以下情形:

  1. 執行大量分配。
  2. 大多數這些元素(或所有元素)都被標記為無法訪問(假設我們將指向我們不再需要的快取的引用置空,設定為null)。
  3. 沒有進一步的分配。
    在這種情況下,大多數GC不會再執行任何收集過程。換句話說,即使有可用於收集的無法訪問的引用,收集器也不會宣告這些引用。這些並非嚴格洩漏,但仍導致高於平常的記憶體使用率。

什麼是記憶體洩漏?

就像記憶體所暗示的那樣,記憶體洩漏是應用程式過去使用但不再需要但尚未返回作業系統或可用記憶體池的記憶體塊。

【譯】JavaScript的工作原理:記憶體管理和4種常見的記憶體洩漏
程式語言支援不同的記憶體管理方式。但是,是否使用某段記憶體實際上是一個不可判定的問題。換句話說,只有開發人員才能明確是否可以將一塊記憶體返回給作業系統。
某些程式語言提供的功能可幫助開發人員實現此目的, 其他人希望開發人員完全明確何時未使用記憶體。維基百科有關於手動自動記憶體管理的好文章。

四種型別的常見JavaScript記憶體洩漏

1.全域性變數

JavaScript以一種有趣的方式處理未宣告的變數:當引用未宣告的變數時,會在全域性物件中建立一個新變數。 在瀏覽器中,全域性物件將是window,這意味著:

function foo(arg) {
    bar = "some text";
}
複製程式碼

等同於

function foo(arg) {
    window.bar = "some text";
}
複製程式碼

假設bar的目的是僅引用foo函式中的變數。但是,如果您不使用var來宣告它,將會建立一個冗餘的全域性變數。在上述情況下,這不會造成太大的傷害。
儘管如此,你一定可以想象一個更具破壞性的場景。

你也可以用這個意外地建立一個全域性變數:

function foo() {
    this.var1 = "potential accidental global";
}
// Foo called on its own, this points to the global object (window)
// rather than being undefined.
foo();
複製程式碼

您可以通過新增'use strict'來避免這些問題; 在您的JavaScript檔案的開始處,它將切換更嚴格的解析JavaScript模式,從而防止意外建立全域性變數。

意外的全域性變數當然是一個問題,然而,更多的時候,你的程式碼會受到顯式定義的全域性變數的影響,這些變數不能被垃圾收集器回收。需要特別注意用於臨時儲存和處理大量資訊的全域性變數。如果你必須使用全域性變數來儲存資料,用完之後一定要把它賦值為null或者在完成之後重新賦值。

2.被遺忘的定時器和回撥函式

以setInterval為例,因為它經常在JavaScript中使用。
提供觀察者模式或接受回撥的工具庫,它通常會確保當其例項無法訪問時,其所回撥的引用在變得無法訪問。下面的程式碼並不罕見:

var serverData = loadData();
setInterval(function() {
    var renderer = document.getElementById('renderer');
    if(renderer) {
        renderer.innerHTML = JSON.stringify(serverData);
    }
}, 5000); //This will be executed every ~5 seconds.
複製程式碼

上面的程式碼片段顯示了使用引用不再需要的節點或資料的定時器的後果。

renderer物件可能會被替換或刪除,這會使得間隔處理程式封裝的塊變得冗餘。如果發生這種情況,則不需要收集處理程式及其依賴關係,因為interval需要先停止(請記住,它仍然處於活動狀態)。這一切歸結為serverData確實儲存和處理負載資料的事實也不會被收集。

當使用observers時,你需要確保你做了一個明確的呼叫,在完成它們之後將其刪除(不再需要觀察者,否則物件將無法訪問)。

幸運的是,大多數現代瀏覽器都會為您完成這項工作:即使您忘記刪除偵聽器,一旦觀察到的物件變得無法訪問,他們會自動收集觀察者處理程式。在過去,一些瀏覽器無法處理這些情況(舊版IE6)。

var element = document.getElementById('launch-button');
var counter = 0;
function onClick(event) {
   counter++;
   element.innerHtml = 'text ' + counter;
}
element.addEventListener('click', onClick);
// Do stuff
element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);
// Now when element goes out of scope,
// both element and onClick will be collected even in old browsers // that don't handle cycles well.
複製程式碼

現在的瀏覽器支援可以檢測這些週期並適當處理它們的垃圾收集器,因此在使節點無法訪問之前,不再需要呼叫removeEventListener。

如果您利用jQuery API(其他庫和框架也支援這一點),您也可以在節點過時之前刪除偵聽器。 即使應用程式在較舊的瀏覽器版本下執行,該庫也會確保沒有記憶體洩漏。

3.閉包

JavaScript開發的一個關鍵點是閉包:一個可以訪問外部函式的變數的內部函式。由於JavaScript執行時的實現方式,可能以下列方式洩漏記憶體:

var theThing = null;
var replaceThing = function () {
  var originalThing = theThing;
  var unused = function () {
    if (originalThing) // 'originalThing'的引用
      console.log("hi");
  };
  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {
      console.log("message");
    }
  };
};
setInterval(replaceThing, 1000);
複製程式碼

一旦replaceThing函式被呼叫,theThing變數將被賦值為一個由很長的字串和一個新閉包(someMethod)組成的新物件。originalThing變數被一個閉包引用,這個閉包由unused變數保持。需要記住的是,當一個閉包的作用域被建立,同屬父範圍內的閉包的作用域會被共享。

在這種情況下,閉包someMethod建立的作用域將與閉包unused的作用域共享。unused引用了originalThing,儘管程式碼中unused從未被呼叫過,但是我們還是可以在replaceThing函式外通過theThing來呼叫someMethod。由於someMethod與unused的閉包作用域共享,閉包unused的引用了originalThing,強制它保持活動狀態(兩個閉包之間的共享作用域)。這阻止了它被垃圾回收。

在上面的例子中,閉包someMethod建立的作用域與閉包unused作用域的共享,而unused的引用originalThing。儘管閉包unused從未被使用,someMethod還是可以通過theThing,從replaceThing範圍外被呼叫。事實上,閉包unused引用了originalThing要求它保持活動,因為someMethod與unused的作用域共享。

閉包會保留一個指向其作用域的指標,作用域就是閉包父函式,所以閉包unused和someMethod都會有一個指標指向replaceThing函式,這也是為什麼閉包可以訪問外部函式的變數。由於閉包unused引用了originalThing變數,這使得originalThing變數存在於lexical environment,replaceThing函式裡面定義的所有的閉包都會有一個對originalThing的引用,所以閉包someMethod自然會保持一個對originalThing的引用,所以就算theThing替換成其它值,它的上一次值不會被回收。

所有這些都可能導致相當大的記憶體洩漏。當上面的程式碼片段一遍又一遍地執行時,您可能會發現記憶體使用量激增。當垃圾收集器執行時,其大小不會縮小。建立了一個閉包的連結串列(在這種情況下,它的根就是theThing變數),並且每個閉包範圍都會間接引用大陣列。

4. DOM樹之外的引用

有些情況下開發者會儲存DOM節點的引用。假設你想快速更新表格中幾行的內容,如果使用字典或陣列儲存這幾行的DOM引用,則會有兩個對同一DOM元素的引用:一個在DOM樹中,另一個在字典或陣列中。如果你決定刪除並回收這些行,您需要記住要使這個兩個引用都無法訪問。

var elements = {
    button: document.getElementById('button'),
    image: document.getElementById('image')
};
function doStuff() {
    elements.image.src = 'http://example.com/image_name.png';
}
function removeImage() {
    // image元素是body的子元素
    document.body.removeChild(document.getElementById('image'));
    // 這時我們還有一個對 #image 的引用,這個引用在elements物件中
    // 換句話說,image元素還在記憶體中,不能被GC回收
}
複製程式碼

涉及DOM樹內的內部節點或葉節點時,還有一個額外需要考慮的問題。如果在程式碼中保留對錶格單元格(一個<td>標籤)的引用,並決定從DOM中刪除該表格並保留對該特定單元格的引用,則可以預期會出現嚴重的記憶體洩漏。你可能會認為垃圾回收器會釋放該這個單元格外的所有內容。然而,情況並非如此。由於單元格是表格的子節點,並且子節點保持對父節點的引用,因此對錶格單元格的這種單引用將使整個表格保留在記憶體中,不能被GC回收。

後續文件翻譯會陸續跟進!!

歡迎關注玄說前端公眾號,後續將推出系列文章《一個大型圖形化應用0到1的過程》,此賬戶也將同步更新

【譯】JavaScript的工作原理:記憶體管理和4種常見的記憶體洩漏

相關文章