【譯】JavaScript的記憶體管理和 4 種處理記憶體洩漏的方法

toddmark發表於2019-03-24

前幾周我們開始了一個關於深度探索 JavaScript 的系列,和 JavaScript 如何工作:我們想通過已經知道的 JavaScript 內容,把它們組織到一起幫你寫出更好的程式碼和應用。

這個系列的第一篇文章關注了執行時和呼叫棧的引擎論述。第二篇深度調查了 Google's V8 JavaScript 引擎的內部同時提供一些如何編寫更好的 JavaScript 程式碼。

在第三篇文章中,我們將討論由於日常使用的程式語言日益成熟和複雜性日益增加而被開發人員忽視的另一個重要主題 - 記憶體管理。我們也提供一些關於在 JavaScript 中如何處理記憶體洩漏的建議,我們在 SessionStack 中遵循這些建議,因為我們要確保 SessionStack 不會引起記憶體洩漏,也不會在我們整合的 web 應用中增加記憶體開銷。

概述

像 C 語言,在底層有原始的記憶體管理比如:malloc()free()。這些原始的方法在作業系統中,被開發者用於精確分配和釋放記憶體。

同時,當有東西(物件,字串等等)被建立時, JavaScript 分配記憶體,同時“自動地”釋放記憶體當不需要他們時,這個過程被叫做垃圾回收。這個看起來自動釋放資源的特徵是困惑的來源,它給 JavaScript(和其他高階語言)的開發者一個他們可以選擇不關心記憶體管理的錯誤的印象。這是個巨大的錯誤。

即使使用高階語言工作,開發者應該有一個記憶體管理的理解(至少是最基本的)。有時候一些跟記憶體管理有關的問題(比如在垃圾收集中的 bug 和一些限定等等)是開發者不得不理解然後合適的處理這些問題(或者用最小的代價和開銷找到合適的替代辦法)。

記憶體生命週期

無論你使用什麼程式語言,記憶體生命週期總是十分相似的:

Memory life cycle

