[譯] JavaScript 工作原理:記憶體管理 + 處理常見的4種記憶體洩漏

LeviDing發表於2017-12-05

幾周前,我們開始了一系列旨在深入挖掘 JavaScript 及其工作原理的研究。我們的初衷是:通過了解 JavaScript 程式碼塊的構建以及它們之間協調工作的原理,我們將能夠編寫更好的程式碼和應用程式。

本系列的第一篇文章著重於提供引擎概覽, 執行時, 以及堆疊呼叫。第二篇文章仔細審查了 Google 的 V8 JavaScript 引擎的內部區塊並且提供了一些關於怎樣編寫更好 JavaScript 程式碼的建議。

在第三篇文章中, 我們將討論另外一個越來越被開發人員忽視的主題,原因是應用於日常基礎記憶體管理的程式語言越來越成熟和複雜。我們也將會在 SessionStack 提供一些關於如何處理 JavaScript 記憶體洩漏的建議,我們需要確認 SessionStack 不會導致記憶體洩漏,或者不會增加我們整合的 web 應用程式的消耗。

概覽

例如,像 C 這樣的程式語言,有 malloc()free() 這樣的基礎記憶體管理函式。開發人員可以使用這些函式來顯式分配和釋放作業系統的記憶體。

與此同時,JavaScrip 在物件被建立時分配記憶體,並在物件不再使用時“自動”釋放記憶體,這個過程被稱為垃圾回收。這種看似“自動”釋放資源的特性是導致混亂的來源,它給了 JavaScript(和其他高階語言)開發者們一種錯覺,他們可以選擇不去關心記憶體管理。這是一種錯誤的觀念

即使使用高階語言,開發者也應該對記憶體管理有一些理解(至少關於基本的記憶體管理)。有時,自動記憶體管理存在的問題(比如垃圾回收器的錯誤或記憶體限制等)要求開發者需要理解記憶體管理,才能處理的更合適(或找到代價最少的替代方案)。

記憶體生命週期

無論你使用哪種程式語言,記憶體生命週期總是大致相同的:

[譯] JavaScript 工作原理:記憶體管理 + 處理常見的4種記憶體洩漏

以下是對迴圈中每一步具體情況的概述:

  • 記憶體分配 — 記憶體由作業系統分配,它允許你的應用程式使用。在基礎語言中 (比如 C 語言),這是一個開發人員應該處理的顯式操作。然而在高階系統中,語言已經幫你完成了這些工作。

  • 記憶體使用 — 這是你的程式真正使用之前分配的記憶體的時候,讀寫操作在你使用程式碼中已分配的變數時發生。

  • 記憶體釋放 — 釋放你明確不需要的記憶體,讓其再次空閒和可用。和記憶體分配一樣,在基礎語言中這是顯式操作。 關於呼叫棧和記憶體堆的概念的快速概覽,可以閱讀我們的關於主題的第一篇文章

記憶體是什麼?

在直接跳到有關 JavaScript 中的記憶體部分之前,我們將簡要地討論一下記憶體的概況以及它是如何工作的:

在硬體層面上,記憶體包含大量的觸發器。每一個觸發器包含一些電晶體並能夠儲存一位。單獨的觸發器可通過唯一識別符號定址, 所以我們可以讀取和覆蓋它們。因此,從概念上講,我們可以把整個計算機記憶體看作是我們可以讀寫的一個大的位組。

作為人類,我們並不擅長在位操作中實現我們所有的思路和演算法,我們把它們組裝成更大的組,它可以用來表示數字。8 位稱為 1 個位元組。除位元組外,還有單詞(有時是 16,有時是 32 位)。

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

  1. 所有程式使用的所有變數和其他資料。
  2. 程式的程式碼,包括作業系統的程式碼。

編譯器和作業系統一起為您處理了大部分的記憶體管理,但是我們建議您看看底層發生了什麼。

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

