javascript是如何工做的:03-內存管理和如何處理4種常見的內存泄漏

概述

像C語言這種具備底層內存管理的原始語言,例如malloc()free()。開發人員使用這些原始語言明確地給操做系統分配和釋放內存。javascript

同時,JavaScript 在建立事物(對象,字符串,等等)的時候分配內存,而且在再也不使用的時候「自動」釋放內存,這是一個垃圾回收的過程。釋放資源的這種看起來「自動」的特性是混亂的來源,給 JavaScript (和其餘高級語言)開發者一種他們能夠再也不關心內存管理的錯誤印象。這是一個大錯誤java

即便在使用高級語言工做的時候,開發者應該理解內存管理(或者最少是基本知識)。有時,自動內存管理存在問題(例如垃圾回收中的bug或者實現限制,等),開發人員必須瞭解這些問題才能正確處理它們(或者找到適當的解決辦法,同時儘可能減小成本和代碼負擔)。git

內存生命週期

不管你使用哪一種編程語言,內存生命週期幾乎都是同樣的:github

如下是對週期中的每一步發生的狀況概述:算法

  • 分配內存 - 內存是由操做系統分配的,操做系統容許你的程序使用它。在底層級別語言(例如C)中,這是你做爲一個開發者應該處理的明確操做。然而,在高級別語言,這是你應該處理的。
  • 使用內存 - 這其實是程序使用以前分配的內存的時候。操做在代碼中分配內存的時候發生。
  • 釋放內存 - 如今是時候釋放你再也不須要的整個內存了,這樣它能夠從新釋放而且可再次使用。與分配內存同樣,在低級別語言中這是明確的。

要快速瞭解調用棧和內存堆的概念,能夠閱讀這個主題的第一篇文章express

什麼是內存?

在直接進入 JavaScript 內存以前,咱們將簡單地討論下什麼是通常內存,以及它是如何工做的。編程

在硬件層面,計算機內存是由大量的觸發器組成。
每一個觸發器包含幾個晶體管,而且能夠存儲一個比特。每一個觸發器經過一個惟一的標識符尋址,所以咱們能夠讀取和複寫它們。所以,從概念上講,咱們能夠認爲整個計算機的內存是一組比特數組,咱們能夠讀寫他們。數組

可是做爲人類,咱們並不擅長使用比特來完成咱們全部的思考和算數,咱們將它們組成更大的組,它們能夠一塊兒來表示數字。8比特被稱爲1字節。除了字節,還有單詞(有時是16,有時是32位)。瀏覽器

內存中存儲大量的東西:緩存

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

編譯器和操做系統一塊兒工做,爲你處理大量的內存管理,可是咱們建議你查看一下背後發生的事情。

當你編譯你的代碼的時候,編譯器能夠檢查原始數據類型,而且提早計算它們須要多少內存。而後在調用堆棧空間中將須要的內存分配給程序。分配這些變量的空間叫作堆棧空間,由於在調用函數的時候,他們的內存會添加到現有的內存之上。一旦他們停止,它們將以LIFO(後入先出)的順序移除。例如,考慮下面的聲明:

init n; // 4bytes
int x[4]; // array of 4 elements, each 4 bytes
double m; // 8 bytes

編譯器能夠立馬看到這些代碼須要多大內存

4 + 4 * 4 + 8 = 28 bytes.
這就是它如何處理當前整型和double的大小。大概20年前,整型一般是2bytes,和4字節。你的代碼不該該依賴當前基本數據類型的大小。

編譯器將插入與操做系統交互的代碼,它須要請求當前棧上所需存儲變量的大小的字節數。

在上面的例子中,編譯器確切地知道每一個變量的內存地址。實際上,無論你何時書寫變量n,這將會在內部被翻譯成相似於「內存地址 4127963」。

注意到,若是咱們試圖在這裏訪問x[4],咱們將須要訪問與之關聯的m。這是由於咱們訪問了數組上不存在的元素 - 它比實際上分配給數組的最後一個元素x[3]多了4字節,而且可能將會最終讀取(或者複寫)一些m的比特。這將對剩下的程序產生很是不指望的後果。

當一個函數調用另外一個函數時,每一個函數在調用堆棧時都會獲得本身的堆棧塊。它將其全部的本地變量都保存在這裏,而且有一個程序計數器去記住執行過程當中的位置。當這個函數調用完成,它的內存塊將會用於其餘用途。

動態分配

不幸的是,當咱們不知道一個變量在編譯的時候須要多少內存,事情就變得不簡單了,假如咱們要作下面的事情:

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

這裏,在編譯的時候,編譯器不知道這個數組須要讀書內存,由於它是由user提供的值決定的。

