【譯】JavaScript的工做原理:內存管理和4種常見的內存泄漏

該系列的第一篇文章重點介紹了引擎,運行時和調用堆棧的概述。第二篇文章深刻剖析了Google的V8 JavaScript引擎,並提供了關於如何編寫更好的JavaScript代碼的一些提示。javascript

在第三篇文章中,咱們將討論另外一個愈來愈被開發人員忽視的關鍵主題,由於平常使用的編程語言(內存管理)愈來愈成熟和複雜。咱們還會提供一些關於如何處理內存泄漏的技巧。前端

概述

相似與C這種編程語言,提供了從底層來管理內存的方法,好比malloc()和free()。開發人員能夠經過它們,來處理操做系統的分配內存,或釋放內存到操做系統中。
在JavaScript當中,當對象或字符串等被建立時,JavaScript會申請和分配內存;當對象或字符再也不被使用時,它們就會被自動釋放,這個過程被稱爲垃圾處理。正是這種自動看似自動回收的認識讓JavaScript開發者誤覺得他們不用關心內存管理,這是一個很大的錯誤
即便使用高級語言,開發者也應該理解內存管理(即使是基礎),有時自動內存管理也會有一些問題(例如bug或者垃圾回收實現的侷限性等等),因此開發者必需要明白它們,纔可以妥善的處理。java

內存生命週期

不管你使用什麼語言,內存的生命週期大致是相同的: 算法

這兒描述一下,在每個生命週期發生的事情:

  • 分配內存——內存是由操做系統分配,運行程序使用它,在底層語言當中(如C),這是須要一個顯示的操做,做爲開發人員須要處理的,在高級語言當中,這個操做被隱藏了。
  • 使用內存——這是你的程序實際使用以前分配的內存。讀取和寫入操做發生在您在代碼中使用分配變量的時候。
  • 釋放內存——當你不須要使用的時候,應該釋放內存,以便它能夠變爲空閒並再次可用。 與分配內存操做同樣,這個操做在底層語言中是能夠直接調用的。

有關調用堆棧和內存堆的概念的概述,您能夠閱讀本系列第一篇文章編程

什麼是內存

在開始討論JavaScript的內存以前,咱們先短暫的討論一下相關概念和內存是怎麼工做的。
在硬件層面之上,電腦的內存是由大量的觸發器,每一個觸發器都包含一些晶體管而且可以存儲一個bit。單個觸發器可經過惟一標識符進行尋址,這樣就能夠讀取並覆蓋它們。所以,從概念上講,咱們能夠將整個計算機內存看做是咱們能夠讀寫的bit數組。
從人類角度來講,咱們不擅長用bit來完成咱們現實中思想和算法,咱們把它們組織成更大的部分,它們一塊兒能夠用來表示數字。 8位(比特位)稱爲1個字節(byte)。除字節外,還有單詞(word)(有時是16,有時是32位)。數組

不少東西都存儲在這個內存中:瀏覽器

  • 全部程序使用的全部變量和其餘數據。
  • 程序的代碼,包括操做系統的代碼。 編譯器和操做系統一塊兒工做,爲您處理大部份內存管理,但咱們仍是建議可以明白下層發生了什麼。

編譯代碼時,編譯器能夠預先檢查原始數據類型並提早計算它們須要多少內存。而後將所需的內存分配給調用堆棧空間中的程序。分配這些變量的空間稱爲堆棧空間,由於隨着函數的調用,它們的內存將被添加到現有內存之上。當它們終止時,它們以LIFO(後進先出)順序被移除。例如,請考慮如下聲明:緩存

int n; // 4字節
int x [4]; // 4個元素的數組,每一個4個字節
double m; // 8個字節
複製代碼

編譯器能夠當即看到代碼須要:
4 + 4×4 + 8 = 28個字節。session

這就是它如何處理整數和雙精度的當前大小。大約20年前,整數一般是2個字節,而且是雙4字節。您的代碼不該該依賴於此時基本數據類型的大小。閉包

編譯器將插入與操做系統進行交互的代碼,以在堆棧中請求必要的字節數,以便存儲變量。

在上面的例子中,編譯器知道每一個變量的確切內存地址。事實上,只要咱們寫入變量n,就會在內部翻譯成相似「內存地址4127963」的內容。

注意,若是咱們試圖在這裏訪問x[4],咱們將訪問與m關聯的數據。這是由於咱們正在訪問數組中不存在的元素 - 它比數組中最後一個實際分配的元素x[3]更遠了4個字節,而且可能最終讀取(或覆蓋)m中的一些位。這兒就會有bug了。