int n; // 4 bytes
int x[4]; // array of 4 elements, each 4 bytes
double m; // 8 bytes
複製程式碼

編譯器可以立即計算到程式碼需要

4 + 4 × 4 + 8 = 28 bytes

這是它處理 integers 和 doubles 型別當前大小的方式。大約 20 年前,integers 通常是 2 個位元組,doubles 通常是 4 個位元組。您的程式碼不應該依賴於某一時刻基本資料型別的大小。

編譯器將插入與作業系統互動的程式碼,為堆疊中的變數請求儲存所需的位元組數。

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

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

[譯] JavaScript 工作原理:記憶體管理 + 處理常見的4種記憶體洩漏

當函式呼叫其他函式時,每個其他函式呼叫時都會產生自己的棧塊。棧塊保留了它所有的區域性變數和一個記錄了執行地點程式計數器。當函式呼叫完成時,其記憶體塊可再次用於其他方面。

動態分配

遺憾的是,當我們不知道編譯時變數需要多少記憶體時,事情變得不再簡單。假設我們想要做如下的事情:

int n = readInput(); // reads input from the user
...
// create an array with "n" elements
複製程式碼

這裡,在編譯時,編譯器不知道陣列需要多少記憶體,因為它是由使用者提供的值決定的。

因此,它不能為堆疊上的變數分配空間。相反,我們的程式需要在執行時明確地向作業系統請求正確的記憶體量。這個記憶體是從堆空間分配的。下表總結了靜態和動態記憶體分配之間的區別:

[譯] JavaScript 工作原理:記憶體管理 + 處理常見的4種記憶體洩漏

靜態和動態記憶體分配的區別

為了充分理解動態記憶體分配是如何工作的,我們需要在指標上花費更多的時間,這可能與本文的主題略有偏差。如果您有興趣瞭解更多資訊,請在評論中告訴我們,我們可以在以後的文章中詳細介紹指標。

JavaScript 中的記憶體分配

現在我們將解釋第一步(分配記憶體)是如何在JavaScript中工作的。

JavaScript 減輕了開發人員處理記憶體分配的責任 - JavaScript自己執行了記憶體分配,同時宣告瞭值。

var n = 374; // allocates memory for a number
var s = 'sessionstack'; // allocates memory for a string 
var o = {
  a: 1,
  b: null
}; // allocates memory for an object and its contained values
var a = [1, null, 'str'];  // (like object) allocates memory for the
                           // array and its contained values
function f(a) {
  return a + 3;
} // allocates a function (which is a callable object)
// function expressions also allocate an object
someElement.addEventListener('click', function() {
  someElement.style.backgroundColor = 'blue';
}, false);
複製程式碼

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

var d = new Date(); // allocates a Date object

var e = document.createElement('div'); // allocates a DOM element
複製程式碼

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

var s1 = 'sessionstack';
var s2 = s1.substr(0, 3); // s2 is a new string
// Since strings are immutable, 
// JavaScript may decide to not allocate memory, 
// but just store the [0, 3] range.
var a1 = ['str1', 'str2'];
var a2 = ['str3', 'str4'];
var a3 = a1.concat(a2); 
// new array with 4 elements being
// the concatenation of a1 and a2 elements
複製程式碼

在 JavaScript 中使用記憶體

基本上在 JavaScript 中使用分配的記憶體,意味著在其中讀寫。

這可以通過讀取或寫入變數或物件屬性的值,甚至傳遞一個變數給函式來完成。

在記憶體不再需要時釋放記憶體

絕大部分記憶體管理問題都處於這個階段。

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

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

不幸的是,這個過程是一個近似值,因為預估是否需要某些記憶體的問題通常是不可判定的(無法通過演算法解決)。

大多數垃圾回收器通過收集不能再訪問的記憶體來工作,例如,所有指向它的變數都超出了作用域。然而,這是可以收集的一組記憶體空間的近似值,因為在某種情況下記憶體位置可能仍然有一個指向它的變數,但它將不會被再次訪問。

