JavaScript 是如何工作的:記憶體管理 + 如何處理四種常見的記憶體洩漏
原文 - How JavaScript works: memory management + how to handle 4 common memory leaks
原文作者 - Alexander Zlatkov
原文地址 - blog.sessionstack.com/how-javascr…
譯者 - yanlee
譯文地址 - yanlee26.github.io/2018/02/01/…
知乎專欄 - zhuanlan.zhihu.com/p/33483627 掘金專欄 - juejin.im/post/5a725c…
幾個星期前,我們開始了一系列旨在深入研究JavaScript及其實際工作原理的系列文章:我們認為通過了解JavaScript的構建塊以及它們如何一起玩,您將能夠編寫更好的程式碼和應用程序。
本系列的第一篇文章重點介紹了引擎,執行時和呼叫堆疊的概述。第二偏文章仔細研究谷歌的V8 JavaScript引擎的內部零件,也提供了有關如何寫出更好的JavaScript程式碼的一些提示。
在這個第三篇文章中,我們將討論另一個越來越被開發人員忽視的關鍵主題,因為日常使用的程式語言(記憶體管理)越來越成熟和複雜。我們也將提供關於如何處理JavaScript中的記憶體洩漏,我們在一些技巧SessionStack中遵循我們需要確保SessionStack不會造成記憶體洩漏,或不增加的Web應用程式,我們正在整合的記憶體消耗。
概觀
像C這樣的語言具有低階的記憶體管理原語,比如malloc()和free()。開發人員使用這些原語來顯式分配和釋放作業系統的記憶體。
同時,當事物(物件,字串等)被建立時,JavaScript分配記憶體,並在不再使用時自動釋放記憶體,稱為垃圾收集。這種釋放資源的看似“自動化”特性是混淆的一個原因,給JavaScript(和其他高階語言)的開發人員帶來了他們可以選擇不關心記憶體管理的錯誤印象。這是一個大錯誤。
即使使用高階語言,開發人員也應該理解記憶體管理(至少是基本的)。有時,自動記憶體管理存在問題(例如垃圾收集器中的錯誤或實施限制等),開發人員必須瞭解這些問題才能正確處理這些問題(或者找到適當的解決方法,並且具有最小的權衡和程式碼債務)。
記憶體生命週期
無論您使用什麼程式語言,記憶體生命週期幾乎都是一樣的:
以下是對迴圈中每個步驟發生的情況的概述:
- 分配記憶體 - 記憶體由作業系統分配,允許程式使用它。在低階語言中(例如C),這是一個作為開發人員應該處理的顯式操作。然而,在高階語言中,這是為你照顧的。
- 使用記憶體 - 這是您的程式實際上使用以前分配的記憶體的時間。讀取和寫入操作正在您的程式碼中使用分配的變數。
- 釋放記憶體 - 現在是釋放你不需要的整個記憶體的時間,以便它可以變成空閒的並且可以再次使用。與分配記憶體操作一樣,這個操作在低階語言中是明確的。
有關呼叫堆疊和記憶體堆的概念的快速概述,您可以閱讀我們關於主題的第一篇文章。
什麼是記憶體?
在直接跳到JavaScript中的記憶體之前,我們將簡要地討論一下記憶體的概況以及它是如何工作的。
在硬體層面上,計算機記憶體由大量的 觸發器組成。每個觸發器包含一些電晶體,並能夠儲存一位。單獨的觸發器可以通過唯一的識別符號來定址,所以我們可以讀取和覆蓋它們。因此,從概念上講,我們可以將整個計算機記憶體看作是我們可以讀寫的一大塊位。
既然作為人類,我們並不善於把所有的思想和算術都做成一點點,我們把它們組織成更大的群體,它們可以一起用來表示數字。8位稱為1個位元組。除位元組外,還有單詞(有時是16,有時是32位)。
很多東西都儲存在這個記憶體中:
- 所有程式使用的所有變數和其他資料。
- 程式的程式碼,包括作業系統的程式碼。
編譯器和作業系統一起工作,為您處理大部分的記憶體管理,但是我們建議您看看底下發生了什麼。
編譯程式碼時,編譯器可以檢查原始資料型別,並提前計算它們需要多少記憶體。然後將所需的金額分配給呼叫堆疊空間中的程式。分配這些變數的空間稱為堆疊空間,因為隨著函式被呼叫,它們的記憶體被新增到現有的記憶體之上。當它們終止時,它們以LIFO(後進先出)順序被移除。例如,請考慮以下宣告:
int n; // 4個位元組
int x [4]; // 4個元素的陣列,每4個位元組
雙m; // 8個位元組
複製程式碼
編譯器可以立即看到程式碼需要
4 + 4×4 + 8 = 28個位元組。
這就是它與目前的整數和雙打的尺寸。大約20年前,整數通常是2個位元組,雙4位元組。您的程式碼不應該依賴於此刻基本資料型別的大小。
編譯器將插入與作業系統互動的程式碼,以便為堆疊中的變數儲存所需的位元組數。
在上面的例子中,編譯器知道每個變數的確切記憶體地址。實際上,每當我們寫入這個變數n,它就會在內部翻譯成“記憶體地址4127963”。
注意,如果我們試圖訪問x[4]這裡,我們將訪問與m關聯的資料。這是因為我們正在訪問陣列中不存在的元素 - 它比陣列中最後一個實際分配的元素多了4個位元組x[3],並且可能最終讀取(或覆蓋)了一些m位元。這對方案的其餘部分幾乎肯定會產生非常不希望的後果。
當函式呼叫其他函式時,每個函式呼叫時都會得到自己的堆疊塊。它保留了所有的區域性變數,而且還有一個程式計數器,它記錄了執行的地方。當功能完成時,其記憶體塊再次可用於其他目的。
動態分配
不幸的是,當我們不知道編譯時變數需要多少記憶體時,事情並不那麼容易。假設我們想要做如下的事情:
int n = readInput(); //從使用者讀取輸入
//用“n”個元素建立一個陣列
複製程式碼
這裡,在編譯時,編譯器不知道陣列需要多少記憶體,因為它是由使用者提供的值決定的。
因此,它不能為堆疊上的變數分配空間。相反,我們的程式需要在執行時明確地向作業系統請求適當的空間。這個記憶體是從堆空間分配的。下表總結了靜態和動態記憶體分配之間的區別:
靜態和動態分配的記憶體之間的差異為了充分理解動態記憶體分配是如何工作的,我們需要在指標上花費更多的時間,這可能與本文主題偏離太多。如果您有興趣瞭解更多資訊,請在評論中告訴我們,我們可以在以後的文章中詳細介紹指標。
在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中使用分配的記憶體,意味著在其中讀寫。
這可以通過讀取或寫入變數或物件屬性的值,甚至將引數傳遞給函式來完成。
當記憶體不再需要時釋放
大部分記憶體管理問題都是在這個階段。
這裡最困難的任務是確定何時不再需要分配的記憶體。它通常需要開發人員確定程式中的哪個部分不再需要這些記憶體,並將其釋放。
高階語言嵌入了一個名為垃圾收集器的軟體,其工作是跟蹤記憶體分配和使用情況,以便在不再需要分配記憶體的情況下自動釋放記憶體。
不幸的是,這個過程是一個近似值,因為知道是否需要某些記憶體的一般問題是不可判定的(不能由演算法來解決)。
大多數垃圾收集器通過收集不能被訪問的記憶體來工作,例如指向它的所有變數超出範圍。然而,這是可以收集的一組記憶體空間的近似值,因為在任何時候記憶體位置可能仍然有一個指向它的變數,但它將不會被再次訪問。
垃圾收集
由於發現一些記憶體是否“不再需要”的事實是不可判定的,所以垃圾收集實現了對一般問題的解決方案的限制。本節將解釋理解主要垃圾收集演算法及其侷限性的必要概念。
記憶體引用
垃圾收集演算法所依賴的主要概念是引用(reference)之一。
在記憶體管理的情況下,如果一個物件訪問後者(可以是隱含的或顯式的),則稱該物件引用另一個物件。例如,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個步驟:
- 根:通常,根是程式碼中引用的全域性變數。例如,在JavaScript中,可以充當根的全域性變數是“視窗”物件。Node.js中的相同物件稱為“全域性”。所有根的完整列表由垃圾收集器構建。
- 演算法然後檢查所有根和他們的孩子並且標記他們是活躍的(意思,他們不是垃圾)。任何根不能達到的將被標記為垃圾。
- 最後,垃圾回收器釋放所有未標記為活動的記憶體塊,並將該記憶體返回給作業系統。
標記和掃描演算法的視覺化
這個演算法比前一個演算法更好,因為“一個物件有零引用”導致這個物件無法訪問。正如我們已經看到週期一樣,情況正好相反。
截至2012年,所有現代瀏覽器都發布了標記式的垃圾回收器。JavaScript垃圾收集(程式碼/增量/併發/並行垃圾收集)領域中所做的所有改進都是對這種演算法(標記和掃描)的實現改進,但不是對垃圾收集演算法本身的改進,也不是它的目標是決定一個物件是否可達。
在本文中,您可以詳細閱讀有關跟蹤垃圾回收的更詳細資訊,這些垃圾回收也涵蓋了標記和掃描以及其優化。
迴圈不再是問題了
在上面的第一個例子中,在函式呼叫返回之後,兩個物件不再被全域性物件可訪問的東西引用。因此,它們將被垃圾收集器發現無法訪問。
即使在物件之間有引用,它們也不能從根目錄訪問。
抵制垃圾收集器的直觀行為
儘管垃圾收集者很方便,但他們也有自己的一套權衡。其中之一是非決定論。換句話說,GC是不可預測的。你不能真正知道什麼時候收集。這意味著在某些情況下,程式會使用更多的記憶體,這是實際需要的。在其他情況下,在特別敏感的應用程式中,短暫暫停可能是顯而易見的。雖然非確定性意味著不能確定何時執行集合,但大多數GC實現共享在分配期間進行收集通行證的通用模式。如果沒有執行分配,大多數GC保持空閒狀態。考慮以下情況:
- 大量的分配被執行。
- 大多數這些元素(或所有這些元素)被標記為無法訪問(假設我們將一個引用指向我們不再需要的快取)。
- 沒有進一步的分配執行。 在這種情況下,大多數GC不會執行任何進一步的收集通行證。換句話說,即使有不可用的引用可用於收集,這些收集器不會宣告。這些並不是嚴格的洩漏,但仍會導致記憶體使用率高於平時。
什麼是記憶體洩漏?
就像記憶體建議一樣,記憶體洩漏是應用程式過去使用的記憶體片段,但不再需要,但尚未返回到作業系統或可用記憶體池。
程式語言有利於不同的記憶體管理方式。但是,是否使用某一段記憶體實際上是一個不可判定的問題。換句話說,只有開發人員可以明確是否可以將一塊記憶體返回到作業系統。
某些程式語言提供了幫助開發人員執行此操作 其他人則希望開發人員能夠完全清楚一段記憶體何時未被使用。維基百科有關手動和自動記憶體管理的好文章。
四種常見的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();
複製程式碼
您可以通過‘use strict’;在JavaScript檔案的開始處新增以避免所有這些,這將開啟更嚴格的解析JavaScript模式,從而防止意外建立全域性變數。
意外的全域性變數當然是一個問題,然而,更多的時候,你的程式碼會受到垃圾收集器無法收集的顯式全域性變數的影響。需要特別注意用於臨時儲存和處理大量資訊的全域性變數。如果您必須使用全域性變數來儲存資料,那麼確保將其分配為空值,或者在完成後重新分配。
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物件可能會在某些時候被替換或刪除,這會使間隔處理程式封裝的塊變得冗餘。如果發生這種情況,那麼處理程式及其依賴項都不會被收集,因為間隔需要先停止(請記住,它仍然是活動的)。這一切都歸結為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在現代瀏覽器支援可以檢測這些週期並適當處理它們的垃圾收集器之前,不再需要呼叫節點。
如果您利用jQueryAPI(其他庫和框架也支援這一點),您也可以在節點過時之前刪除偵聽器。即使應用程式在較舊的瀏覽器版本下執行,庫也會確保沒有記憶體洩漏。
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變數所持有的閉包所引用(這個theThing變數是前一次呼叫的變數replaceThing)。需要記住的是,一旦在同一父作用域中為閉包建立了閉包的作用域,作用域就被共享了。
在這種情況下,為閉包建立的範圍將someMethod與之共享unused。unused有一個參考originalThing。即使unused從未使用過,someMethod 也可以theThing在整個範圍之外使用replaceThing(例如全球某個地方)。而且someMethod與封閉範圍一樣unused,引用unused必須originalThing強制它保持活躍(兩封閉之間的整個共享範圍)。這阻止了它的收集。
在上面的例子中,所述封閉建立的範圍someMethod與共享unused,而unused引用originalThing。someMethod可以theThing在replaceThing範圍之外使用,儘管這unused是從來沒有使用的事實。事實上,未使用的引用originalThing要求它保持活躍,因為someMethod與未使用的共享封閉範圍。
所有這些都可能導致相當大的記憶體洩漏。當上面的程式碼片段一遍又一遍地執行時,您可以預期會看到記憶體使用率的上升。當垃圾收集器執行時,其大小不會縮小。建立一個閉包的連結串列(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樹內的內部節點或葉節點時,還有一個額外的考慮因素需要考慮。如果您在程式碼中保留對錶格單元格(標籤)的引用,並決定從DOM中刪除該表格,並保留對該特定單元格的引用,則可以預期會出現嚴重的記憶體洩漏。你可能會認為垃圾收集器會釋放除了那個單元之外的所有東西。但情況並非如此。由於單元格是表格的子節點,並且子節點保持對父節點的引用,所以對錶格單元格的這種單引用可以將整個表格儲存在記憶體中。
我們在SessionStack嘗試遵循這些最佳實踐,編寫正確處理記憶體分配的程式碼,原因如下:
一旦將SessionStack整合到生產Web應用程式中,它就會開始記錄所有事件:所有DOM變更,使用者互動,JavaScript異常,堆疊跟蹤,網路請求失敗,除錯訊息等等。
使用SessionStack,您可以在Web應用程式中重放問題,看到你的使用者發生的一切。所有這些都必須在您的網路應用程式沒有效能影響的情況下進行。
由於使用者可以重新載入頁面或導航你的應用程式,所有的觀察者,攔截器,變數分配等都必須正確處理,所以它們不會導致任何記憶體洩漏,或者不會增加Web應用程式的記憶體消耗我們正在整合。
有一個免費的計劃,所以你可以試試看