JS 內(nèi)存泄漏與垃圾回收機制
前言
不管什么程序語言,內(nèi)存生命周期基本是一致的:
分配你所需要的內(nèi)存 使用分配到的內(nèi)存(讀、寫) 不需要時將其釋放\歸還
“所有語言 第二部分都是明確的,第一和第三部分在 底層語言 中是明確的。但在像 JavaScript 這些高級語言中,大部分都是隱含的。
”
“注意:JavaScript 是一種高級的解釋執(zhí)行的編程語言,是一種屬于網(wǎng)絡的高級腳本語言。
”
在 Chrome 瀏覽器中,V8 被限制了內(nèi)存的使用(64位約1.4G/1464MB , 32位約0.7G/732MB),限制的主要原因是 V8 最初為瀏覽器而設計,不太可能遇到用大量內(nèi)存的場景;更深層原因是 V8 垃圾回收機制的限制:清理大量的內(nèi)存垃圾很耗時間,這樣會引起 JavaScript 線程暫停執(zhí)行,導致性能和應用直線下降。
JavaScript 是在創(chuàng)建變量(對象,字符串等)時自動進行了分配內(nèi)存,并且在不使用它們時“自動”釋放。釋放的過程稱為垃圾回收。
這個“自動”是混亂的根源,并讓 JavaScript 開發(fā)者錯誤的感覺他們可以不關心內(nèi)存管理,進而引發(fā)內(nèi)存泄漏。
一、什么是內(nèi)存泄漏
程序的運行需要內(nèi)存。只要程序提出要求,操作系統(tǒng)或者運行時(runtime)就必須供給內(nèi)存。
對于持續(xù)運行的服務進程,必須及時釋放不再用到的內(nèi)存。否則,內(nèi)存占用越來越高,輕則影響系統(tǒng)性能,重則導致進程崩潰。
不再用到的內(nèi)存,沒有及時釋放,就叫做內(nèi)存泄漏(memory leak)。
二、常見的內(nèi)存泄漏
寫得不好的JavaScript可能出現(xiàn)難以察覺且有害的內(nèi)存泄漏問題。
在內(nèi)存有限的設備上,或者在函數(shù)會被調(diào)用很多次的情況下,內(nèi)存泄漏可能是個大問題。JavaScript中的內(nèi)存泄漏大部分是由不合理的引用導致的。
下面來講一講常見的內(nèi)存泄漏:
1. 意外聲明的全局變量
意外聲明全局變量是最常見但也最容易修復的內(nèi)存泄漏問題。下面的代碼沒有使用任何關鍵字聲明變量:
function?setName()?{
??name?=?'yuanyuan';
}
此時,解釋器會把變量 name 當作 window 的屬性來創(chuàng)建(相當于 window.name = 'yuanyuan' )。可想而知,在 window 對象上創(chuàng)建的屬性,只要 window 本身不被清理就不會消失。這個問題很容易解決,只要在變量聲明前頭加上 var 、 let 或 const 關鍵字即可,這樣變量就會在函數(shù)執(zhí)行完畢后離開作用域。
2. 被遺忘的定時器
定時器也可能會悄悄地導致內(nèi)存泄漏。下面的代碼中,定時器的回調(diào)通過閉包引用了外部變量:
let?name?=?'yuanyuan';?
setInterval(()?=>?{
??console.log(name);?
},?100);
只要定時器一直運行,回調(diào)函數(shù)中引用的 name 就會一直占用內(nèi)存。垃圾回收程序當然知道這一點,因而就不會清理外部變量。
3. 使用不當?shù)拈]包
使用 JavaScript 閉包很容易在不知不覺間造成內(nèi)存泄漏。請看下面的例子:
let?outer?=?function()?{?
??let?name?=?'yuanyuan';?
??return?function()?{?
????return?name;?
??};?
};
這會導致分配給 name 的內(nèi)存被泄漏。以上代碼創(chuàng)建了一個內(nèi)部閉包,只要 outer 函數(shù)存在就不能清理 name ,因為閉包一直在引用著它。假如 name 的內(nèi)容很大(不止是一個小字符串),那可能就是個大問題了。
4. 未清理的 DOM 引用
DOM 元素的生命周期正常情況下取決于是否掛載在 DOM 樹上,當元素從 DOM 樹上移除時,就可以被銷毀回收了。
但如果某個 DOM 元素在 JS 中也持有它的引用,想要徹底刪除這個元素,就需要把兩個引用都清除,這樣才能正常回收它。
//?在對象中引用?DOM
var?elements?=?{
??btn:?document.getElementById('btn'),
}
function?doSomeThing()?{
??elements.btn.click()
}
function?removeBtn()?{
??//?移除?DOM?樹中的?btn
??document.body.removeChild(document.getElementById('button'))
??//?但是此時全局變量?elements?還是保留了對?btn?的引用,?btn?還是存在于內(nèi)存中,不能被?GC?回收
}
雖然別的地方刪除了,但是對象中還存在對 dom 的引用。
解決方法是刪除 DOM 節(jié)點時,也要釋放 JS 對節(jié)點的引用:
elements.btn?=?null;
三、垃圾回收機制
有些語言(比如 C 語言)必須手動釋放內(nèi)存,程序員負責內(nèi)存管理。
char?*?buffer;
buffer?=?(char*)?malloc(42);
//?Do?something?with?buffer
free(buffer);
上面是 C 語言代碼,malloc方法用來申請內(nèi)存,使用完畢之后,必須自己用free方法釋放內(nèi)存。
這很麻煩,所以大多數(shù)語言提供自動內(nèi)存管理,減輕程序員的負擔,這被稱為"垃圾回收機制"(garbage collector)。
JavaScript 是使用垃圾回收的語言,也就是說執(zhí)行環(huán)境負責在代碼執(zhí)行時管理內(nèi)存。在C和C++等語言中,跟蹤內(nèi)存使用對開發(fā)者來說是個很大的負擔,也是很多問題的來源。JavaScript為開發(fā)者卸下了這個負擔,通過自動內(nèi)存管理實現(xiàn)內(nèi)存分配和閑置資源回收。基本思路很簡單:確定哪個變量不會再使用,然后釋放它占用的內(nèi)存。這個過程是周期性的,即垃圾回收程序每隔一定時間(或者說在代碼執(zhí)行過程中某個預定的收集時間)就會自動運行。垃圾回收過程是一個近似且不完美的方案,因為某塊內(nèi)存是否還有用,屬于“不可判定的”問題,意味著靠算法是解決不了的。
我們以函數(shù)中局部變量的正常生命周期為例。函數(shù)中的局部變量會在函數(shù)執(zhí)行時存在。此時,棧(或堆)內(nèi)存會分配空間以保存相應的值。函數(shù)在內(nèi)部使用了變量,然后退出。此時,就不再需要那個局部變量了,它占用的內(nèi)存可以釋放,供后面使用。這種情況下顯然不再需要局部變量了,但并不是所有時候都會這么明顯。垃圾回收程序必須跟蹤記錄哪個變量還會使用,以及哪個變量不會再使用,以便回收內(nèi)存。如何標記未使用的變量也許有不同的實現(xiàn)方式。不過,在瀏覽器的發(fā)展史上,用到過兩種主要的標記策略:標記清理和引用計數(shù)。
1. 標記清理
JavaScript 最常用的垃圾回收策略是標記清理(mark-and-sweep)。當變量進入上下文,比如在函數(shù)內(nèi)部聲明一個變量時,這個變量會被加上存在于上下文中的標記。而不在上下文中的變量,比如在函數(shù)外部聲明的全局變量,邏輯上講,永遠不應該釋放它們的內(nèi)存,因為只要上下文中的代碼在運行,就有可能用到它們。當變量離開上下文時,也會被加上離開上下文的標記。
給變量加標記的方式有很多種。比如,當變量進入上下文時,反轉(zhuǎn)某一位;或者可以維護“在上下文中”和“不在上下文中”兩個變量列表,可以把變量從一個列表轉(zhuǎn)移到另一個列表。標記過程的實現(xiàn)并不重要,關鍵是策略。
垃圾回收程序運行的時候,會標記內(nèi)存中存儲的所有變量(記住,標記方法有很多種)。然后,它會將所有在上下文中的變量,以及被在上下文中的變量引用的變量的標記去掉。在此之后再被加上標記的變量就是待刪除的了,原因是任何在上下文中的變量都訪問不到它們了。隨后垃圾回收程序做一次內(nèi)存清理,銷毀帶標記的所有值并收回它們的內(nèi)存。
到了2008年,IE、Firefox、Opera、Chrome 和 Safari 都在自己的 JavaScript 實現(xiàn)中采用標記清理(或其變體),只是在運行垃圾回收的頻率上有所差異。
下面在介紹引用計數(shù)時會通過代碼來更深入的了解和對比兩種策略。
2. 引用計數(shù)
另一種沒那么常用的垃圾回收策略是引用計數(shù)(referencecounting)。其思路是對每個值都記錄它被引用的次數(shù)。聲明變量并給它賦一個引用值時,這個值的引用數(shù)為1。如果同一個值又被賦給另一個變量,那么引用數(shù)加1。類似地,如果保存對該值引用的變量被其他值給覆蓋了,那么引用數(shù)減1。當一個值的引用數(shù)為0時,就說明沒辦法再訪問到這個值了,因此可以安全地收回其內(nèi)存了。垃圾回收程序下次運行的時候就會釋放引用數(shù)為0的值的內(nèi)存。
引用計數(shù)最早由Netscape Navigator 3.0采用,但很快就遇到了嚴重的問題:循環(huán)引用。所謂循環(huán)引用,就是對象A有一個指針指向?qū)ο驜,而對象B也引用了對象A。比如:
function?problem()?{?
?let?objectA?=?new?Object();?
?let?objectB?=?new?Object();?
?
?objectA.someOtherObject?=?objectB;?
?objectB.anotherObject?=?objectA;?
}
在這個例子中, objectA 和 objectB 通過各自的屬性相互引用,意味著它們的引用數(shù)都是2。在標記清理策略下,這不是問題,因為在函數(shù)結束后,這兩個對象都不在作用域中。而在引用計數(shù)策略下, objectA 和 objectB 在函數(shù)結束后還會存在,因為它們的引用數(shù)永遠不會變成0。如果函數(shù)被多次調(diào)用,則會導致大量內(nèi)存永遠不會被釋放。為此,Netscape在4.0版放棄了引用計數(shù),轉(zhuǎn)而采用標記清理。事實上,引用計數(shù)策略的問題還不止于此。
在IE8及更早版本的IE中,并非所有對象都是原生JavaScript對象。BOM和DOM中的對象是C++實現(xiàn)的組件對象模型(COM,Component Object Model)對象,而COM對象使用引用計數(shù)實現(xiàn)垃圾回收。因此,即使這些版本IE的JavaScript引擎使用標記清理,JavaScript存取的COM對象依舊使用引用計數(shù)。換句話說,只要涉及COM對象,就無法避開循環(huán)引用問題。下面這個簡單的例子展示了涉及COM對象的循環(huán)引用問題:
let?element?=?document.getElementById("some_element");?
let?myObject?=?new?Object();
myObject.element?=?element;?
element.someObject?=?myObject;
這個例子在一個DOM對象( element )和一個原生JavaScript對象( myObject )之間制造了循環(huán)引用。myObject 變量有一個名為 element 的屬性指向DOM對象 element ,而 element 對象有一個 someObject 屬性指回 myObject 對象。由于存在循環(huán)引用,因此DOM元素的內(nèi)存永遠不會被回收,即使它已經(jīng)被從頁面上刪除了 也是如此。
為避免類似的循環(huán)引用問題,應該在確保不使用的情況下切斷原生JavaScript對象與DOM元素之間的連接。比如,通過以下代碼可以清除前面的例子中建立的循環(huán)引用:
myObject.element?=?null;?
element.someObject?=?null;
把變量設置為 null 實際上會切斷變量與其之前引用值之間的關系。當下次垃圾回收程序運行時,這些值就會被刪除,內(nèi)存也會被回收。
為了補救這一點,IE9把BOM和DOM對象都改成了JavaScript對象,這同時也避免了由于存在兩套垃圾回收算法而導致的問題,還消除了常見的內(nèi)存泄漏現(xiàn)象。
回過頭可以發(fā)現(xiàn),使用標記清理策略的話,循環(huán)引用就不再是問題了。
在上面的示例中,函數(shù)調(diào)用返回之后,兩個對象從全局對象出發(fā)無法獲取。因此,他們將會被垃圾回收器回收。第二個示例同樣,一旦 div 和其事件處理無法從根獲取到,他們將會被垃圾回收器回收。
四、內(nèi)存管理方案
JavaScript變量可以保存兩種類型的值:原始值和引用值。原始值是 6 種原始數(shù)據(jù)類型:Undefined 、 Null 、Boolean 、 Number 、 String 和 Symbol 。
原始值和引用值有以下特點:
原始值大小固定,因此保存在棧內(nèi)存上。 從一個變量到另一個變量復制原始值會創(chuàng)建該值的第二個副本。 引用值是對象,存儲在堆內(nèi)存上。 包含引用值的變量實際上只包含指向相應對象的一個指針,而不是對象本身。 從一個變量到另一個變量復制引用值只會復制指針,因此結果是兩個變量都指向同一個對象。 typeof 操作符可以確定值的原始類型,而 instanceof 操作符用于確保值的引用類型。
任何變量(不管包含的是原始值還是引用值)都存在于某個執(zhí)行上下文中(也稱為作用域)。這個上下文(作用域)決定了變量的生命周期,以及它們可以訪問代碼的哪些部分。
執(zhí)行上下文可以總結如下:
執(zhí)行上下文分全局上下文、函數(shù)上下文和塊級上下文。 代碼執(zhí)行流每進入一個新上下文,都會創(chuàng)建一個作用域鏈,用于搜索變量和函數(shù)。 函數(shù)或塊的局部上下文不僅可以訪問自己作用域內(nèi)的變量,而且也可以訪問任何包含上下文乃至全局上下文中的變量。 全局上下文只能訪問全局上下文中的變量和函數(shù),不能直接訪問局部上下文中的任何數(shù)據(jù)。 變量的執(zhí)行上下文用于確定什么時候釋放內(nèi)存。
JavaScript是使用垃圾回收的編程語言,開發(fā)者不需要操心內(nèi)存分配和回收。
JavaScript的垃圾回收程序可以總結如下:
離開作用域的值會被自動標記為可回收,然后在垃圾回收期間被刪除。 主流的垃圾回收算法是標記清理,即先給當前不使用的值加上標記,再回來回收它們的內(nèi)存。 引用計數(shù)是另一種垃圾回收策略,需要記錄值被引用了多少次。 JavaScript 引擎不再使用這種算法,但某些舊版本的IE仍然會受這種算法的影響,原因是JavaScript會訪問非原生JavaScript對象(如DOM元素)。 引用計數(shù)在代碼中存在循環(huán)引用時會出現(xiàn)問題。 解除變量的引用不僅可以消除循環(huán)引用,而且對垃圾回收也有幫助。為促進內(nèi)存回收,全局對象、全局對象的屬性和循環(huán)引用都應該在不需要時解除引用。
在使用垃圾回收的編程環(huán)境中,開發(fā)者通常無須關心內(nèi)存管理。不過,JavaScript運行在一個內(nèi)存管理與垃圾回收都很特殊的環(huán)境。分配給瀏覽器的內(nèi)存通常比分配給桌面軟件的要少很多,分配給移動瀏覽器的就更少了。這更多出于安全考慮而不是別的,就是為了避免運行大量JavaScript的網(wǎng)頁耗盡系統(tǒng)內(nèi)存而導致操作系統(tǒng)崩潰。這個內(nèi)存限制不僅影響變量分配,也影響調(diào)用棧以及能夠同時在一個線程中執(zhí)行的語句數(shù)量。
● 解除引用
將內(nèi)存占用量保持在一個較小的值可以讓頁面性能更好。優(yōu)化內(nèi)存占用的最佳手段就是保證在執(zhí)行代碼時只保存必要的數(shù)據(jù)。如果數(shù)據(jù)不再必要,那么把它設置為 null ,從而釋放其引用。這也可以叫作解除引用。這個建議最適合全局變量和全局對象的屬性。局部變量在超出作用域后會被自動解除引用,如下面的例子所示:
function?createPerson(name)?{
????let?localPerson?=?new?Object();
????localPerson.name?=?name;
????return?localPerson;
}
let?globalPerson?=?createPerson("Nicholas");?//?解除globalPerson對值的引用
globalPerson?=?null;
在上面的代碼中,變量 globalPerson 保存著createPerson() 函數(shù)調(diào)用返回的值。在 createPerson() 內(nèi)部, localPerson 創(chuàng)建了一個對象并給它添加了一個 name 屬性。然后, localPerson 作為函數(shù)值被返回,并被賦值給globalPerson 。localPerson 在 createPerson() 執(zhí)行完成超出上下文后會自動被解除引用,不需要顯式處理。但globalPerson 是一個全局變量,應該在不再需要時手動解除其引用,最后一行就是這么做的。
不過要注意,解除對一個值的引用并不會自動導致相關內(nèi)存被回收。解除引用的關鍵在于確保相關的值已經(jīng)不在上下文里了,因此它在下次垃圾回收時會被回收。
● 通過 const 和 let 聲明提升性能
ES6增加這兩個關鍵字不僅有助于改善代碼風格,而且同樣有助于改進垃圾回收的過程。因為 const 和 let 都以塊(而非函數(shù))為作用域,所以相比于使用 var ,使用這兩個新關鍵字可能會更早地讓垃圾回收程序介入,盡早回收應該回收的內(nèi)存。在塊作用域比函數(shù)作用域更早終止的情況下,這就有可能發(fā)生。
參考
JavaScript高級程序設計(第4版) 內(nèi)存管理 JavaScript 內(nèi)存泄漏教程