所以,它不能爲堆棧上的變量分配空間。相反,咱們的程序須要在運行的時候向操做系統申請適當的空間。這個內存是從堆空間分配的。靜態和動態分配內存的不一樣能夠總結爲以下的表格:

要徹底理解動態內存分配是如何工做的,咱們須要花費一些時間在指針上,這可能有點偏離了本文的主體。若是你有興趣瞭解更多,請在評論區讓我知道,咱們能夠在之後的文章中再去深刻了解這些細節。

在 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 對象對原型(隱式引用)和對它屬性值(顯式引用)有一個引用。

在這種狀況下,「對象」的範圍擴展到比常規的 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. Roots:一般,roots是代碼中引用的全局變量。例如在 JavaScript 中,一個能夠充當root的全局變量是"window"對象。Node.js中相同的對象是「global」。垃圾回收生成全部roots完整的列表。
  2. 算法將會檢查roots的全部變量已經他們的children而且標記爲活動(意思是,他們不是垃圾)。root訪問不到的東西將會被標記爲垃圾。
  3. 最後,垃圾回收將會釋放沒有被標記爲活動的內存片斷,而且將內存返回給操做系統。

這個算法比以前那個好,由於「一個對象零引用」致使這個對象不可訪問。相反的狀況並不像咱們看到的循環引用那樣。

截止到2012年,全部的現代瀏覽器都實現了一個標記掃描垃圾回收。過去幾年在JavaScript垃圾回收領域所作的全部優化都是對該算法(標記掃描)的改進實現,而不是優化垃圾回收算法自己,也不是一個對象是否可訪問的目標。

在這篇文章,你能夠讀到關於追蹤垃圾回收很是詳細的介紹,而且包括標記掃描的優化。

循環再也不是問題

在上面的第一個例子中,當函數調用返回後,兩個對象再也不被全局對象的任何東西訪問。所以,垃圾回收將會找到沒法訪問的他們。

即便在兩個對象之間有引用,可是從root開始再也沒法訪問。

垃圾回收器的反直覺行爲

雖然垃圾回收器很是方便有着本身的一套推導方案。其中之一是非決定的。換句話說,CGs是不可預測的。你沒法真正判斷什麼時候執行回收。這意味着在某些狀況下,程序會使用比它實際須要的更多的內存。在其餘狀況下,在一些很是敏感的應用程序中短暫停頓是很是明顯的。雖然非肯定性意味着不能肯定何時執行回收,大多數GC在分配內存期間實現執行集合傳遞的通用模式。若是不執行分配,大多數GCs處於空閒狀態。考慮如下狀況:

  1. 執行一組至關大的分配。
  2. 大多數這些元素(或者全部)都被標記爲沒法訪問(假設咱們將再也不須要的引用指向緩存)。
  3. 沒有更多的執行分配。

在這種狀況下,大多數GCs將不會進一步執行回收。換句話說,對於收集器就算有不可訪問的變量引用,收集器也不會清理這些。這些嚴格意義上來講不是引用,可是會致使比一般更高的內存。

什麼是內存泄漏?

正如內存表示,內存泄漏是應用程序過去使用,可是將來再也不須要的內存片斷,而且沒有返回給操做系統或者空閒內存池。

編程語言傾向於不一樣的內存管理方式。然而,肯定一個內存片斷使用與否是一個難以肯定的問題。換句話說,只有開發者能夠清楚地肯定一個內存片斷是否能夠返回給操做系統。

某些編程語言提供了幫助開發者實現這個的功能。其餘則指望開發者清楚的知道一段內存未被使用。維基百科對於內存管理的手動自動有着很是好的文章。

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();
你可經過在你的 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的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保持活動(兩個閉包之間共享整個做用域)。這就阻止了回收。

在上面的例子中,爲閉包someMethod建立的做用域與unused是共享的,同時unused引用originalThingsomeMethod能夠經過replaceThing外部的做用域theThing來使用,儘管實際上unused從未被使用。實際上未使用的引用originalThing須要保持active狀態,由於someMethodunused共享做用域。

全部的這些會致使至關大的內存泄漏。當上面的代碼一遍又一遍的運行時,你可能會看到內存使用量暴增。當垃圾回收運行的時候它的大小不會縮小。一個有序的閉包列表被建立(在本例中root是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中刪除這個表格,但須要保留對特定單元格的引用,則可能會致使大的內存泄漏。你可能會認爲垃圾回收會釋放除了那個單元格之外的任何東西。然而實際上並不是如此。因爲單元格是表格的一個子節點,而且子節點保留對父節點的引用,這個對錶格單元格的單個引用將整個表格保持在內存中

相關文章
相關標籤/搜索