随着現在的程式設計語言功能越來越成熟、複雜,記憶體管理也容易被大家忽略。本文将會讨論JavaScript中的記憶體洩漏以及如何處理,友善大家在使用JavaScript編碼時,更好的應對記憶體洩漏帶來的問題。
概述
像C語言這樣的程式設計語言,具有簡單的記憶體管理功能函數,例如malloc( )和free( )。開發人員可以使用這些功能函數來顯式地配置設定和釋放系統的記憶體。
當建立對象和字元串等時,JavaScript就會配置設定記憶體,并在不再使用時自動釋放記憶體,這種機制被稱為垃圾收集。這種釋放資源看似是“自動”的,但本質是混淆的,這也給JavaScript(以及其他進階語言)的開發人員産生了可以不關心記憶體管理的錯誤印象。其實這是一個大錯誤。
即使使用進階語言,開發人員也應該了解記憶體管理的知識。有時自動記憶體管理也會存在問題(例如垃圾收集器中的錯誤或實施限制等),開發人員必須了解這些問題才能正确地進行處理。
記憶體生命周期
無論你使用的是什麼程式設計語言,記憶體生命周期幾乎都是一樣的:
以下是對記憶體生命周期中每個步驟發生的情況的概述:
- 配置設定記憶體 - 記憶體由作業系統配置設定,允許程式使用它。在簡單的程式設計語言中,這個過程是開發人員應該處理的一個顯式操作。然而,在進階程式設計語言中,系統會幫助你完成這個操作。
- 記憶體使用 - 這是程式使用之前申請記憶體的時間段,你的代碼會通過使用配置設定的變量
來對記憶體進行讀取和寫入操作。
- 釋放記憶體 - 對于不再需要的記憶體進行釋放的操作,以便確定其變成空閑狀态并且可以被再次使用。與配置設定記憶體操作一樣,這個操作在簡單的程式設計語言中是需要顯示操作的。
什麼是記憶體?
在硬體層面上,計算機的記憶體由大量的觸發器組成的。每個觸發器包含一些半導體,并能夠存儲一位資料。單獨的觸發器可以通過唯一的辨別符來尋址,是以我們可以讀取和覆寫它們。是以,從概念上講,我們可以把整個計算機記憶體看作是我們可以讀寫的一大塊空間。
很多東西都存儲在記憶體中:
- 程式使用的所有變量和其他資料。
- 程式的代碼,包括作業系統的代碼。
編譯器和作業系統一起工作,來處理大部分的記憶體管理,但是我們需要了解從本質上發生了什麼。
編譯代碼時,編譯器會檢查原始資料類型,并提前計算它們需要多少記憶體,然後将所需的記憶體配置設定給調用堆棧空間中的程式。配置設定這些變量的空間被稱為堆棧空間,随着函數的調用,記憶體會被添加到現有的記憶體之上。當終止時,空間以LIFO(後進先出)順序被移除。例如如下聲明:
int n; // 4個位元組
int x [4]; // 4個元素的數組,每一個占4個位元組
double m; // 8個位元組
複制
編譯器插入與作業系統進行互動的代碼,以便在堆棧中請求所需的位元組數來存儲變量。
在上面的例子中,編譯器知道每個變量的确切記憶體位址。實際上,每當我們寫入這個變量n,它就會在内部翻譯成“記憶體位址4127963”。
注意,如果我們試圖通路x[4],我們将通路與m關聯的資料。這是因為我們正在通路數組中不存在的元素 - 它比數組中最後一個資料實際配置設定的元素多了4個位元組x[3],并且可能最終讀取(或覆寫)了一些m比特。這對其餘部分會産生不利的後果。
當函數調用其它函數時,每個函數被調用時都會得到自己的堆棧塊。它會保留所有的局部變量和一個程式計數器,還會記錄執行的地方。當功能完成時,其記憶體塊會被釋放,可以再次用于其它目的。
動态配置設定
如若我們不知道編譯時,變量需要的記憶體數量時,事情就會變得複雜。假設我們想要做如下事項:
int n = readInput(); //讀取使用者的輸入
...
//用“n”個元素建立一個數組
複制
在編譯時,編譯器不知道數組需要多少記憶體,因為它是由使用者提供的輸入值決定的。
是以,它不能為堆棧上的變量配置設定空間。相反,我們的程式需要在運作時明确地向作業系統請求适當的空間。這個記憶體是從堆空間配置設定的。下表總結了靜态和動态記憶體配置設定之間的差別:
在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( );
複制
标記和掃描算法
為了決定是否需要對象,标記和掃描算法會确定對象是否是活動的。
标記和掃描算法經過以下3個步驟:
- roots:通常,root是代碼中引用的全局變量。例如,在JavaScript中,可以充當root的全局變量是“視窗”對象。Node.js中的相同對象稱為“全局”。所有root的完整清單由垃圾收集器建構。
- 然後算法會檢查所有root和他們的子對象并且标記它們是活動的(即它們不是垃圾)。任何root不能達到的,将被标記為垃圾。
- 最後,垃圾回收器釋放所有未标記為活動的記憶體塊,并将該記憶體傳回給作業系統。
這個算法比引用計數垃圾收集算法更好。JavaScript垃圾收集(代碼/增量/并發/并行垃圾收集)領域中所做的所有改進都是對這種标記和掃描算法的實作改進,但不是對垃圾收集算法本身的改進。
周期不再是問題了
在上面的互相引用例子中,在函數調用傳回之後,兩個對象不再被全局對象可通路的對象引用。是以,它們将被垃圾收集器發現,進而進行收回。
即使在對象之間有引用,它們也不能從root目錄中通路,進而會被認為是垃圾而收集。
抵制垃圾收集器的直覺行為
盡管垃圾收集器使用起來很友善,但它們也有自己的一套标準,其中之一是非決定論。換句話說,垃圾收集是不可預測的。你不能真正知道什麼時候進行收集,這意味着在某些情況下,程式會使用更多的記憶體,雖然這是實際需要的。在其它情況下,在特别敏感的應用程式中,短暫暫停是很可能出現的。盡管非确定性意味着不能确定何時進行集合,但大多數垃圾收集實作了共享在配置設定期間進行收集的通用模式。如果沒有執行配置設定,大多數垃圾收集會保持空閑狀态。如以下情況:
- 大量的配置設定被執行。
- 大多數這些元素(或所有這些元素)被标記為無法通路(假設我們将一個引用指向不再需要的緩存)。
- 沒有進一步的配置設定執行。
在這種情況下,大多數垃圾收集不會做出任何的收集工作。換句話說,即使有不可用的引用需要收集,但是收集器不會進行收集。雖然這并不是嚴格的洩漏,但仍會導緻記憶體使用率高于平時。
什麼是記憶體洩漏?
記憶體洩漏是應用程式使用過的記憶體片段,在不再需要時,不能傳回到作業系統或可用記憶體池中的情況。
程式設計語言有各自不同的記憶體管理方式。但是是否使用某一段記憶體,實際上是一個不可判定的問題。換句話說,隻有開發人員明确的知道是否需要将一塊記憶體傳回給作業系統。
四種常見的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( );
複制
你可以通過在JavaScript檔案的開始處添加‘use strict’;來避免這中錯誤,這種方式将開啟嚴格的解析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對象可能會在某些時候被替換或删除,這會使interval處理程式封裝的塊變得備援。如果發生這種情況,那麼處理程式及其依賴項都不會被收集,因為interval需要先停止。這一切都歸結為存儲和處理負載資料的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以便使其它保持活躍(兩封閉之間的整個共享範圍)。這就阻止了它被收集。
所有這些都可能導緻相當大的記憶體洩漏。當上面的代碼片段一遍又一遍地運作時,你會看到記憶體使用率的不斷上升。當垃圾收集器運作時,其記憶體大小不會縮小。這種情況會建立一個閉包的連結清單,并且每個閉包範圍都帶有對大數組的間接引用。
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中删除該表格,還需要保留對該特定單元格的引用,則可能會出現嚴重的記憶體洩漏。你可能會認為垃圾收集器會釋放除了那個單元之外的所有東西,但情況并非如此。由于單元格是表格的一個子節點,并且子節點保留着對父節點的引用,是以對表格單元格的這種引用,會将整個表格儲存在記憶體中。
總結
以上内容是對JavaScript記憶體管理機制的講解,以及常見的四種記憶體洩漏的分析。希望對JavaScript的程式設計人員有所幫助。
原文連結:https://blog.sessionstack.com/how-javascript-works-memory-management-how-to-handle-4-common-memory-leaks-3f28b94cfbec