原文 How JavaScript works: memory management + how to handle 4 common memory leaksjavascript
幾周前咱們開始了一個系列博文旨在深刻挖掘 JavaScript
並弄清楚它的工做原理:咱們認爲經過了解 JavaScript
的構建單元並熟悉它們是怎樣結合起來的,有助於寫出更好的代碼和應用。java
本系列的第一篇文章着重提供一個關於引擎、運行時和調用棧的概述。第二篇文章深刻分析了 Google
的 V8
引擎的內部實現並提供了一些編寫更優質 JavaScript
代碼的建議。算法
在第三篇的本文中,咱們將會討論另外一個很是重要的主題,因爲平常使用的編程語言的逐漸成熟和複雜性,它被愈來愈多的開發者忽視——內存管理。咱們還會提供一些在 SessionStack
中遵循的關於如何處理 JavaScript
內存泄露的方法,咱們必須保證 SessionStack
不會發生內存泄漏,或致使整合進來的應用增長內存消耗。express
像 C
這樣的語言,具備低水平的內存管理原語如 malloc()
和 free()
,這些原語被開發者用來顯式地向操做系統分配和釋放內存。編程
同時,JavaScript
在事物(對象、字符串等)被建立時分配內存,並在它們再也不須要用到時自動釋放內存,這個過程稱爲垃圾收集。這個看似自動釋放資源的特性是困惑的來源,形成 JavaScript
(和其餘高級語言)開發者錯誤的印象,認爲他們能夠選擇沒必要關心內存管理。這是個天大的誤解。數組
即使在使用高級編程語言時,開發者也應該瞭解內存管理(至少最基本的)。有時會遇到自動內存管理的問題(如垃圾收集器的BUG和實現限制等),開發者應該瞭解這些問題才能合理地處理它們(或找到適當的解決方案,用最小的代價和代碼債)。瀏覽器
不管使用哪一種編程語言,內存的生命週期幾乎老是相同的:緩存
下面是週期中每一個步驟發生了什麼的概覽:session
C
)中這是一個做爲開發人員應該處理的顯式操做。而在高級編程語言中是由語言自己幫你處理的。想要快速瀏覽調用棧和內存堆的概念,能夠閱讀咱們關於這個主題的第一篇文章。數據結構
在直接介紹 JavaScript
中的內存以前,咱們會簡要討論一下內存是什麼及它是怎樣工做的。
在硬件層面,計算機內存由大量的觸發器組成。每一個觸發器包含幾個晶體管可以存儲一個比特(譯註:1位)。能夠經過惟一標識符來訪問單個觸發器,因此能夠對它們進行讀寫操做。所以從概念上,咱們能夠把整個計算機內存想象成一個巨大的可讀寫的比特陣列。
做爲人類,咱們並不擅長使用字節進行全部的思考和算術,咱們把它們組織成更大的組合,一塊兒用來表示數字。8比特稱爲1個字節。除字節以外,還有其餘詞(有時是16比特、有時是32比特)。
不少東西存儲在內存中:
編譯器和操做系統一塊兒工做來處理大部分的內存管理,但咱們仍是建議你瞭解一下底層發生的事情。
編譯代碼時,編譯器能夠檢測到原始數據類型而後提早計算出須要多少內存。隨後給棧空間中的程序分配所需額度。分配變量的空間被稱爲棧空間是由於當函數調用時,它們被添加到已有內存的頂部。當它們終止時,根據後進先出的原則被移除。例如,考慮以下聲明:
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();
爲了決定是否還須要對象,這個算法肯定了對象是否能夠訪問。
標記和清理算法有以下三個步驟:
JavaScript
中,能夠做爲根的全局變量是 window
對象。同一對象在 Node.js
中被稱爲 global
。垃圾收集器創建了全部根的完整列表。
這個算法比以前的更好,由於「一個對象沒有引用」形成這個對象變得不可獲取,但經過循環咱們看到反過來倒是不成立的。
2012年後,全部現代瀏覽器都裝載了標記和清理垃圾收集器。近年來,在 JavaScript
垃圾收集全部領域的改善(分代/增量/併發/並行垃圾收集)都是這個算法(標記和清理)的實現改進,既不是垃圾收集算法自身的改進也並不是決定是否對象可獲取的目標的改進。
在這篇文章中,你能夠閱讀到有關追蹤垃圾收集的大量細節,而且涵蓋了標記和清理及它的優化。
在上面的第一個例子中,當函數調用返回後,兩個對象再也不被全局對象的可獲取節點引用。結果是,它們會被垃圾收集齊認爲是不可獲取的。
即使它們彼此間仍存在引用,它們也不能被根獲取到。
雖然垃圾收集器很方便,但它們也有本身的一套折中策略。其一是非肯定性。換句話說,垃圾收集是不可預測的。你沒法確切知道垃圾收集何時執行。這意味着在一些狀況下程序會要求比實際須要更多的內存。另外一些狀況下,短時暫停會在一些特別敏感的應用中很明顯。雖然非肯定性意味着沒法肯定垃圾收集執行的時間,但大多數垃圾收集的實現都共享一個通用模式:在內存分配期間進行收集。若是沒有內存分配發生,垃圾收集器就處於閒置。考慮如下場景:
在這個場景下,大多數垃圾收集不會再運行收集傳遞。換言之,即時存在沒法訪問的引用能夠收集,它們也不會被收集器注意到。這些不是嚴格意義上的泄露,可是仍然致使了比正常更高的內存使用。
就像內存所暗示的,內存泄露是被應用使用過的一塊內存在不須要時還沒有返還給操做操做系統或因爲糟糕的內存釋放未能返還。
編程語言喜歡用不一樣的方式進行內存管理。但一塊已知內存是否還被使用其實是個沒法決定的問題。換句話說,只有開發人員能夠弄清除是否應該將一塊內存還給操做系統。
某些編程語言提供了開發人員手動釋放內存的特性。另外一些則但願由開發人員徹底提供顯式的聲明。維基百科上有關於手動和自動內存管理的好的文章。
JavaScript
泄露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
或從新分配。
讓咱們來看看 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
(其餘庫和框架也支持),它也能夠在節點無效以前移除監聽器。這個庫也會確保沒有內存泄露發生,即便應用運行在老瀏覽器之下。
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
使用(例如,在全局中)。又由於 someMethod
與 unused
共享閉包做用域,unused
引用的 originalThing
被強制處於活躍狀態(在兩個閉包之間被共享的整個做用域)。這些妨礙了被收集。
在上述列子中,當 unused
引用了 originalThing
時,共享了爲 someMethod
建立的做用域。能夠經過 replaceThing
做用域外的 theThing
使用 someMethod
,且無論其實 unused
從未使用。事實上 unused
引用了 originalThing
使其保持在活躍狀態,由於someMethod
與 unused
共享了閉包做用域。
全部的這些致使了至關大的內存泄露。你會看到在上述代碼一遍又一遍運行時內存使用量的激增。它不會在垃圾收集器運行時變小。一系列的閉包被建立(此例中根是變量 theThing
),每個閉包做用域都間接引用了大數組。
Meteor
團隊發現了這個問題,他們有一篇很是棒的文章詳細描述了這個問題。
還有種狀況是當開發人員把 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
中移除但保留對那個特別單元格的引用,就能預料到將會有大量的內存泄露。你可能認爲垃圾收集器將釋放其餘全部的東西除了那個單元格。可是,這將不會發生。由於這個單元格是表格的一個子節點,子節點保存了對它們父節點的引用,引用這一個單元格將會在內存裏保存整個表格。