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

幾周前,咱們開始了一系列旨在深刻挖掘 JavaScript 及其工做原理的研究。咱們的初衷是:經過了解 JavaScript 代碼塊的構建以及它們之間協調工做的原理,咱們將可以編寫更好的代碼和應用程序。javascript

本系列的第一篇文章着重於提供引擎概覽, 運行時, 以及堆棧調用。第二篇文章仔細審查了 Google 的 V8 JavaScript 引擎的內部區塊而且提供了一些關於怎樣編寫更好 JavaScript 代碼的建議。html

在第三篇文章中, 咱們將討論另一個愈來愈被開發人員忽視的主題,緣由是應用於平常基礎內存管理的程序語言愈來愈成熟和複雜。咱們也將會在 SessionStack 提供一些關於如何處理 JavaScript 內存泄漏的建議,咱們須要確認 SessionStack 不會致使內存泄漏,或者不會增長咱們集成的 web 應用程序的消耗。前端

概覽

例如,像 C 這樣的編程語言,有 malloc()free() 這樣的基礎內存管理函數。開發人員可使用這些函數來顯式分配和釋放操做系統的內存。java

與此同時,JavaScrip 在對象被建立時分配內存,並在對象再也不使用時「自動」釋放內存,這個過程被稱爲垃圾回收。這種看似「自動」釋放資源的特性是致使混亂的來源,它給了 JavaScript(和其餘高級語言)開發者們一種錯覺,他們能夠選擇不去關心內存管理。這是一種錯誤的觀念node

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

內存生命週期

不管你使用哪一種程序語言,內存生命週期老是大體相同的:android

如下是對循環中每一步具體狀況的概述:ios

  • 內存分配 — 內存由操做系統分配,它容許你的應用程序使用。在基礎語言中 (好比 C 語言),這是一個開發人員應該處理的顯式操做。然而在高級系統中,語言已經幫你完成了這些工做。git

  • 內存使用 — 這是你的程序真正使用以前分配的內存的時候,讀寫操做在你使用代碼中已分配的變量時發生。github

  • 內存釋放 — 釋放你明確不須要的內存,讓其再次空閒和可用。和內存分配同樣,在基礎語言中這是顯式操做。 關於調用棧和內存堆的概念的快速概覽,能夠閱讀咱們的關於主題的第一篇文章

內存是什麼?

在直接跳到有關 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 的位。這對項目的其他部分有預料以外的影響。

當函數調用其餘函數時,每一個其餘函數調用時都會產生本身的棧塊。棧塊保留了它全部的局部變量和一個記錄了執行地點程序計數器。當函數調用完成時,其內存塊可再次用於其餘方面。

動態分配

遺憾的是,當咱們不知道編譯時變量須要多少內存時,事情變得再也不簡單。假設咱們想要作以下的事情:

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

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

所以,它不能爲堆棧上的變量分配空間。相反,咱們的程序須要在運行時明確地向操做系統請求正確的內存量。這個內存是從堆空間分配的。下表總結了靜態和動態內存分配之間的區別:

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

爲了充分理解動態內存分配是如何工做的,咱們須要在指針上花費更多的時間,這可能與本文的主題略有誤差。若是您有興趣瞭解更多信息,請在評論中告訴咱們,咱們能夠在之後的文章中詳細介紹指針。

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();
複製代碼

標記和掃描算法

爲了肯定是否須要某個對象,本算法判斷該對象是否可訪問。

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

1.根節點:通常來講,根是代碼中引用的全局變量。例如,在 JavaScript 中,能夠充當根節點的全局變量是「window」對象。Node.js 中的全局對象被稱爲「global」。完整的根節點列表由垃圾收集器構建。 2.而後算法檢查全部根節點和他們的子節點而且把他們標記爲活躍的(意思是他們不是垃圾)。任何根節點不能訪問的變量將被標記爲垃圾。 3.最後,垃圾收集器釋放全部未被標記爲活躍的內存塊,並將這些內存返回給操做系統。

標記和掃描算法行爲的可視化。

由於「一個對象有零引用」致使該對象不可達,因此這個算法比前一個算法更好。咱們在週期中看到的情形恰巧相反,是不正確的。

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

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

週期再也不是問題

在上面的第一個例子中,函數調用返回後,兩個對象再也不被全局對象中的某個變量引用。所以,垃圾收集器會認爲它們不可訪問。

即便兩個對象之間有引用,從根節點它們也再也不可達。

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

儘管垃圾收集器很方便,但他們也有本身的一套權衡策略。其中之一是不肯定性。換句話說,GCs(垃圾收集器)們是不可預測的。你不能肯定一個垃圾收集器什麼時候會執行收集。這意味着在某些狀況下,程序其實須要使用更多的內存。其餘狀況下,在特別敏感的應用程序中,短暫暫停多是顯而易見的。儘管不肯定性意味着不能肯定一個垃圾收集器什麼時候執行收集,大多數 GC 共享分配中的垃圾收集通用模式。若是沒有執行分配,大多數 GC 保持空閒狀態。考慮以下場景:

  1. 大量的分配被執行。
  2. 大多數這些元素(或所有)被標記爲不可訪問(假設咱們廢除一個指向咱們再也不須要的緩存的引用)。
  3. 沒有執行更深的內存分配。

在這種狀況下,大多數 GC 不會運行任何更深層次的收集。換句話說,即便存在不可用的引用可用於收集,收集器也不會聲明這些引用。這些並非嚴格的泄漏,但仍會致使高於平常的內存使用率。

什麼是內存泄漏?

就像內存描述的那樣,內存泄漏是應用程序過去使用但再也不須要的還沒有返回到操做系統或可用內存池的內存片斷。

編程語言偏好不一樣的內存管理方式。可是,某段內存是否被使用其實是一個不可斷定問題。換句話說,只有開發人員能夠明確某塊內存是否能夠返回給操做系統。

某些編程語言提供了幫助開發人員執行上述操做的功能。其餘人則但願開發人員可以徹底明確某段內存什麼時候處於未使用狀態。維基百科在如何手工自動內存管理方面有很好的文章。

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應用程序的內存消耗。

這裏有一個免費的計劃因此你能夠試試看.

Resources


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

相關文章
相關標籤/搜索