[譯文] JavaScript工做原理:內存管理+如何處理4種常見的內存泄露

原文 How JavaScript works: memory management + how to handle 4 common memory leaksjavascript

幾周前咱們開始了一個系列博文旨在深刻挖掘 JavaScript 並弄清楚它的工做原理:咱們認爲經過了解 JavaScript 的構建單元並熟悉它們是怎樣結合起來的,有助於寫出更好的代碼和應用。java

本系列的第一篇文章着重提供一個關於引擎、運行時和調用棧的概述。第二篇文章深刻分析了 GoogleV8 引擎的內部實現並提供了一些編寫更優質 JavaScript 代碼的建議。算法

在第三篇的本文中,咱們將會討論另外一個很是重要的主題,因爲平常使用的編程語言的逐漸成熟和複雜性,它被愈來愈多的開發者忽視——內存管理。咱們還會提供一些在 SessionStack 中遵循的關於如何處理 JavaScript 內存泄露的方法,咱們必須保證 SessionStack 不會發生內存泄漏,或致使整合進來的應用增長內存消耗。express

概述

C 這樣的語言,具備低水平的內存管理原語如 malloc()free(),這些原語被開發者用來顯式地向操做系統分配和釋放內存。編程

同時,JavaScript 在事物(對象、字符串等)被建立時分配內存,並在它們再也不須要用到時自動釋放內存,這個過程稱爲垃圾收集。這個看似自動釋放資源的特性是困惑的來源,形成 JavaScript(和其餘高級語言)開發者錯誤的印象,認爲他們能夠選擇沒必要關心內存管理。這是個天大的誤解。數組

即使在使用高級編程語言時,開發者也應該瞭解內存管理(至少最基本的)。有時會遇到自動內存管理的問題(如垃圾收集器的BUG和實現限制等),開發者應該瞭解這些問題才能合理地處理它們(或找到適當的解決方案,用最小的代價和代碼債)。瀏覽器

內存生命週期

不管使用哪一種編程語言,內存的生命週期幾乎老是相同的:緩存

內存生命循環

下面是週期中每一個步驟發生了什麼的概覽:session

  • 分配內存——內存由容許程序使用的操做系統分配。在低級編程語言(如 C)中這是一個做爲開發人員應該處理的顯式操做。而在高級編程語言中是由語言自己幫你處理的。
  • 使用內存——這是程序實際上使用以前所分配內存的階段。讀寫操做發生在使用代碼中分配的變量時。
  • 釋放內存——如今是釋放不須要的整個內存的時候了,這樣它才能變得空閒以便再次可用。與分配內存同樣,在低級編程語言中這是一個顯式操做。

想要快速瀏覽調用棧和內存堆的概念,能夠閱讀咱們關於這個主題的第一篇文章數據結構

什麼是內存?

在直接介紹 JavaScript 中的內存以前,咱們會簡要討論一下內存是什麼及它是怎樣工做的。

在硬件層面,計算機內存由大量的觸發器組成。每一個觸發器包含幾個晶體管可以存儲一個比特(譯註:1位)。能夠經過惟一標識符來訪問單個觸發器,因此能夠對它們進行讀寫操做。所以從概念上,咱們能夠把整個計算機內存想象成一個巨大的可讀寫的比特陣列。

做爲人類,咱們並不擅長使用字節進行全部的思考和算術,咱們把它們組織成更大的組合,一塊兒用來表示數字。8比特稱爲1個字節。除字節以外,還有其餘詞(有時是16比特、有時是32比特)。

不少東西存儲在內存中:

  1. 全部程序使用的全部變量和其餘數據。
  2. 程序代碼,包括操做系統的。

編譯器和操做系統一塊兒工做來處理大部分的內存管理,但咱們仍是建議你瞭解一下底層發生的事情。

編譯代碼時,編譯器能夠檢測到原始數據類型而後提早計算出須要多少內存。隨後給棧空間中的程序分配所需額度。分配變量的空間被稱爲棧空間是由於當函數調用時,它們被添加到已有內存的頂部。當它們終止時,根據後進先出的原則被移除。例如,考慮以下聲明:

int n; // 4 bytes 4字節
int x[4]; // array of 4 elements, each 4 bytes 含有四個元素的數組,每一個4字節
double m; // 8 bytes 8字節

編譯器可以當即看出這段代碼須要4+4*4+8=28字節。

這是現今處理整型和雙精度浮點數的大小。20年之前,整型一般是2字節,雙精度是4字節。代碼永遠不該該依賴當前基本數據類型的大小。

編譯器將會插入代碼與操做系統交互,請求棧上存儲變量所需的字節數。

在上面的例子中,編譯器知道每一個變量的精確內存地址。實際上,每當寫入變量 n,它都會在內部被轉換成相似「內存地址4127963」的東西。

注意,若是試圖在這裏訪問 x[4],將會訪問到與 m 關聯的數據。這是由於咱們在訪問數組中一個不存在的元素——比數組中最後實際分配的成員 x[3] 要遠4個字節,這可能最終會讀取(或寫入)一些 m 中的比特。這必將會使程序其他部分產生很是不但願獲得的結果。

變量內存分配

當函數調用其餘函數時,每一個函數都會在被調用時獲得屬於本身的一塊棧。這裏不只保存了全部的局部變量,還保存着記錄執行位置的程序計數器。當函數結束時,它的內存單元再次變得空閒可供他用。

動態分配

不幸的是,當咱們在編譯時沒法得知變量須要多少內存的時候事情就沒那麼簡單了。假設咱們要作以下的事情:

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

這在編譯時,編譯器沒法知道數組須要多少內存,由於它取決於用戶提供的值。

所以沒法爲棧中的變量分配空間。相反,咱們的程序須要在運行時顯式向操做系統請求合適的空間。這種內存由堆空間分配。靜態和動態內存分配的區別總結爲下表:

靜態內存分配與動態內存分配的區別

要充分理解動態內存分配的原理,咱們須要在指針上多花些時間,但這已經偏離了本文的主題。若是有興趣學習更多,請在評論裏留言告訴咱們,咱們能夠在之後的文章中討論更多關於指針的細節。

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 分配一個DOM元素

方法能夠分配新的值或對象:

var s1 = 'sessionstack';
var s2 = s1.substr(0, 3); // s2 is a new string s2是一個新字符串
// Since strings are immutable, 因爲字符串是不可變的
// JavaScript may decide to not allocate memory, JavaScript可能會決定不分配內存
// but just store the [0, 3] range. 而僅僅存儲[0, 3]這個範圍
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 由a1和a2的元素的結合

JavaScript 中使用內存

JavaScript 中使用分配的內存基本上意味着在其中進行讀寫操做。

這能夠經過讀取或寫入變量的值或對象屬性、甚至向函數傳參數的時候實現。

在不須要內存時將其釋放

大多數內存管理問題出如今這個階段。

最大的難題是弄清楚什麼時候再也不須要分配的內存。一般須要開發者來決定這塊內存在程序的何處再也不須要而且釋放它。

高級編程語言嵌入了一個叫作垃圾收集器軟件,它的工做是追蹤內存分配和使用以便發現分配的內存什麼時候再也不須要,並在這種狀況下自動釋放它。

不幸的是這個過程只是個近似的過程,由於知道是否還須要一些內存的通常問題是不可決定的(沒法靠算法解決)。

大多數垃圾收集器的工做原理是收集不能再訪問的內存,好比指向它的全部變量都超出做用域。但這也是對可收集內存空間的一種低估,由於在任什麼時候候做用域內都仍可能有一個變量指向一個內存地址,然而它不再會被訪問。

垃圾收集

因爲沒法肯定某些內存是否「再也不須要」,垃圾收集實現了對通常解決方法的限制。這一節將會解釋理解主要的垃圾收集算法的必要概念和侷限性。

內存引用

垃圾收集算法依賴的主要概念之一是引用