當函數調用其餘函數時,每一個函數在調用時都會得到本身的堆棧塊。它保留了它全部的局部變量,同時還有一個程序計數器,記錄它在執行時的位置。當功能完成時,其存儲器塊再次可用於其餘目的。

動態分配內存

不幸的是,當咱們在編譯時有時不知道變量須要多少內存時,假設咱們想要作以下的事情:

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(); // 爲日期對象分配內存
var e = document.createElement('div'); // 爲DOM元素分配內存
複製代碼

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

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對象具備對其原型(隱式引用)及其屬性值(顯式引用)的引用。

在這種狀況下,「對象」的概念擴展到比常規JavaScript對象更普遍的範圍,而且還包含函數範圍(或全局詞法範圍)。

詞法範圍定義瞭如何在嵌套函數中解析變量名稱:即便父函數已返回,內部函數也包含父函數的做用域。

引用計數法垃圾收集

這是最簡單的垃圾收集算法。若是指向它引用數時零,則該對象被視爲「垃圾可收集的」 。
看下下面的代碼:

var o1 = {
  o2: {
    x: 1
  }
};
// 兩個對象被建立. 
// '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',沒有地方應用它了,他能夠被垃圾回收
複製代碼

循環引用的問題

在循環引用方面存在限制。在如下示例中,建立了兩個對象並相互引用,從而建立了一個循環。在函數調用以後它們將超出範圍,所以它們其實是無用的而且能夠被釋放。可是,引用計數算法認爲,因爲兩個對象中的每個至少被引用一次,所以二者都不能被垃圾收集。

function f() {
  var o1 = {};
  var o2 = {};
  o1.p = o2; // o1 引用 o2
  o2.p = o1; // o2 引用 o1. 造成了循環.
}

f();
複製代碼

標記和掃描算法

爲了肯定是否須要一個對象,該算法肯定對象是否能夠得到。

標記和掃描算法經過如下3個步驟:

  1. root:通常來講,root是在代碼中引用的全局變量。例如,在JavaScript中,能夠充當root的全局變量是「window」對象。Node.js中的相同對象稱爲「global」。垃圾收集器構建了全部root的完整列表。
  2. 而後算法檢查全部root和它們的子節點,並將它們標記爲活動(意思是,它們不是垃圾)。root沒法訪問的任何內容都將被標記爲垃圾。
  3. 最後,垃圾收集器釋放全部未標記爲活動的內存塊,並將該內存返回給操做系統。
    此算法優於前一個算法,由於「對象具備零引用」致使此對象沒法訪問。正如咱們在週期中看到的那樣,狀況正好相反。

截至2012年,全部現代瀏覽器都提供了標記 - 清除垃圾收集器。在過去幾年中,在JavaScript垃圾收集(生成/增量/併發/並行垃圾收集)領域所作的全部改進都是該算法的實現改進(標記和清除),不只不是對垃圾收集算法自己的改進,也不是判斷一個對象是否可及做爲目標。

本文中,您能夠更詳細地閱讀跟蹤垃圾收集,其中還包括標記和清除及其優化。

循環引用再也不是問題

在上面的第一個示例中,在函數調用返回以後,兩個對象再也不被從全局對象可到達的內容引用。所以,垃圾收集器將沒法訪問它們。

儘管對象之間存在引用,但它們沒法從根目錄訪問。

垃圾收集器的反常行爲

雖然垃圾收集器很方便,但它們有本身的權衡取捨。其中之一是非決定論。換句話說,GC是不可預測的。您沒法肯定什麼時候會執行收集。這意味着在某些狀況下,程序會使用更多實際須要的內存。在其餘狀況下,在特別敏感的應用中,短暫停頓可能會很明顯。儘管非肯定性意味着沒法肯定什麼時候執行集合,但大多數GC的實現都是在分配期間執行集合過程這種常見模式。若是沒有執行分配,則大多數GC保持空閒。請考慮如下情形:

  1. 執行大量分配。
  2. 大多數這些元素(或全部元素)都被標記爲沒法訪問(假設咱們將指向咱們再也不須要的緩存的引用置空,設置爲null)。
  3. 沒有進一步的分配。
    在這種狀況下,大多數GC不會再運行任何收集過程。換句話說,即便有可用於收集的沒法訪問的引用,收集器也不會聲明這些引用。這些並不是嚴格泄漏,但仍致使高於日常的內存使用率。

什麼是內存泄漏?

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

編程語言支持不一樣的內存管理方式。可是,是否使用某段內存其實是一個 不可斷定的問題。換句話說,只有開發人員才能明確是否能夠將一塊內存返回給操做系統。
某些編程語言提供的功能可幫助開發人員實現此目的, 其餘人但願開發人員徹底明確什麼時候未使用內存。維基百科有關於 手動自動內存管理的好文章。

