↑ 點擊上方藍字關注我們,和小夥伴一起聊技術!
幾個星期前,我們開始編寫深入研究JavaScript工作原理的系列文章。通過閱讀這些文章,你可以了解到JavaScript的構建塊及其交互原理,從而能夠編寫出更好的代碼(前排提示:文中所有標藍部分均可閱讀原文獲取詳情)。
本系列的第一篇文章簡單介紹了引擎、運行時間和堆棧的調用。第二篇文章研究了谷歌V8 JavaScript引擎的內部機制,並介紹了一些編寫JavaScript代碼的技巧。
而這第三篇文章將討論另一個很重要的主題——內存管理。隨著程式語言變得越來越成熟越來越複雜,開發人員很容易忽視這一問題。同時,本文還將提供一些處理JavaScript內存洩漏的技巧,既能確保SessionStack不會出現內存洩漏,也不會增加web應用程式的內存佔用。
概述
像C這樣的程式語言都會有低級別的內存管理原語,例如malloc()和free()。開發人員使用這些原語能夠顯式地對內存進行分配和釋放。
而JavaScript會在對象(對象、字符串等)創建時為它們分配內存,在對象不再使用時,「自動」釋放內存。這個過程我們稱之為垃圾收集。這種看似很「自動化」的資源釋放機制其實是混亂的根源,因為這給JavaScript(以及其他高級語言)開發人員帶來了一種錯覺,認為自己可以不用管理內存。這種想法是錯誤的。
即使是使用高級語言,開發人員也應該了解一些內存管理方面的知識(或者至少懂得一些基礎知識)。因為在自動內存管理(比如垃圾收集器的bug或實現限制等)出現問題的時候,開發人員必須能夠理解並正確地解決這些問題(或者找到一個合適的解決方案,以最低的代價來修改代碼)。
內存的生命周期
無論使用哪種程式語言,內存的生命周期都是一樣的:
這裡簡單介紹一下內存生命周期中的每一個階段:
分配內存——內存由作業系統分配,並允許程序使用它。在低級語言(例如C)中,開發人員必須顯式地執行這一操作。而在高級語言中,系統會自動為你分配內存。
使用內存——在這一步中,程序將使用先前分配的內存。在代碼中使用已分配過內存的變量時,就會發生內存讀寫操作。
釋放內存——釋放所有不再使用的內存,使之成為自由內存,並可以被重利用。與分配內存操作一樣,這一操作在低級語言中也是需要顯式地執行。
要快速了解調用棧和內存堆的相關概念,你可以閱讀本系列的第一篇文章。
內存是什麼?
在介紹JavaScript中的內存之前,我們先來簡單討論一下什麼是內存,以及它是如何工作的。
在硬體層面上,計算機存儲器由大量的觸發器組成。每個觸發器包含了一些電晶體,並且能夠存儲一個比特(bit,又稱「位」)。單個觸發器由唯一的標識符來尋址,這樣我們就能夠讀取和覆蓋它們。因此,從概念上講,可以把整個計算機內存看作是可以讀寫的一個巨大數組。
因為我們並不擅長用比特來思考和計算,所以要把它們組織成更大的群體,這樣才可以用來表示數字。8個比特稱為1個字節(byte)。除了字節之外,還有字(word,有時是16位,有時是32位)。
很多東西都存儲在內存中:
程序使用的所有變量和其他數據。
程序的代碼,包括作業系統的代碼。
編譯器和作業系統會為你處理大部分的內存管理工作,但你還是需要了解一下底層到底發生了什麼。
編譯代碼時,編譯器會檢查原始數據類型並提前計算所需的內存,然後將所需的數量分配給調用堆棧空間中的程序。為這些變量分配的空間稱為棧空間,因為當函數被調用時,它們的內存就會被添加到現有內存中。當調用終止時,它們將會在LIFO命令(後進先出)中被移除。例如,看一下這個聲明:
int n; // 4個字節
int x[4]; // 4個元素的數組,每個元素4個字節
double m; // 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把開發人員從內存分配的責任中解救了出來: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();
標記-清除(Mark-and-sweep)算法
該算法能夠判斷出某個對象是否可以訪問,從而知道該對象是否有用。
該算法由以下步驟組成:
垃圾收集器構建一個「根」列表,用於保存引用的全局變量。在JavaScript中,「window」對象是一個可作為根節點的全局變量。
所有根節點都會被檢查並標記為活動的(也就是說不是垃圾)。子節點都是遞歸檢查的,所有可以從根節點中得到的都不被認為是垃圾。
所有未標記為活動的內存碎片都被視為垃圾。收集器現在可以釋放這些內存並將其還給作業系統。
這個算法比上一個算法要好,因為「一個對象沒有被引用」就意味著這個對象無法訪問。
截止到2012年,所有的現代瀏覽器都有一個「標記-清除」垃圾收集器。在過去的幾年裡,JavaScript在垃圾收集(生成、增量、並發、並行的垃圾收集)領域所做的所有改進都是對該算法實現的改進(標記和清除),而不是對垃圾收集算法本身的改進。
在這篇文章中,你可以更詳細地閱讀到有關跟蹤垃圾收集的詳細信息,同時還包括了標記-清除算法及其優化。
循環不再是問題
在上面的第一個例子中,函數調用返回後,那兩個對象就不再被全局對象可訪問的東西所引用。因此,垃圾收集器會認為它們不可訪問。
儘管對象之間存在引用,但它們對於根節點來說是不可達的。
垃圾收集器的反直觀行為
儘管垃圾收集器很方便,但它們有一套自己的折衷方案,其中之一就是非決定論,換句話說,GC是不可預測的,你無法真正判斷何時進行垃圾收集。這意味著在某些情況下,程序會使用更多的內存,這實際上是必需的。在對速度特別敏感的應用程式中,可能會很明顯的感受到短時間的停頓。如果沒有分配內存,則大多數GC將處於空閒狀態。看看以下場景:
在此場景中,大多數GC將不再繼續收集。換句話說,即使是不可用的引用,收集器也不會奪走這些引用。雖然這些並不是嚴重的內存洩漏,但仍然會出現高於平時內存使用的情況。
內存洩漏是什麼?
從本質上說,內存洩漏可以定義為:不再被應用程式所需要的內存,出於某種原因,它不會返回到作業系統或空閒內存池中。
程式語言支持不同的內存管理方法。然而,某一塊內存是否被使用實際上無法判斷。換句話說,只有開發人員才知道這塊內存是否可以還給作業系統。
某些程式語言為開發人員提供了幫助,另一些則期望開發人員能清楚地了解內存何時不再被使用。維基百科上有一些有關人工和自動內存管理的很不錯的文章。
四種常見的內存洩漏
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解析模式,以防止意外的全局變量。你可以在這裡了解更多到有關這種JavaScript執行的模式。
儘管我們討論的是未知的全局變量,但仍然有很多代碼充斥著顯式的全局變量。根據定義,這些是不可收集的(除非被指定為空或重新分配)。用於臨時存儲和處理大量信息的全局變量特別令人擔憂。如果你必須使用一個全局變量來存儲大量數據,那麼請確保將其指定為null,或者在完成後將其重新賦值。
2. 被遺忘的定時器和回調
在JavaScript中,setInterval的使用很常見。
大多數提供了觀察器和採用回調工具的庫,都會在自身實例變得不可訪問時,自動將指向回調的引用置為不可訪問。然而,對於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也不能被收集。
對觀察器來說,當變量不再需要的時候,需要顯示地刪除它們(或者是無法訪問的關聯對象)。
過去的某些瀏覽器(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.
現代瀏覽器(包括Internet Explorer和Microsoft Edge)使用了先進的垃圾收集算法來檢測這些循環並能夠正確處理它們。換句話說,在將節點置為不可訪問之前,無需嚴格調用removeEventListener。
一些框架或庫,比如JQuery,會在處置節點之前自動刪除監聽器(在使用它們特定的API的時候)。這是由庫內部的機制實現的,能夠確保不發生內存洩漏,即使在有問題的瀏覽器下運行也能這樣,比如……IE 6。
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)的新對象。同時,變量unused指向一個引用了originalThing的閉包。是不是很混亂,嘿嘿?重要的是,一旦具有相同父作用域的多個閉包的作用域被創建,則這個作用域就可以被共享。
在這種情況下,為閉包someMethod而創建的作用域可以被unused共享的。unused內部存在一個對originalThing的引用。即使unused從未使用過,someMethod也可以在replaceThing的作用域之外(例如在全局範圍內)通過theThing來被調用。由於someMethod共享了unused閉包的作用域,那麼unused引用包含的originalThing會迫使它保持活動狀態(兩個閉包之間的整個共享作用域)。這阻止了它被收集。
當這段代碼重複運行時,可以觀察到內存使用在穩定增長,當GC運行後,內存使用也不會變小。從本質上說,在運行過程中創建了一個閉包鍊表(它的根是以變量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樹的內部或葉節點時,還需要額外注意一個問題。假設你在JavaScript代碼中有一個指向某個表(<td>標記)的特定單元格的引用。有一天,你決定從DOM中刪除這個表,但要保留對該單元格的引用。人們可能會認為GC會收集所有的東西,除了單元格。但事實上,這種情況並不會發生。單元格是該表的子節點,而子節點則會引用父節點。也就是說,JavaScript代碼中引用整個表的單元格會使得整個表留在內存中。在保存對DOM元素的引用時,要仔細考慮這個問題。
在SessionStack,我們編寫代碼的時候一直遵循著這些最佳實踐,並對處理內存分配十分謹慎,因為:
一旦將SessionStack集成到你web應用程式中,它就會開始記錄所有的內容,包括:所有DOM的更改、用戶交互、JavaScript異常、堆棧跟蹤、失敗的網絡請求、調試消息等等。通過使用SessionStack,你可以將web應用程式中的問題作為視頻進行回放,並查看發生在用戶身上的所有內容。所有這些都必須在對web應用程式性能沒有影響的情況下進行。
由於用戶可以重新加載頁面或瀏覽web應用,所以你必須正確處理所有的監視器、攔截器、變量分配等等,這樣,才不會出現任何形式的內存洩漏,也不會增加所集成的web應用的內存佔用。
我們有一個免費的試用體驗,你可以試一試。
參考資源
http://www-bcf.usc.edu/~dkempe/CS104/08-29.pdf
https://blog.meteor.com/an-interesting-kind-of-javascript-memory-leak-8b47d2e7f156
http://www.nodesimplified.com/2017/08/javascript-memory-management-and.html
原文:How JavaScript works: memory management + how to handle 4 common memory leaks
作者:Alexander Zlatkov
譯者:雁驚寒