在內存管理的上下文中,若是一個對象能夠訪問另外一個對象則說成是前者引用了後者(但是隱式也但是顯式)。例如,JavaScript 對象有對其原型的引用(隱式引用)和對屬性的引用(顯式引用)。

在這個上下文中,」對象「的概念擴展到比常規 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
// 建立了兩個對象
// o2 被看成 o1 的屬性而引用
// 如今沒有可被收集的垃圾

var o3 = o1; // the 'o3' variable is the second thing that
            // has a reference to the object pointed by 'o1'.
            // o3是第二個引用了o1 所指向對象的變量。

o1 = 1;      // now, the object that was originally in 'o1' has a
            // single reference, embodied by the 'o3' variable
            // 如今,原本被 o1 指向的對象變成了單一引用,體如今 o3 上。

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
                // 經過屬性 o2 創建了對它所指對象的引用
                // 這個對象如今有兩個引用:一個做爲屬性的o2
                // 另外一個是變量 o4

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.
            // 本來由 o1 引用的對象如今含有0個引用。
            // 它能夠被做爲垃圾而收集
            // 可是它的屬性 o2 仍然被變量 o4 引用,因此它不能被釋放。

o4 = null; // what was the 'o2' property of the object originally in
           // 'o1' has zero references to it.
           // It can be garbage collected.
           // 本來由 o1 引用的對象的屬性 o2 如今也只有0個引用,它如今能夠被收集了。

循環制造出問題

這在循環引用時存在限制。在下面示例中,建立了兩個互相引用的對象,從而建立了一個循環。它們在函數調用返回後超出做用域,因此實際上它們已經沒用了並應該被釋放。但引用計數算法考慮到因爲它們至少被引用了一次,因此二者都不會被看成垃圾收集。

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

f();

3-4.png

標記和清理算法

爲了決定是否還須要對象,這個算法肯定了對象是否能夠訪問。

標記和清理算法有以下三個步驟:

  1. 根:一般,根是被代碼引用的全局變量。例如在 JavaScript 中,能夠做爲根的全局變量是 window 對象。同一對象在 Node.js 中被稱爲 global。垃圾收集器創建了全部根的完整列表。
  2. 接着算法檢查全部根及它們的子節點,並把它們標記爲活躍的(意爲它們不是垃圾)。根所不能獲取到的任何東西都被標記爲垃圾。
  3. 最終,垃圾收集器把未標記爲活躍的全部內存片斷釋放並返還給操做系統。

標記和清理算法的視覺化行爲.gif

這個算法比以前的更好,由於「一個對象沒有引用」形成這個對象變得不可獲取,但經過循環咱們看到反過來倒是不成立的。

2012年後,全部現代瀏覽器都裝載了標記和清理垃圾收集器。近年來,在 JavaScript 垃圾收集全部領域的改善(分代/增量/併發/並行垃圾收集)都是這個算法(標記和清理)的實現改進,既不是垃圾收集算法自身的改進也並不是決定是否對象可獲取的目標的改進。

這篇文章中,你能夠閱讀到有關追蹤垃圾收集的大量細節,而且涵蓋了標記和清理及它的優化。

循環再也不是問題

在上面的第一個例子中,當函數調用返回後,兩個對象再也不被全局對象的可獲取節點引用。結果是,它們會被垃圾收集齊認爲是不可獲取的。

3-6.png

即使它們彼此間仍存在引用,它們也不能被根獲取到。

垃圾收集器與直覺相反的行爲

雖然垃圾收集器很方便,但它們也有本身的一套折中策略。其一是非肯定性。換句話說,垃圾收集是不可預測的。你沒法確切知道垃圾收集何時執行。這意味着在一些狀況下程序會要求比實際須要更多的內存。另外一些狀況下,短時暫停會在一些特別敏感的應用中很明顯。雖然非肯定性意味着沒法肯定垃圾收集執行的時間,但大多數垃圾收集的實現都共享一個通用模式:在內存分配期間進行收集。若是沒有內存分配發生,垃圾收集器就處於閒置。考慮如下場景:

  1. 執行大量內存分配。
  2. 它們大多數(或所有)被標記爲不可獲取(假設咱們將一個再也不須要的指向緩存的引用置爲null)。
  3. 再也不有進一步的內存分配發生。