垃圾回收機制

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

記憶體引用

垃圾收集演算法所依賴的主要概念來源於附錄參考資料

在記憶體管理的上下文中,如果一個物件可以訪問另一個物件(可以是隱式的或顯式的),則稱該物件引用另一個物件。例如, 一個 JavaScript 引用了它的 prototype (隱式引用)和它的屬性值(顯式引用)。

在這種情況下,“物件”的概念擴充套件到比普通JavaScript物件更廣泛的範圍,幷包含函式作用域(或全域性詞法範圍)。

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

引用計數垃圾收集

這是最簡單的垃圾收集演算法。 如果有零個指向它的引用,則該物件被認為是“可垃圾回收的”。

請看下面的程式碼:

var o1 = {
  o2: {
    x: 1
  }
};
// 2 objects are created. 
// 'o2' is referenced by 'o1' object as one of its properties.
// None can be garbage-collected

var o3 = o1; // the 'o3' variable is the second thing that 
            // has a reference to the object pointed by 'o1'. 
                                                       
o1 = 1;      // now, the object that was originally in 'o1' has a         
            // single reference, embodied by the 'o3' variable

var o4 = o3.o2; // reference to 'o2' property of the object.
                // This object has now 2 references: one as
                // a property. 
                // The other as the 'o4' variable

o3 = '374'; // The object that was originally in 'o1' has now zero
            // references to it. 
            // It can be garbage-collected.
            // However, what was its 'o2' property is still
            // referenced by the 'o4' variable, so it cannot be
            // freed.

o4 = null; // what was the 'o2' property of the object originally in
           // 'o1' has zero references to it. 
           // It can be garbage collected.
複製程式碼

週期產生問題

在週期迴圈中有一個限制。在下面的例子中,兩個物件被建立並相互引用,這就建立了一個迴圈。在函式呼叫之後,它們會超出界限,所以它們實際上是無用的,並且可以被釋放。然而,引用計數演算法認為,由於兩個物件中的每一個都被至少引用了一次,所以兩者都不能被垃圾收集。

function f() {
  var o1 = {};
  var o2 = {};
  o1.p = o2; // o1 references o2
  o2.p = o1; // o2 references o1. This creates a cycle.
}

f();
複製程式碼

[譯] JavaScript 工作原理:記憶體管理 + 處理常見的4種記憶體洩漏

標記和掃描演算法

為了確定是否需要某個物件,本演算法判斷該物件是否可訪問。

標記和掃描演算法經過這 3 個步驟:

1.根節點:一般來說,根是程式碼中引用的全域性變數。例如,在 JavaScript 中,可以充當根節點的全域性變數是“window”物件。Node.js 中的全域性物件被稱為“global”。完整的根節點列表由垃圾收集器構建。 2.然後演算法檢查所有根節點和他們的子節點並且把他們標記為活躍的(意思是他們不是垃圾)。任何根節點不能訪問的變數將被標記為垃圾。 3.最後,垃圾收集器釋放所有未被標記為活躍的記憶體塊,並將這些記憶體返回給作業系統。

[譯] JavaScript 工作原理:記憶體管理 + 處理常見的4種記憶體洩漏

標記和掃描演算法行為的視覺化。

因為“一個物件有零引用”導致該物件不可達,所以這個演算法比前一個演算法更好。我們在週期中看到的情形恰巧相反,是不正確的。

截至 2012 年,所有現代瀏覽器都內建了標記掃描式的垃圾回收器。去年在 JavaScript 垃圾收集(通用/增量/併發/並行垃圾收集)領域中所做的所有改進都是基於這種演算法(標記和掃描)的實現改進,但這不是對垃圾收集演算法本身的改進,也不是對判斷一個物件是否可達這個目標的改進。