四種類型的常見JavaScript內存泄漏

1.全局變量

JavaScript以一種有趣的方式處理未聲明的變量:當引用未聲明的變量時,會在全局對象中建立一個新變量。 在瀏覽器中,全局對象將是window,這意味着:

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

等同於

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

假設bar的目的是僅引用foo函數中的變量。可是,若是您不使用var來聲明它,將會建立一個冗餘的全局變量。在上述狀況下,這不會形成太大的傷害。
儘管如此,你必定能夠想象一個更具破壞性的場景。

你也能夠用這個意外地建立一個全局變量:

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'來避免這些問題; 在您的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.
複製代碼

上面的代碼片斷顯示了使用引用再也不須要的節點或數據的定時器的後果。

renderer對象可能會被替換或刪除,這會使得間隔處理程序封裝的塊變得冗餘。若是發生這種狀況,則不須要收集處理程序及其依賴關係,由於interval須要先中止(請記住,它仍然處於活動狀態)。這一切歸結爲serverData確實存儲和處理負載數據的事實也不會被收集。

當使用observers時,你須要確保你作了一個明確的調用,在完成它們以後將其刪除(再也不須要觀察者,不然對象將沒法訪問)。

幸運的是,大多數現代瀏覽器都會爲您完成這項工做:即便您忘記刪除偵聽器,一旦觀察到的對象變得沒法訪問,他們會自動收集觀察者處理程序。在過去,一些瀏覽器沒法處理這些狀況(舊版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) // 'originalThing'的引用
      console.log("hi");
  };
  theThing = {
    longStr: new Array(1000000).join('*'),
    someMethod: function () {
      console.log("message");
    }
  };
};
setInterval(replaceThing, 1000);
複製代碼

一旦replaceThing函數被調用,theThing變量將被賦值爲一個由很長的字符串和一個新閉包(someMethod)組成的新對象。originalThing變量被一個閉包引用,這個閉包由unused變量保持。須要記住的是,當一個閉包的做用域被建立,同屬父範圍內的閉包的做用域會被共享。

在這種狀況下,閉包someMethod建立的做用域將與閉包unused的做用域共享。unused引用了originalThing,儘管代碼中unused從未被調用過,可是咱們仍是能夠在replaceThing函數外經過theThing來調用someMethod。因爲someMethod與unused的閉包做用域共享,閉包unused的引用了originalThing,強制它保持活動狀態(兩個閉包之間的共享做用域)。這阻止了它被垃圾回收。

在上面的例子中,閉包someMethod建立的做用域與閉包unused做用域的共享,而unused的引用originalThing。儘管閉包unused從未被使用,someMethod仍是能夠經過theThing,從replaceThing範圍外被調用。事實上,閉包unused引用了originalThing要求它保持活動,由於someMethod與unused的做用域共享。

閉包會保留一個指向其做用域的指針,做用域就是閉包父函數,因此閉包unused和someMethod都會有一個指針指向replaceThing函數,這也是爲何閉包能夠訪問外部函數的變量。因爲閉包unused引用了originalThing變量,這使得originalThing變量存在於lexical environment,replaceThing函數裏面定義的全部的閉包都會有一個對originalThing的引用,因此閉包someMethod天然會保持一個對originalThing的引用,因此就算theThing替換成其它值,它的上一次值不會被回收。

全部這些均可能致使至關大的內存泄漏。當上面的代碼片斷一遍又一遍地運行時,您可能會發現內存使用量激增。當垃圾收集器運行時,其大小不會縮小。建立了一個閉包的鏈表(在這種狀況下,它的根就是theThing變量),而且每一個閉包範圍都會間接引用大數組。

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'));
    // 這時咱們還有一個對 #image 的引用,這個引用在elements對象中
    // 換句話說,image元素還在內存中,不能被GC回收
}
複製代碼

涉及DOM樹內的內部節點或葉節點時,還有一個額外須要考慮的問題。若是在代碼中保留對錶格單元格(一個<td>標籤)的引用,並決定從DOM中刪除該表格並保留對該特定單元格的引用,則能夠預期會出現嚴重的內存泄漏。你可能會認爲垃圾回收器會釋放該這個單元格外的全部內容。然而,狀況並不是如此。因爲單元格是表格的子節點,而且子節點保持對父節點的引用,所以對錶格單元格的這種單引用將使整個表格保留在內存中,不能被GC回收。

後續文檔翻譯會陸續跟進!!

歡迎關注玄說前端公衆號,後續將推出系列文章《一個大型圖形化應用0到1的過程》,此帳戶也將同步更新

相關文章
相關標籤/搜索