概述某些語言,比如C有低級的原生內存管理原語,像 malloc() 和 free() 。開發人員使用這些原語可以顯式分配和釋放操作系統的內存。
相對地,JavaScript會在創建變量(對象、字符串)時自動分配內存,並在這些變量不被使用時自動釋放內存,這個過程被稱為 垃圾回收 。這個“自動”釋放資源的特性帶來了很多困惑,讓JavaScript(和其他高級級語言)開發者誤以為可以不關心內存管理。 這是壹個很大的錯誤
即使使用高級級語言,開發者也應該對於內存管理有壹定的理解(至少有基本的理解)。有時自動內存管理存在壹些問題(例如垃圾回收實現可能存在缺陷或者不足),開發者必須弄明白這些問題,以便找壹個合適解決方法。
內存生命周期無論妳用哪壹種編程語言,內存生命周期幾乎總是壹樣的:
Here is an overview of what happens at each step of the cycle:
這是對生命周期中的每壹步大概的說明:
分配內存 — 內存是被操作系統分配,這允許程序使用它。在低級語言中(例如C),這是壹個作為開發者需要處理的顯式操作。在高級語言中,然而,這些操作都代替開發者進行了處理。
使用內存。 實際使用之前分配的內存,通過在代碼操作變量對內在進行讀和寫。
釋放內存 。不用的時候,就可以釋放內存,以便重新分配。與分配內存操作壹樣,釋放內存在低級語言中也需要顯式操作。
想要快速的了解堆棧和內存的概念,可以閱讀本系列第壹篇文章。
什麽是內存
在直接探討Javascript中的內存之前,我們先簡要的討論壹下什麽是內存、內存大概是怎麽樣工作的。
在硬件中,電腦的內存包含了大量的觸發電路,每壹個觸發電路都包含壹些<span style="font-size: 1rem;">能夠儲存1位數據的</span>晶體管。觸發器通過 唯壹標識符 來尋址,從而可以讀取和覆蓋它們。因此,從概念上來講,可以認為電腦內存是壹個巨大的可讀寫陣列。
人類不善於把我們所有的思想和算術用位運算來表示,我們把這些小東西組織成壹個大家夥,這些大家夥可以用來表現數字:8位是壹個字節。字節之上是字(16位、32位)。
許多東西被存儲在內存中:
所有的變量和程序中用到的數據;
程序的代碼,包括操作系統的代碼。
編譯器和操作系統***同工作幫助開發者完成大部分的內存管理,但是我們推薦妳了解壹下底層到底發生了什麽。
編譯代碼的時候,編譯器會解析原始數據類型,提前計算出它們需要多大的內存空間。然後將所需的數量分配在 棧空間 中。之所以稱為棧空間,是因在函數被調用的時候,他們的內存被添加在現有內存之上(就是會在棧的最上面添加壹個棧幀來指向存儲函數內部變量的空間)。終止的時候,以LIFO(後進先出)的順序移除這些調用。例如:
int n; // 4字節
int x[4]; // 4個元素的數組,每個元素4字節
double m; // 8字節編譯器馬上知道需要內存
4 + 4 × 4 + 8 = 28字節。
這是當前整型和雙精度的大小。大約20年以前,整型通常只需要2個字節,雙精度需要4個字節,妳的代碼不受基礎數據類型大小的限制。
編譯器會插入與操作系統交互的代碼,來請求棧中必要大小的字節來儲存變量。
在上面的例子中,編輯器知道每個變量準確的地址。事實上,無論什麽時候我們寫變量 n ,將會在內部被翻譯成類似“memory address 4127963”的語句。
註意,如果我們嘗試訪問 x[4] 的內存(開始聲明的x[4]是長度為4的數組, x[4] 表示第五個元素),我們會訪問m的數據。那是因為我們正在訪問壹個數組裏不存在的元素,m比數組中實際分配內存的最後壹個元素 x[3] 要遠4個字節,可能最後的結果是讀取(或者覆蓋)了 m 的壹些位。這肯定會對其他程序產生不希望產生的結果。
當函數調用其他函數的時候,每壹個函數被調用的時候都會獲得自己的棧塊。在自己的棧塊裏會保存函數內所有的變量,還有壹個程序計數器會記錄變量執行時所在的位置。當函數執行完之後,會釋放它的內存以作他用。
動態分配
不幸的是,事情並不是那麽簡單,因為在編譯的時候我們並不知道壹個變量將會需要多少內存。假設我們做了下面這樣的事:
int n = readInput(); //讀取用戶的輸入
...
//創建壹個有n個元素的數組編譯器不知道這個數組需要多少內存,因為數組大小取決於用戶提供的值。
因此,此時不能在棧上分配空間。程序必須在運行時向操作系統請求夠用的空間。此時內存從 堆空間 中被分配。靜態與動態分配內存之間的不同在下面的表格中被總結出來:
靜態分配內存與動態分配內存的區別。
為了完全理解動態內存是如何分配的,我們需要花更多的時間在 指針 上,這個可能很大程度上偏離了這篇文章的主題。如果妳有興趣學習更多的知識,那就在評論中讓我知道,我就可以在之後的文章中寫更多關於指針的細節。
JavaScript中的內存分配
現在我們來解釋JavaScript中的第壹步( 分配內存 )是如何工作的。
JavaScript在開發者聲明值的時候自動分配內存。
var n = 374; // 為數值分配內存
var s = 'sessionstack'; //為字符串分配內存
var o = {
a: 1,
b: null
}; //為對象和它包含的值分配內存
var a = [1, null, 'str']; //為數組和它包含的值分配內存
function f(a) {
return a + 3;
} //為函數(可調用的對象)分配內存
//函數表達式也會分配壹個對象
someElement.addEventListener('click', function() {
someElement.style.backgroundColor = 'blue';
}, false);
//壹些函數調用也會導致對象分配
`var d = new Date(); // allocates a Date object` //分配壹個Date對象的內存
`var e = document.createElement('p'); //分配壹個DOM元素的內存
//方法可以分配新的值或者對象
var s1 = 'sessionstack';
var s2 = s1.substr(0, 3); //s2是壹個新的字符串
// 因為字符串是不可變的
// JavaScript可能決定不分配內存
// 而僅僅存儲 0-3的範圍
var a1 = ['str1', 'str2'];
var a2 = ['str3', 'str4'];
var a3 = a1.concat(a2);
//新的數組有4個元素是a1和a2連接起來的。在JavaScript中使用內存
在JavaScript中使用被分配的內存,本質上就是對內在的讀和寫。
比如,讀、寫變量的值或者對象的屬性,抑或向壹個函數傳遞參數。
內存不在被需要時釋放內存
大部分的內存管理問題都在這個階段出現。
這裏最難的任務是找出這些被分配的內存什麽時候不再被需要。這常常要求開發者去決定程序中的壹段內存不在被需要而且釋放它。
高級語言嵌入了壹個叫 垃圾回收 的軟件,它的工作是跟蹤內存的分配和使用,以便於發現壹些內存在壹些情況下不再被需要,它將會自動地釋放這些內存。
不幸的是,這個過程是壹個近似的過程,因為壹般關於知道內存是否是被需要的問題是不可判斷的(不能用壹個算法解決)。
大部分的垃圾回收器會收集不再被訪問的內存,例如指向它的所有變量都在作用域之外。然而,這是壹組可以收集的內存空間的近似值。因為在任何時候,壹個內存地址可能還有壹個在作用域裏的變量指向它,但是它將不會被再次訪問。
垃圾收集
由於找到壹些內存是否是“不再被需要的”這個事實是不可判定的,垃圾回收的實現存在局限性。本節解釋必要的概念去理解主要的垃圾回收算法和它們的局限性。
內存引用
垃圾回收算法依賴的主要概念是 引用。
在內存管理的語境下,壹個對象只要顯式或隱式訪問另壹個對象,就可以說它引用了另壹個對象。例如,JavaScript對象引用其Prototype( 隱式引用 ),或者引用prototype對象的屬性值( 顯式引用 )。
在這種情況下,“對象”的概念擴展到比普通JavaScript對象更廣的範圍,並且還包含函數作用域。(或者global 詞法作用域 )
詞法作用域定義變量的名字在嵌套的函數中如何被解析:內部的函數包含了父級函數的作用域,即使父級函數已經返回。
引用計數垃圾回收
這是最簡單的垃圾回收算法。 壹個對象在沒有其他的引用指向它的時候就被認為“可被回收的”。
看壹下下面的代碼:
var o1 = {
o2: {
x: 1
}
};
//2個對象被創建
/'o2'被'o1'作為屬性引用
//誰也不能被回收
var o3 = o1; //'o3'是第二個引用'o1'指向對象的變量
o1 = 1; //現在,'o1'只有壹個引用了,就是'o3'
var o4 = o3.o2; // 引用'o3'對象的'o2'屬性
//'o2'對象這時有2個引用: 壹個是作為對象的屬性
//另壹個是'o4'
o3 = '374'; //'o1'原來的對象現在有0個對它的引用
//'o1'可以被垃圾回收了。
//然而它的'o2'屬性依然被'o4'變量引用,所以'o2'不能被釋放。
o4 = null; //最初'o1'中的'o2'屬性沒有被其他的引用了
//'o2'可以被垃圾回收了循環引用創造麻煩
在涉及循環引用的時候有壹個限制。在下面的例子中,兩個對象被創建了,而且相互引用,這樣創建了壹個循環引用。它們會在函數調用後超出作用域,應該可以釋放。然而引用計數算法考慮到2個對象中的每壹個至少被引用了壹次,因此都不可以被回收。
function f() {
var o1 = {};
var o2 = {};
o1.p = o2; // o1 引用 o2
o2.p = o1; // o2 引用 o1\. 形成循環引用
}
f();
標記清除算法
為了決定壹個對象是否被需要,這個算法用於確定是否可以找到某個對象。
這個算法包含以下步驟。
垃圾回收器生成壹個根列表。根通常是將引用保存在代碼中的全局變量。在JavaScript中,window對象是壹個可以作為根的全局變量。
所有的根都被檢查和標記成活躍的(不是垃圾),所有的子變量也被遞歸檢查。所有可能從根元素到達的都不被認為是垃圾。
所有沒有被標記成活躍的內存都被認為是垃圾。垃圾回收器就可以釋放內存並且把內存還給操作系統。
上圖就是標記清除示意。
這個算法就比之前的(引用計算)要好些,因為“壹個對象沒有被引用”導致這個對象不能被訪問。相反,正如我們在循環引用的示例中看到的,對象不能被訪問到,不壹定不存在引用。
2012年起,所有瀏覽器都內置了標記清除垃圾回收器。在過去幾年中,JavaScript垃圾回收領域中的所有改進(代/增量/並行/並行垃圾收集)都是由這個算法(標記清除法)改進實現的,但並不是對垃圾收集算法本身的改進,也沒有改變它確定對象是否可達這個目標。
推薦 壹篇文章 ,其中有關於跟蹤垃圾回收的細節,包括了標記清除法和它的優化算法。
循環引用不再是問題
在上面的例子中(循環引用的那個),在函數執行完之後,這個2個對象沒有被任何可以到達的全局對象所引用。因此,他們將會被垃圾回收器發現為不可到達的。
盡管在這兩個對象之間有相互引用,但是他們不能從全局對象上到達。
垃圾回收器的反常行為
盡管垃圾回收器很方便,但是他們有壹套自己的方案。其中之壹就是不確定性。換句話說,GC是不可預測的。妳不可能知道壹個回收器什麽時候會被執行。這意味著程序在某些情況下會使用比實際需求還要多的內存。在其他情況下,在特別敏感的應用程序中,可能會出現短停頓。盡管不確定意味著不能確定回收工作何時執行,但大多數GC實現都會在分配內存的期間啟動收集例程。如果沒有內存分配,大部分垃圾回收就保持空閑。參考下面的情況。
執行相當大的壹組分配。
這些元素中的大部分(或者所有的)都被標記為不可到達的(假設我們清空了壹個指向我們不再需要的緩存的引用。)
沒有更多的分配被執行。
在這種情況下,大多數垃圾回收實現都不會做進壹步的回收。換句話說,盡管這裏有不可達的引用變量可供回收,回收器也不會管。嚴格講,這不是泄露,但結果卻會占用比通常情況下更多的內存。
什麽是內存泄漏
內存泄漏基本上就是不再被應用需要的內存,由於某種原因,沒有被歸還給操作系統或者進入可用內存池。
編程語言喜歡不同的管理內存方式。然而,壹段確定的內存是否被使用是壹個不可判斷的問題。換句話說,只有開發者才能弄清楚,是否壹段內存可以被還給操作系統。
某些編程語言為開發者提供了釋放內存功能。另壹些則期待開發者清楚的知道壹段內存什麽時候是沒用的。Wikipedia有壹篇非常好的關於內存管理的文章。
4種常見的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作為函數調用,this指向全局變量(window)
// 而不是undefined
foo();為了防止這些問題發生,可以在妳的JaveScript文件開頭使用 'use strict'; 。這個可以使用壹種嚴格的模式解析JavaScript來阻止意外的全局變量。
除了意外創建的全局變量,明確創建的全局變量同樣也很多。這些當然屬於不能被回收的(除非被指定為null或者重新分配)。特別那些用於暫時存儲數據的全局變量,是非常重要的。如果妳必須要使用全局變量來存儲大量數據,確保在是使用完成之後為其賦值 null或者重新賦其他值。
2: 被遺忘的定時器或者回調
在JavaScript中使用 setInterval 是十分常見的。
大多數庫,特別是提供觀察器或其他接收回調的實用函數的,都會在自己的實例無法訪問前把這些回調也設置為無法訪問。但涉及 setInterval 時,下面這樣的代碼十分常見:
var serverData = loadData();
setInterval(function() {
var renderer = document.getElementById('renderer');
if(renderer) {
renderer.innerHTML = JSON.stringify(serverData);
}
}, 5000); //每5秒執行壹次定時器可能會導致對不需要的節點或者數據的引用。
renderer 對象在將來有可能被移除,讓interval處理器內部的整個塊都變得沒有用。但由於interval仍然起作用,處理程序並不能被回收(除非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);
// 做點事
element.removeEventListener('click', onClick);
element.parentNode.removeChild(element);
// 當元素被銷毀
//元素和事件都會即使在老的瀏覽器裏也會被回收如今的瀏覽器(包括IE和Edge)使用現代的垃圾回收算法,可以立即發現並處理這些循環引用。換句話說,先調用 removeEventListener 再刪節點並非嚴格必要。
jQuery等框架和插件會在丟棄節點前刪除監聽器。這都是它們內部處理,以保證不會產生內存泄漏,甚至是在有問題的瀏覽器(沒錯,IE6)上也不會。
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 )的對象。同時,變量 unused 保持了壹個引用 originalThing ( theThing 是上次調用 replaceThing 生成的值)的閉包。已經有點困惑了吧?最重要的事情是 壹旦為同壹父域中的作用域產生閉包,則該作用域是***享的。
這裏,作用域產生了閉包, someMethod 和 unused ***享這個閉包中的內存。 unused 引用了 originalThing 。盡管 unused 不會被使用, someMethod 可以通過 theThing 來使用 replaceThing 作用域外的變量(例如某些全局的)。而且 someMethod 和 unused 有***同的閉包作用域, unused 對 originalThing 的引用強制 oriiginalThing 保持激活狀態(兩個閉包***享整個作用域)。這阻止了它的回收。
當這段代碼重復執行,可以觀察到被使用的內存在持續增加。垃圾回收運行的時候也不會變小。從本質上來說,閉包的連接列表已經創建了(以 theThing 變量為根),這些閉包每個作用域都間接引用了大數組,導致大量的內存泄漏。
這個問題被Meteor團隊發現,他們有描述了閉包大量的細節。
4: DOM外引用
有的時候在數據結構裏存儲DOM節點是非常有用的,比如妳想要快速更新壹個表格幾行的內容。此時存儲每壹行的DOM節點的引用在壹個字典或者數組裏是有意義的。此時壹個DOM節點有兩個引用:壹個在dom樹中,另外壹個在字典中。如果在未來的某個時候妳想要去移除這些排,妳需要確保兩個引用都不可到達。
var elements = {
button: document.getElementById('button'),
image: document.getElementById('image')
};
function doStuff() {
image.src = '/image_name.png';
}
function removeImage() {
//image是body元素的子節點
document.body.removeChild(document.getElementById('image'));
//這個時候我們在