在本文中, 您可以閱讀有關垃圾回收跟蹤的更詳細的資訊,文章也包括標記和掃描演算法以及其優化。

週期不再是問題

在上面的第一個例子中,函式呼叫返回後,兩個物件不再被全域性物件中的某個變數引用。因此,垃圾收集器會認為它們不可訪問。

[譯] JavaScript 工作原理:記憶體管理 + 處理常見的4種記憶體洩漏

即使兩個物件之間有引用,從根節點它們也不再可達。

統計垃圾收集器的直觀行為

儘管垃圾收集器很方便,但他們也有自己的一套權衡策略。其中之一是不確定性。換句話說,GCs(垃圾收集器)們是不可預測的。你不能確定一個垃圾收集器何時會執行收集。這意味著在某些情況下,程式其實需要使用更多的記憶體。其他情況下,在特別敏感的應用程式中,短暫暫停可能是顯而易見的。儘管不確定性意味著不能確定一個垃圾收集器何時執行收集,大多數 GC 共享分配中的垃圾收集通用模式。如果沒有執行分配,大多數 GC 保持空閒狀態。考慮如下場景:

  1. 大量的分配被執行。
  2. 大多數這些元素(或全部)被標記為不可訪問(假設我們廢除一個指向我們不再需要的快取的引用)。
  3. 沒有執行更深的記憶體分配。

在這種情況下,大多數 GC 不會執行任何更深層次的收集。換句話說,即使存在不可用的引用可用於收集,收集器也不會宣告這些引用。這些並不是嚴格的洩漏,但仍會導致高於日常的記憶體使用率。

什麼是記憶體洩漏?

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

[譯] JavaScript 工作原理:記憶體管理 + 處理常見的4種記憶體洩漏

程式語言偏好不同的記憶體管理方式。但是,某段記憶體是否被使用實際上是一個不可判定問題。換句話說,只有開發人員可以明確某塊記憶體是否可以返回給作業系統。

某些程式語言提供了幫助開發人員執行上述操作的功能。其他人則希望開發人員能夠完全明確某段記憶體何時處於未使用狀態。維基百科在如何手工自動記憶體管理方面有很好的文章。

JavaScript 常見的四種記憶體洩漏

1:全域性變數

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

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

等同於:

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

我們假設 bar 的目的只是引用 foo 函式中的一個變數。然而,如果你不使用 var 來宣告它,就會建立一個冗餘的全域性變數。在上面的情況中,這不會造成很嚴重的後果。你可以想象一個更具破壞性的場景。

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

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

你可以通過在 JavaScript 檔案的開頭新增 'use strict'; 來避免這些後果,這將開啟一種更嚴格的 JavaScript 解析模式,從而防止意外建立全域性變數。

意外的全域性變數當然是個問題,然而更常出現的情況是,你的程式碼會受到顯式的全域性變數的影響,而這些全域性變數無法通過垃圾收集器收集。需要特別注意用於臨時儲存和處理大量資訊的全域性變數。如果你必須使用全域性變數來儲存資料,當你這樣做的時候,要保證一旦完成使用就把他們賦值為 null 或重新賦值

2:被忘記的定時器或者回撥函式

我們以經常在 JavaScript 中使用的 setInterval 為例。

提供觀察者和其他接受回撥的工具庫通常確保所有對回撥的引用在其例項無法訪問時也變得無法訪問。然而,下面的程式碼並不鮮見:

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 物件可能會在某些時候被替換或刪除,這會使得間隔處理程式封裝的塊變得冗餘。如果發生這種情況,處理程式及其依賴項都不會被收集,因為間隔處理需要先備停止(請記住,它仍然是活動的)。這一切都歸結為一個事實,即事實儲存和處理負載資料的 serverData 也不會被收集。

當使用觀察者時,你需要確保一旦依賴於它們的事務已經處理完成,你編寫了明確的呼叫來刪除它們(不再需要觀察者,或者物件將變得不可用時)。