在這個場景下,大多數垃圾收集不會再運行收集傳遞。換言之,即時存在沒法訪問的引用能夠收集,它們也不會被收集器注意到。這些不是嚴格意義上的泄露,可是仍然致使了比正常更高的內存使用。

什麼是內存泄露?

就像內存所暗示的,內存泄露是被應用使用過的一塊內存在不須要時還沒有返還給操做操做系統或因爲糟糕的內存釋放未能返還。

3-7.jpeg

編程語言喜歡用不一樣的方式進行內存管理。但一塊已知內存是否還被使用其實是個沒法決定的問題。換句話說,只有開發人員能夠弄清除是否應該將一塊內存還給操做系統。

某些編程語言提供了開發人員手動釋放內存的特性。另外一些則但願由開發人員徹底提供顯式的聲明。維基百科上有關於手動自動內存管理的好的文章。

四種常見 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();
能夠經過在 JavaScript 文件的開頭添加 'use strict'; 來避免這一切,這會開啓一個更加嚴格的模式來解析代碼,它能夠防止意外建立全局變量。

意外的全局變量固然是個問題,可是一般狀況下,你的代碼會被顯示全局變量污染,而且根據定義它們沒法被垃圾收集器收集。應該尤爲注意用來臨時性存儲和處理大量信息的全局變量。若是你必須使用全局變量存儲信息而當你這樣作了時,確保一旦完成以後就將它賦值爲 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 對象可能在某個時候被覆蓋或移除,這將會致使封裝在間隔處理函數中的語句變得冗餘。一旦發生這種狀況,處理器和它依賴的東西必需要等到間隔器先被中止以後才能收集(記住,它依然是活躍的)。這將會致使這樣的事實:用於儲存和處理數據的 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 APIs(其餘庫和框架也支持),它也能夠在節點無效以前移除監聽器。這個庫也會確保沒有內存泄露發生,即便應用運行在老瀏覽器之下。

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 調用所獲得的變量 theThing )。要記住的是當一個閉包做用域被建立時,位於同一個父做用域內的其餘閉包也共享這個做用域。

在這個案列中,爲閉包 someMethod 建立的做用域被 unused 共享。即使 unused 從未使用,someMethod 能夠經過位於 replaceThing 外層的 theThing 使用(例如,在全局中)。又由於 someMethodunused 共享閉包做用域,unused 引用的 originalThing 被強制處於活躍狀態(在兩個閉包之間被共享的整個做用域)。這些妨礙了被收集。

在上述列子中,當 unused 引用了 originalThing 時,共享了爲 someMethod 建立的做用域。能夠經過 replaceThing 做用域外的 theThing 使用 someMethod,且無論其實 unused 從未使用。事實上 unused 引用了 originalThing 使其保持在活躍狀態,由於someMethodunused 共享了閉包做用域。

全部的這些致使了至關大的內存泄露。你會看到在上述代碼一遍又一遍運行時內存使用量的激增。它不會在垃圾收集器運行時變小。一系列的閉包被建立(此例中根是變量 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.
    // 圖片是body的直接子元素
    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.
    // 這時,全局elements對象仍有一個對#button元素的引用。換句話說,button元素
    // 仍然在內存裏,沒法被垃圾收集器回收。
}

還有一個例外狀況應該被考慮到,它出如今引用 DOM 樹的內部或葉節點時。若是你在代碼裏保存了一個對錶格單元(td 標籤)的引用,而後決定把表格從 DOM 中移除但保留對那個特別單元格的引用,就能預料到將會有大量的內存泄露。你可能認爲垃圾收集器將釋放其餘全部的東西除了那個單元格。可是,這將不會發生。由於這個單元格是表格的一個子節點,子節點保存了對它們父節點的引用,引用這一個單元格將會在內存裏保存整個表格。

相關文章
相關標籤/搜索