下面是一個週期的每一步發生了什麼的概述:

  • 分配記憶體 —— 記憶體被作業系統分配來允許你的程式使用它們。在低階語言中(比如 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 X 4 + 8 = 28 bytes 的空間。

對於整型和雙精度的當前空間而言是這樣工作的。大約 20 年前,整型是典型的 2 bytes,雙精度是 4 bytes。你的程式碼不應該依賴於某個時刻基本資料型別的大小。

編譯器會插入程式碼和作業系統互動,來請求必要的位元組數量,為你的變數在棧上儲存。

在上面的例子中,編譯器知道每個變數的精確地記憶體地址。事實上,無論什麼時候我們寫操作變數 n 時,這種操作會在內部翻譯成某種比如“記憶體地址 4127963”。

注意如果我們試圖訪問 x[4],我們將會訪問資料關聯的 m。這是因為我們訪問了一個不存在的陣列元素——它的 4 位元位比在 x[3] 陣列中真正分配的最後一個元素更遠,而且可能結束讀(或者複寫)m 的位元位。這對於剩下的程式會有一系列意想不到的後果。

image

當一個方法呼叫另一個方法時,在呼叫時每個方法都會得到棧的一部分。它儲存了所有的本地變數,而且程式計數器也會記住這個執行的位置。當方法結束時,它的記憶體塊會再次為其他用處可用。

動態分配

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

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(); // allocates a Date object
var e = document.createElement('div'); // allocates a DOM element
複製程式碼

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

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 物件有一個引用指向 prototype (這裡是隱式引用)同時有一個引用指向它的屬性值(顯示引用)。

在這個上下文中,“object” 的概念比常規的 JavaScript 物件更廣泛,也包括了函式作用域(或者全域性詞法作用域)。

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

垃圾收集的引用計數

這是最簡單的垃圾收集算範。一個物件如果它的引用指標為零就會被當做 “垃圾可回收的”。

看看下面的程式碼:

var o1 = {
  o2: {
    x: 1
  }
};
// 2 個物件被建立
// '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' 屬性的原始的'o1'物件有零個引用。
           // 它現在可以被垃圾回收It can be garbage collected.
複製程式碼

迴圈帶來的問題

當討論迴圈的時候有個限制。下面的例子,兩個物件被建立並且相互引用,因此建立了一個迴圈。在函式呼叫後,它們將離開作用域,所以它們事實上應該沒有用並且要被釋放。然而,引用計數演算法認為既然兩個物件最後一次相互引用了,它們都不會被垃圾回收。

function f() {
  var o1 = {};
  var o2 = {};
  o1.p = o2; // o1 引用了 o2
  o2.p = o1; // o2 引用了 o1. 這裡建立了迴圈.
}

f();
複製程式碼

test

標記-清除演算法

為了決定哪個物件是需要的,這個演算法測試一個物件是否可以訪問。

標記-清除演算法執行以下 3 步:

  1. 根節點:通常,根節點在程式碼中是被引用的全域性變數。比如在 JavaScript 中,全域性變數作為根節點的表現是 “window” 物件。在 Nodde.js 中叫做 “global” 的物件是完全相同的。根節點的完整列表通過垃圾收集器建立。

  2. 這個演算法檢查所有的根節點和孩子結點,然後把它們標記為 “active”(意味著,它們不是垃圾)。根節點不能到達的任何東西被標記為垃圾。

  3. 最後,垃圾收集釋放所有沒有被標記為“active”的記憶體塊,並且把記憶體返回給作業系統。

test

一個標記-清除演算法的動畫

這個演算法要比之前的由於一個“零引用的物件”不能訪問的演算法更好。這當我們在迴圈中看到的是不同的。

在2012年的時候,所有的現代瀏覽器搭載了標記-清除垃圾收集器。所有的在 JavaScript 領域的垃圾收集的改進(世代,增量,併發,平行垃圾收集)超過了去年這個演算法(標記-清除)的改進,但沒有改進超過垃圾收集演算法本身,不論改進的目標是不是一個物件可訪問。

在這篇文章中,你可以瞭解到更多的關於垃圾收集追蹤的細節,這些細節包含了標記-清除的優化。

迴圈不再是問題

在之前的第一個例子中,函式返回之後,兩個物件不再從全域性物件通過可訪問的東西相互引用。 因此,它們通過垃圾回收會發現不在能訪問。

【譯】JavaScript的記憶體管理和 4 種處理記憶體洩漏的方法

即使這兩個物件相互引用,它們從根節點不可訪問。

垃圾收集的直覺計數行為

儘管垃圾收集是方便的,它們有一些自己的平衡。其中之一叫做無決定。換句話說,GCs 是不可預計的。你不能真正分辨什麼時候一個收集將會執行。這就意味著一些程式使用了比它們實際需要的更多的記憶體。在其他的例子中,短暫停在特殊敏感的應用中會被注意到。儘管無決定意味著不能確定收集什麼時候執行,大部分 GC 實施在分配時共享了收集傳遞的常見模式。如果沒有分配被執行,大部分 GCs 保持閒置。考慮下面的場景:

  1. 一組可測量的分配被執行。
  2. 這些元素的大部分(或者所有)被標記為不可到達。
  3. 沒有更多的分配可以執行。

在這個場景中,大部分 GCs 將不會執行任何更多的收集傳遞。換句話說,即使這裡有不可到達的引用可供收集使用,它們也不會被收集器宣告。這些不是嚴格的洩漏,但是,結果是高於平常的記憶體使用。

什麼是記憶體洩漏?

就像是記憶體一樣,記憶體洩漏是應用中過去不再使用但是沒有返回給作業系統或是給自由記憶體池的記憶體塊。

【譯】JavaScript的記憶體管理和 4 種處理記憶體洩漏的方法

程式語言喜歡通過不同的方式管理記憶體。然而,某個記憶體是否被使用實際上是一個不可確定的問題。換句話說,只有開發者可以搞清楚是否一塊記憶體應該返回給作業系統。

某些程式語言提供一些特性幫助開發者做這件事。另一些語言期望開發者可以完全確定什麼時候記憶體塊不再需要。維基百科上有關於手動自動記憶體管理的好文章。

常見的 JavaScript 洩漏的四種型別

1. 全域性變數

JavaScript 用一種有趣的方式處理未宣告的變數:當一個未宣告的變數被引用時,全域性物件上有一個新的變數,全域性物件一般是 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();
複製程式碼

你可以通過新增 use strict 來避免所有的 this;這個新增在 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.

複製程式碼

上面的程式碼段展示了使用計時器的後果,引用了一個不再需要的資料或節點。 這個 render 物件可能在某個時候被替換或者移除,這可能使通過計時處理程式封裝的塊冗餘。如果這個發生了,無論是這個處理還是它的依賴可能在計時器需要第一次停止時被收集(記著,它仍然有效)。它呈現了一個事實是 serverData 確定儲存和執行了資料載入將也不會被收集。

當使用觀察者時,你需要確定建立一個精確的呼叫去移除一旦你處理過後的東西(不再需要的觀察者,和將不再能訪問的物件)。

幸運的是,大多數現代瀏覽器將會為你實現:即使你忘記移除監聽,一旦發現一個物件不可訪問,它們自動收集觀察者的處理。過去一些瀏覽器不會處理這些東西(優秀的老 IE6)。

儘管如此,一旦物件廢棄在當前行中移除觀察是最佳實踐。看下面的例子:

var element = document.getElementById('launch-button');
var counter = 0;
function onClick(event) {
   counter++;
   element.innerHtml = 'text ' + counter;
}
element.addEventListener('click', onClick);
// 做點別的事
element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);
// 當元素離開作用域時,
// 元素和 onClick 都會被收集即使在老的瀏覽器中也是這樣
// 也沒有處理迴圈
複製程式碼

