原文:How JavaScript works: memory management + how to handle 4 common memory leaksjavascript
譯者:neal1991java
welcome to star my articles-translator , providing you advanced articles translation. Any suggestion, please issue or contact megit
LICENSE: MITgithub
幾個禮拜以前咱們開始一系列對於JavaScript以及其本質工做原理的深刻挖掘:咱們認爲經過了解JavaScript的構建方式以及它們是如何共同合做的,你就可以寫出更好的代碼以及應用。算法
這個系列的第一篇博客專一於介紹對於引擎,運行時以及調用棧的概述(譯者注:第一篇博客翻譯版)。第二篇博客近距離地檢測了Google V8 引擎的內部而且提供了一些如何寫出更好的JavaScript代碼的建議。shell
在第三篇博客中,咱們將會討論另一個關鍵的話題。這個話題因爲隨着編程語言的逐漸成熟和複雜化,愈來愈被開發者所忽視,這個話題就是在平常工做中使用到的——內存管理。咱們還將提供一些有關如何處理咱們在SessionStack中的JavaScript中的內存泄漏的建議,由於咱們須要確保SessionStack不會致使內存泄漏或者增長咱們集成的Web應用程序的內存消耗。express
語言,好比C,具備低層次的內存管理方法,好比malloc()
以及free()
。開發者利用這些方法精確地爲操做系統分配以及釋放內存。編程
同時,JavaScript會在建立一些變量(對象,字符串等等)的時候分配內存,而且會在這些不被使用以後「自動地」釋放這些內存,這個過程被稱爲垃圾收集。這個看起來「自動化的」特性其實就是產生誤解的緣由,而且給JavaScript(以及其餘高層次語言)開發者一個假象,他們不須要關心內存管理。大錯特錯。數組
即便是使用高層次語言,開發者應該對於內存管理有必定的理解(或者最基本的理解)。有時候自動的內存管理會存在一些問題(好比一些bug或者垃圾收集器的一些限制等等),對於這些開發者必須可以理解從而可以合適地處理(或者使用最小的代價以及代碼債務去繞過這個問題)。瀏覽器
無論你在使用什麼編程語言,內存的生命週期基本上都是同樣的:
下面是對於週期中每一步所發生的狀況的概述:
對於調用棧以及內存堆有一個快速的概念認識,你能夠閱讀咱們關於這個話題的第一篇博客。
在咱們講述JavaScript內存以前,咱們將簡要地討論一下內存是什麼以及它們是如何在 nutshell 中工做的。
在硬件層次上,計算機內存由大量的 寄存器 組成。每個寄存器都包含一些晶體管而且可以存儲一比特。單獨的寄存器能夠經過獨特的標識符去訪問,所以咱們可以讀取以及重寫它們。所以,從概念上來講,咱們能夠認爲咱們的整個計算機內存就是一個咱們可以讀寫的大型比特數組。
由於做爲人類,咱們不擅長直接基於比特進行思考以及算術,咱們將它們組織成大規模羣組,它們在一塊兒能夠表明一個數字。8個比特稱爲一個字節。除了字節,還有詞(有時候是16比特,有時候是32比特)。
內存中存儲了不少東西:
編譯器和操做系統共同合做爲你處理大部分的內存管理,可是咱們建議你應該瞭解其內部的運行原理。
當你編譯你的代碼的時候,編譯器將會檢查原始數據類型而且提早計算好它們須要多少內存。須要的內存被分配給程序,這被稱爲棧空間。這些被分配給變量的空間被稱爲棧空間,由於一旦函數被調用,它們的內存就會增長到現有內存的上面。當它們終止的時候,它們就會之後進先出(LIFO)的順序移除。好比,考慮下面的聲明。
int n; // 4 bytes int x[4]; // array of 4 elements, each 4 bytes double m; // 8 bytes
編譯器可以當即計算出代碼須要
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
可以分配新的值或者對象的方法:
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對象要廣而且包括函數做用域(或者全局詞法做用域)。
詞法做用域定義了變量名稱是如何在嵌套函數中解析的:內部函數包含了父函數的做用域即便父函數已經返回了。
這是最簡單的垃圾收集器算法。若是沒有引用指向這個對象的時候,這個對象就被認爲是「能夠做爲垃圾收集」。
請看以下代碼:
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();
爲了決定哪一個對象是須要的,算法會決定是否這個對象是可訪問的。
這個算法由如下步驟組成:
這個算法要優於以前的由於「一個具備0引用的對象」可讓一個對象不可以再被訪問。可是相反的卻不必定成立,好比咱們遇到循環的時候。
在2012年,全部的現代瀏覽器都使用標記-清除垃圾收集器。過去幾年,JavaScript垃圾收集(代數/增量/並行/並行垃圾收集)領域的全部改進都是對該算法(標記和掃描)的實現進行了改進,但並無對垃圾收集算法自己的改進, 其目標是肯定一個對象是否可達。
在這篇文章中,你能夠獲得更多關於垃圾收集追蹤而且也覆蓋到了關於標記-清除算法的優化。
在上述的第一個例子中,在函數調用返回以後,這兩個對象不可以被全局對象所訪問。所以,垃圾收集器就會發現它們不可以被訪問了。
即便在這兩個對象之間存在着引用,它們不再能從root訪問了。
雖然垃圾收集器很方便,但它們本身也有本身的代價。 其中一個是非肯定論。 換句話說,GC是不可預測的。 你不能真正地告訴你何時會收集。 這意味着在某些狀況下,程序會使用實際須要的更多內存。 在其餘狀況下,特別敏感的應用程序可能會引發短暫暫停。 雖然非肯定性意味着在執行集合時沒法肯定,但大多數GC實現共享在分配期間執行收集遍歷的常見模式。 若是沒有執行分配,大多數GC保持空閒狀態。 考慮如下狀況:
在這種狀況下,大多數GC不會再運行收集處理。換句話說,即便存在對於收集器來講不可訪問的引用,它們也不會被收集器所認領。嚴格意義來講這並非泄露,可是依然會致使比日常更多的內存使用。
實質上,內存泄漏能夠被定義爲應用程序再也不須要的內存,可是因爲某些緣由不會返回到操做系統或可用內存池。
編程語言有支持管理內存的不一樣方法。 然而,某塊內存是否被使用其實是一個不可斷定的問題。 換句話說,只有開發人員能夠清楚一個內存是否能夠返回到操做系統。
某些編程語言提供了幫助開發者執行此操做的功能。其餘的則指望開發人員可以徹底明確什麼時候使用一塊內存。 維基百科有關於手動和自動內存管理的好文章。
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();
爲了阻止這些錯誤的發生,能夠在js文件頭部添加'use strict'。這將會使用嚴格模式來解析 JavaScript 從而阻止意外的全局變量。瞭解更多關於JavaScript執行的模式。
即便咱們討論了未預期的全局變量,但仍然有不少代碼用顯式的全局變量填充。 這些定義是不可收集的(除非分配爲null或從新分配)。 特別是,用於臨時存儲和處理大量信息的全局變量值得關注。 若是你必須使用全局變量來存儲大量數據,請確保在完成以後將其分配爲null或從新分配。
setInterval
在 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
所表明的對象在將來可能被移除,讓部分interval 處理器中代碼變得再也不被須要。然而,這個處理器不可以被收集由於interval依然活躍的(這個interval須要被中止從而表面這種狀況)。若是這個interval處理器不可以被收集,那麼它的依賴也不可以被收集。這意味這存儲大量數據的severData
也不可以被收集。
在這種觀察者的狀況下,作出準確的調用從而在不須要它們的時候當即將其移除是很是重要的(或者相關的對象被置爲不可訪問的)。
過去,之前特別重要的是某些瀏覽器(好的老IE 6)沒法管理好循環引用(有關更多信息,請參見下文)。 現在,大多數瀏覽器一旦觀察到的對象變得沒法訪問,就能收集觀察者處理器,即便偵聽器沒有被明確刪除。 可是,在處理對象以前,明確刪除這些觀察者仍然是一個很好的作法。 例如:
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.
當今,如今瀏覽器(報錯IE和Edge)都使用了現代的垃圾收集算法,其可以檢測到這些循環而且進行適宜的處理。換句話說,不再是嚴格須要在將節點置爲不可訪問以前調用removeEventListener 。
框架和庫(如jQuery)在處理節點以前(在爲其使用特定的API時)會刪除偵聽器。 這是由庫內部處理的,這也確保沒有泄漏,即便在有問題的瀏覽器下運行,如...是的,IE 6。
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
)。同時,unused
會保持一個指向originalThing
引用的閉包(從上一個調用的theThing
到replaceThing
)。可能已經很迷惑了,是否是?重要的事情是一旦在相同的父級做用域爲閉包產生做用域,這個做用域就會被共享。
在這種狀況下,爲someMethod
閉包產生的做用域就會被unused
所共享。unused
具備對於originaThing
的引用。即便 unused
再也不被使用,someMethod
依然能夠經過replaceThing
做用域以外的theThing
來使用。而且因爲somethod
和unused
共享閉包做用域,unused指向originalThing的引用強迫其保持活躍(兩個閉包之間的整個共享做用域)。這將會阻止垃圾手機。
當這個代碼段重複運行時,能夠觀察到內存使用量的穩定增加。 當GC運行時,這不會變小。 實質上,建立了一個關閉的連接列表(其root以TheThing變量的形式),而且這些閉包的範圍中的每個都對大數組進行間接引用,致使至關大的泄漏。
這個問題由Meteor團隊發現,他們有一篇很好的文章,詳細描述了這個問題。
有時將DOM節點存儲在數據結構中多是有用的。 假設要快速更新表中的幾行內容。 存儲對字典或數組中每一個DOM行的引用多是有意義的。 當發生這種狀況時,會保留對同一DOM元素的兩個引用:一個在DOM樹中,另外一個在字典中。 若是未來某個時候您決定刪除這些行,則須要使兩個引用置爲不可訪問。
var elements = { button: document.getElementById('button'), image: document.getElementById('image') }; function doStuff() { 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樹內部的內部或葉節點的引用時,必須考慮這一點。 假設你在JavaScript代碼中保留對錶格特定單元格(<td>標記)的引用。 有一天,你決定從DOM中刪除該表,但保留對該單元格的引用。 直觀地,能夠假設GC將收集除了該單元格以外的全部內容。 實際上,這不會發生:該單元格是該表的子節點,而且孩子們保持對父代的引用。 也就是說,從JavaScript代碼引用表格單元會致使整個表保留在內存中。 保持對DOM元素的引用時須要仔細考慮。