幸運的是,大多數現代瀏覽器都會為你做這件事:即使你忘記刪除監聽器,當觀察物件變得無法訪問時,它們也會自動收集觀察者處理程式。過去一些瀏覽器無法處理這些情況(舊的 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) // a reference to 'originalThing'
      console.log("hi");
  };
  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {
      console.log("message");
    }
  };
};
setInterval(replaceThing, 1000);
複製程式碼

一旦呼叫了 replaceThing 函式,theThing 就得到一個新的物件,它由一個大陣列和一個新的閉包(someMethod)組成。然而 originalThing 被一個由 unused 變數(這是從前一次呼叫 replaceThing 變數的 Thing 變數)所持有的閉包所引用。需要記住的是一旦為同一個父作用域內的閉包建立作用域,作用域將被共享。

在個例子中,someMethod 建立的作用域與 unused 共享。unused 包含一個關於 originalThing 的引用。即使 unused 從未被引用過,someMethod 也可以通過 replaceThing 作用域之外的 theThing 來使用它(例如全域性的某個地方)。由於 someMethodunused 共享閉包範圍,unused 指向 originalThing 的引用強制它保持活動狀態(兩個閉包之間的整個共享範圍)。這阻止了它們的垃圾收集。

在上面的例子中,為閉包 someMethod 建立的作用域與 unused 共享,而 unused 又引用 originalThingsomeMethod 可以通過 replaceThing 範圍之外的 theThing 來引用,儘管 unused 從來沒有被引用過。事實上,unused 對 originalThing 的引用要求它保持活躍,因為 someMethod 與 unused 的共享封閉範圍。

所有這些都可能導致大量的記憶體洩漏。當上面的程式碼片段一遍又一遍地執行時,您可以預期到記憶體使用率的上升。當垃圾收集器執行時,其大小不會縮小。一個閉包鏈被建立(在例子中它的根就是 theThing 變數),並且每個閉包作用域都包含對大陣列的間接引用。

Meteor 團隊發現了這個問題,它們有一篇很棒的文章詳細地描述了這個問題。

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() {
    // The image is a direct child of the body element.
    document.body.removeChild(document.getElementById('image'));
    // At this point, we still have a reference to #button in the
    //global elements object. In other words, the button element is
    //still in memory and cannot be collected by the GC.
}
複製程式碼

在涉及 DOM 樹內的內部節點或葉節點時,還有一個額外的因素需要考慮。如果你在程式碼中保留對錶格單元格(td 標記)的引用,並決定從 DOM 中刪除該表格但保留對該特定單元格的引用,則可以預見到嚴重的記憶體洩漏。你可能會認為垃圾收集器會釋放除了那個單元格之外的所有東西。但情況並非如此。由於單元格是表格的子節點,並且子節點保持對父節點的引用,所以對錶格單元格的這種單引用會把整個表格儲存在記憶體中

我們在 SessionStack 嘗試遵循這些最佳實踐,編寫正確處理記憶體分配的程式碼,原因如下:

一旦將 SessionStack 整合到你的生產環境的 Web 應用程式中,它就會開始記錄所有的事情:所有的 DOM 更改,使用者互動,JavaScript 異常,堆疊跟蹤,失敗網路請求,除錯訊息等。

通過 SessionStack,你可以像視訊一樣回放 web 應用程式中的問題,並檢視所有的使用者行為。所有這些都必須在您的網路應用程式沒有效能影響的情況下進行。

由於使用者可以重新載入頁面或導航你的應用程式,所有的觀察者,攔截器,變數分配等都必須正確處理,這樣它們才不會導致任何記憶體洩漏,也不會增加我們正在整合的Web應用程式的記憶體消耗。

這裡有一個免費的計劃所以你可以試試看.

[譯] JavaScript 工作原理:記憶體管理 + 處理常見的4種記憶體洩漏

Resources


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章