當現代瀏覽器支援合適的檢測迴圈和事件的垃圾收集時,你就不必在一個節點不可訪問時去呼叫 removeEventListener

如果你使用過 jQuery 的 API(其他支援 this 的庫和框架也可以),你可以在一個節點廢棄時用監聽移除它們。甚至當應用在舊的瀏覽器上執行時,這些庫也會確保沒有記憶體洩漏。

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(變數是之前從呼叫 replaceThingtheThing 變數) 被一個閉包控制。一旦閉包的作用域在同一個父級作用域中被建立就被記住了,這個作用域是共享的。

在這個例子中,閉包 someMethod 建立的作用域和 unused 共享。 unused 有一個 originalThing 的引用。即使 unused 從來沒有用過,通過 replaceThing 的作用域的外部,someMethod 可以被使用(比如:一些全域性的地方)。同時作為 someMethodunused 共享了閉包作用域, unused 的引用不得不對 originalThing 強制它保持活躍(在兩個閉包之間全部共享的作用域)。這阻止了它的回收。

在上面的例子中,someMethod 閉包建立的作用域共享了 unused,而 unused 引用了 originalThing。通過 replaceThing 作用域的外部的 theThingsomeMethod 可以被使用,儘管事實是 unused 從來沒有被用過。因為 someMethod 共享了 unused 的作用域,這個事實是 unused 的引用 originalThing 要去它保持活動。

這一切可以當做記憶體洩漏考慮。你可以期望看到一個記憶體使用的程度,尤其當上面程式碼一遍又一遍執行時。當垃圾回收執行時,它的大小不會減少。閉包的連結列表被建立(這個例子中它的根節點是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() {
    // image 是 body 元素的直接孩子
    document.body.removeChild(document.getElementById('image'));
    // 在這裡我們仍能看到在全域性物件
    // 中的一個對 #button 的引用
    // 換句話說,這個 button 元素仍然在記憶體中,不能被回收
}
複製程式碼

當談到內部 DOM 樹的葉子節點或者內部引用時,需要考慮額外的條件。如果在你的程式碼在保持對一個表格的單元格(一個 <td>標籤)的引用並且決定從 DOM 中移除仍然保留的某個單元的的引用,你可以遇見這裡會有記憶體洩漏。你可能認為垃圾回收可以釋放一切除了單元格。然而,這不在這裡個例子中。因為單元格是表格的孩子,同時孩子節點們對父節點保留引用,對錶格單元格的引用將會在記憶體中保留整個表格

我們在 SessionStack 中試著尋找編寫程式碼的最佳實踐,以便合適的控制記憶體分配,下面是原因:

一旦你在 web 應用產品中整合了 SessionStack,它開始記錄一切:所有的 DOM變化,使用者互動, JavaScript 報錯,棧追蹤,失敗的網路請求,除錯資訊等等。在 SessionStack中,你可以像視訊一樣重複播放它們然後給你的使用者看到一切發生的事情。然後所有的這些對你的 web 應用沒有表現上的影響。

因為使用者可以重新載入頁面或者跳轉到你的 APP中,所有的觀察者,檢查者,變數分配等等不得不適當處理,所以他們不會引起任何記憶體洩漏,或者在我們整合的 web 應用中增加記憶體開銷。

這裡有個免費的計劃你可以現在試試

【譯】JavaScript的記憶體管理和 4 種處理記憶體洩漏的方法

資源

pic

原文地址

相關文章