幾個星期前,咱們開始了一個系列,旨在更深刻地研究 JavaScript 及其實際工做原理:咱們認爲,經過了解 JavaScript 的構建塊以及它們如何協同工做,您將可以編寫更好的代碼和應用程序。javascript
本系列的第一篇文章重點介紹了引擎、運行時和調用堆棧的概述。第二篇文章仔細研究了谷歌的 V8 JavaScript 引擎的內部部分,也提供了一些建議關於如何編寫更好的JavaScript代碼。前端
在這第三篇文章中,咱們將討論另外一個重要的主題——內存管理,因爲平常使用的編程語言的日益成熟和複雜性,這個主題愈來愈被開發人員忽視。咱們還將提供一些關於如何在 SessionStack中處理 JavaScript 中的內存泄漏的技巧,由於咱們須要確保 SessionStack 不會致使內存泄漏或不會增長集成在其中的 web 應用程序的內存消耗。java
像 C 這樣的語言有低級的內存管理原語,如 malloc()
和 free()
。開發人員使用這些原語來顯式地在操做系統之間分配和釋放內存。web
同時,JavaScript在建立對象(對象、字符串等)時分配內存,在再也不使用時「自動」釋放內存,這個過程稱爲 垃圾收集。這種看似「自動」釋放資源的特性是混亂的根源,並給JavaScript(和其餘高級語言)開發人員一種錯誤的印象,他們能夠選擇不關心內存管理。這是一個大錯誤。算法
即便在使用高級語言時,開發人員也應該瞭解內存管理(或至少了解基本知識)。有時,自動內存管理會出現一些問題(好比說出現了 bug 或者垃圾收集器中的實現限制等),開發人員必須瞭解這些問題才能正確地處理它們(或者找到一種適當的替代方案,以實現最小的折中和代碼改動)。express
不管你使用哪一種編程語言,內存生命週期幾乎老是相同的:編程
如下是在循環的每一步所發生的事情的概述:api
分配內存——內存是由操做系統分配的,並容許您的程序去使用。在低級語言(如C)中,這是一個做爲開發人員應該處理的顯式操做。然而,在高級語言中,就已經爲您處理好了。數組
使用內存——這是程序實際使用以前分配的內存的時間。當您在代碼中使用分配的變量時,將執行讀寫操做。瀏覽器
釋放內存——如今是時候釋放您不須要的整個內存了,這樣它就能夠再次變得空閒和可用。與分配內存操做同樣,這個操做在低級語言中是顯式的。
要快速瞭解調用堆棧和內存堆的概念,能夠閱讀咱們關於這個主題的第一篇文章。
在直接跳到 JavaScript 中的內存以前,咱們將簡要地討論一般意義的內存是什麼以及它是如何工做的。
在硬件層面上,計算機內存由大量的觸發器組成。每一個觸發器包含幾個晶體管,可以存儲一比特。單個觸發器可由惟一標識符尋址,所以咱們能夠讀取或者覆蓋它們。所以,從概念上講,咱們能夠把整個計算機內存看做是一個能夠讀寫的巨大位數組。
由於做爲人類,咱們並不擅長把全部的思考和算術都以比特的形式表現出來,因此咱們把它們組織成更大的羣體,這些羣體能夠用來表示數字。8位稱爲1字節。除了字節以外,還有單詞(有時是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();
複製代碼
爲了肯定一個對象是否被須要,該算法肯定對象是不是可得到的。
標記清除法經過這三個步驟:
一、根:一般,根表示的是在代碼中引用的全局變量。例如,在JavaScript中,能夠充當根的全局變量是「window」對象。在 Node.js 中相同的對象被稱爲 「global」。垃圾收集器將構建全部根的完整列表。
二、而後,算法會檢查全部根及其子節點,並將它們標記爲活動的(這意味着它們不是垃圾)。不屬於任何一個根的內存會被標記爲垃圾。
三、最後,垃圾收集器釋放全部未標記爲活動的內存塊,並將這些內存返回給操做系統。
這個算法比以前的算法更好,由於「一個對象沒有被引用」會致使這個對象不能被訪問。一樣,相反的狀況並不像咱們在循環中看到的那樣。從2012年開始,全部的現代瀏覽器都推出了「標記清除」垃圾收集器。過去幾年,在 JavaScript 垃圾收集領域(世代/增量/並行/並行垃圾收集)所作的全部改進都是該算法(標記清除)的實現改進,但不是垃圾收集算法自己的改進,或者決定一個對象是否是可獲取的這個目標的改進
在本文中,您能夠更詳細地瞭解跟蹤垃圾收集,其中也包括標記清除算法及其優化。
在上面的第一個例子中,函數調用返回後,兩個對象再也不被全局對象中可訪問的對象引用。所以,垃圾收集器將把他們標記爲不可訪問的。
即便這兩個對象互相引用,它們也不能從 window 中被訪問。儘管垃圾收集器很方便,但它們仍是有本身的權衡。其中一個是不肯定性。換句話說,垃圾收集器是不可預測的。您不能真正的分辨出垃圾回收器何時會被執行。這意味着在某些狀況下,程序會使用比實際須要更多的內存。在其餘狀況下,在特別敏感的應用程序中可能會出現短暫的停頓。儘管不肯定性意味着不能肯定什麼時候執行垃圾回收,可是大多數垃圾收集器的實現共享了在內存分配期間執行垃圾回收這樣的公共模塊。若是不執行內存分配,大多數垃圾收集器將保持空閒狀態。考慮如下場景:
執行分配一組很大的內存。
這些元素中的大部分(或所有)都被標記爲不可得到的(假設咱們將指向咱們再也不須要的一片內存的引用設爲 null
)。
再也不執行進一步的內存分配。
在這種狀況下,大多數垃圾收集器將再也不進行任何垃圾回收。換句話說,即便有能夠被回收的不可得到的引用,也不會被收集器標記。這些並非嚴格意義上的泄漏,但仍然會致使比一般更高的內存使用量。
正如內存所暗示的,內存泄漏是應用程序在過去使用過但再也不被須要的,但還沒有返回到操做系統或空閒內存池的內存片斷。
編程語言喜歡使用不一樣的內存管理方法。然而,是否使用某段內存其實是一個 沒法肯定的問題。換句話說,只有開發人員才能弄清楚一塊內存是否能夠返回到操做系統。某些編程語言提供了幫助開發人員完成內存分配和回收的特性。另外一些則但願開發人員可以徹底清楚地知道什麼時候有一塊內存未被使用。Wikipedia有關於手動和自動內存管理的好文章。
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 或者從新賦值。
讓咱們以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.
複製代碼
上面的代碼片斷顯示了使用計時器引用再也不須要的節點或數據的結果。
渲染器對象可能會被替換或刪除,這將使由interval處理程序封裝的塊變得多餘。若是發生這種狀況,處理程序及其依賴項都不會被收集,由於須要首先中止間隔(請記住,它仍然是活動的)。這一切都歸結到一個事實,即服務器數據,它確定存儲和處理負載的數據也不會被收集。
在使用觀察者時,您須要確保在使用完它們以後進行顯式調用來刪除它們(要麼再也不須要觀察者,要麼對象將變得不可到達)。
幸運的是,大多數現代瀏覽器均可覺得您完成這項工做:一旦觀察到的對象變得沒法訪問,即便您忘記刪除偵聽器,它們也會自動收集觀察者處理程序。在過去,一些瀏覽器沒法處理這些狀況(好的舊版本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(其餘庫和框架也支持此功能),您還能夠在節點廢棄以前刪除偵聽器。即便應用程序在較舊的瀏覽器版本下運行,該庫也會確保沒有內存泄漏。
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被調用,這個東西就會獲得一個新的對象,這個對象由一個大數組和一個新的閉包(someMethod)組成。然而,originalThing是由未使用的變量(即上一次調用replaceThing的thing變量)持有的閉包引用的。須要記住的是,一旦在同一個父範圍中爲閉包建立了範圍,這個範圍就會被共享。
在本例中,爲閉包someMethod建立的範圍與未使用的共享。未使用的有對原始事物的引用。即便從未使用過,也有一些方法能夠經過replaceThing範圍以外的事物來使用(例如,在全球的某個地方)。因爲someMethod與未使用的包共享閉包範圍,未使用的引用必須保持活動(兩個閉包之間的整個共享範圍)。這阻止了它的收集。
在上面的示例中,爲閉包someMethod建立的範圍與未使用的共享,而未使用的引用原始值。有些方法能夠經過replaceThing做用域以外的東西來使用,儘管事實上從未使用過。未使用的引用originalThing要求它保持活動狀態,由於someMethod與未使用的共享閉包範圍。
全部這些都會致使至關大的內存泄漏。當上面的代碼片斷反覆運行時,您可能會看到內存使用量的激增。當垃圾收集器運行時,它的大小不會縮小。建立閉包的鏈表(在本例中,它的根是theThing變量),每一個閉包做用域都間接引用大數組。
這個問題是由流星小組發現的,他們有一篇很好的文章來詳細描述這個問題。
在某些狀況下,開發人員將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樹中的內部節點或葉節點時,還須要考慮另一個問題。若是您在代碼中保留了對錶單元格的引用(標記),並決定從DOM中刪除表,但保留了對特定單元格的引用,那麼可能會出現嚴重的內存泄漏。您可能認爲垃圾收集器會釋放除那個單元格以外的全部內容。然而,事實並不是如此。因爲單元格是表的子節點,而且子節點保留對父節點的引用,因此對錶單元格的單個引用將把整個表保存在內存中。
咱們在SessionStack試圖遵循這些最佳實踐來編寫正確處理內存分配的代碼,緣由以下:
一旦您將SessionStack集成到您的產品web應用程序中,它將開始記錄全部內容:全部DOM更改、用戶交互、JavaScript異常、堆棧跟蹤、失敗的網絡請求、調試消息等等。
使用SessionStack,您能夠將web應用程序中的問題以視頻的形式重播,並查看發生在用戶身上的全部事情。全部這些都必須在不影響web應用程序性能的狀況下進行。
因爲用戶能夠從新加載頁面或導航應用程序,因此必須正確處理全部的觀察者、攔截器、變量分配等,這樣它們就不會形成任何內存泄漏,也不會增長咱們集成的web應用程序的內存消耗。
思想參考來自www-bcf.usc.edu/~dkempe/CS1…
思想參考來自 David Glasse 的blog.meteor.com/an-interest…
思想參考來自 Sebastián Peyrott 的auth0.com/blog/four-t…
概念部分來自 MDN 前端 文檔developer.mozilla.org/en-US/docs/…