【譯】JavaScript的內存管理和 4 種處理內存泄漏的方法

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

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

在第三篇文章中,咱們將討論因爲平常使用的編程語言日益成熟和複雜性日益增長而被開發人員忽視的另外一個重要主題 - 內存管理。咱們也提供一些關於在 JavaScript 中如何處理內存泄漏的建議,咱們在 SessionStack 中遵循這些建議,由於咱們要確保 SessionStack 不會引發內存泄漏,也不會在咱們集成的 web 應用中增長內存開銷。java

概述

像 C 語言,在底層有原始的內存管理好比:malloc()free()。這些原始的方法在操做系統中,被開發者用於精確分配和釋放內存。node

同時,當有東西(對象,字符串等等)被建立時, JavaScript 分配內存,同時「自動地」釋放內存當不須要他們時,這個過程被叫作垃圾回收。這個看起來自動釋放資源的特徵是困惑的來源,它給 JavaScript(和其餘高級語言)的開發者一個他們能夠選擇不關心內存管理的錯誤的印象。這是個巨大的錯誤。web

即便使用高級語言工做,開發者應該有一個內存管理的理解(至少是最基本的)。有時候一些跟內存管理有關的問題(好比在垃圾收集中的 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 中的分配

如今來看看再 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 領域的垃圾收集的改進(世代,增量,併發,平行垃圾收集)超過了去年這個算法(標記-清除)的改進,但沒有改進超過垃圾收集算法自己,不論改進的目標是否是一個對象可訪問。

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

循環再也不是問題

在以前的第一個例子中,函數返回以後,兩個對象再也不從全局對象經過可訪問的東西相互引用。 所以,它們經過垃圾回收會發現不在能訪問。

即便這兩個對象相互引用,它們從根節點不可訪問。

垃圾收集的直覺計數行爲

儘管垃圾收集是方便的,它們有一些本身的平衡。其中之一叫作無決定。換句話說,GCs 是不可預計的。你不能真正分辨何時一個收集將會執行。這就意味着一些程序使用了比它們實際須要的更多的內存。在其餘的例子中,短暫停在特殊敏感的應用中會被注意到。儘管無決定意味着不能肯定收集何時執行,大部分 GC 實施在分配時共享了收集傳遞的常見模式。若是沒有分配被執行,大部分 GCs 保持閒置。考慮下面的場景:

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

在這個場景中,大部分 GCs 將不會運行任何更多的收集傳遞。換句話說,即便這裏有不可到達的引用可供收集使用,它們也不會被收集器聲明。這些不是嚴格的泄漏,可是,結果是高於日常的內存使用。

什麼是內存泄漏?

就像是內存同樣,內存泄漏是應用中過去再也不使用可是沒有返回給操做系統或是給自由內存池的內存塊。

程序語言喜歡經過不一樣的方式管理內存。然而,某個內存是否被使用其實是一個不可肯定的問題。換句話說,只有開發者能夠搞清楚是否一塊內存應該返回給操做系統。

某些程序語言提供一些特性幫助開發者作這件事。另外一些語言指望開發者能夠徹底肯定何時內存塊再也不須要。維基百科上有關於手動自動內存管理的好文章。

常見的 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 應用中增長內存開銷。

這裏有個免費的計劃你能夠如今試試

資源

pic

原文地址

相關文章
相關標籤/搜索