JavaScript 內(nèi)存泄露
今天下午同事讓幫忙看web內(nèi)存泄露問題。當(dāng)時(shí)定位到創(chuàng)建ActiveX 對(duì)象的時(shí)候產(chǎn)生的,于是我對(duì)這個(gè)奇怪的問題進(jìn)行了一些深入探索。
很多時(shí)候我都依賴javascript的垃圾回收機(jī)制,所以對(duì)C 以及C++ 操作內(nèi)存語言常發(fā)生的內(nèi)存泄露是很陌生的。當(dāng)時(shí)創(chuàng)建回調(diào)函數(shù)用了閉包,當(dāng)然最終的解決方法是也避免閉包調(diào)用。
隨著這個(gè)問題的浮出水面,我回憶起以前的一個(gè)項(xiàng)目中也應(yīng)該存在這個(gè)內(nèi)存泄露問題。于是查閱了相關(guān)資料把類似的問題總結(jié)下來,希望對(duì)大家也有幫助。
原因:對(duì)于一門具有垃圾收回機(jī)制的語言存在內(nèi)存泄露,其原因不外乎就是javascript腳本引擎存在bug。
很多時(shí)候,我們要做的不是去修正那樣的bug,而是想辦法去規(guī)避。
目前發(fā)現(xiàn)的可能導(dǎo)致內(nèi)存泄露的代碼有三種:
· 循環(huán)引用
· 自動(dòng)類型裝箱轉(zhuǎn)換
· 某些DOM操作
下面具體的來說說內(nèi)存是如何泄露的
循環(huán)引用:這種方式存在于IE6和FF2中(FF3未做測(cè)試),當(dāng)出現(xiàn)了一個(gè)含有DOM對(duì)象的循環(huán)引用時(shí),就會(huì)發(fā)生內(nèi)存泄露。
什么是循環(huán)引用?首先搞清楚什么是引用,一個(gè)對(duì)象A的屬性被賦值為另一個(gè)對(duì)象B時(shí),則可以稱A引用了B。假如B也引用了A,那么A和B之間構(gòu)成了循環(huán)引用。同樣道理 如果能找到A引用B B引用C C又引用A這樣一組飲用關(guān)系,那么這三個(gè)對(duì)象構(gòu)成了循環(huán)引用。當(dāng)一個(gè)對(duì)象引用自己時(shí),它自己形成了循環(huán)引用。注意,在js中變量永遠(yuǎn)是對(duì)象的屬性,它可以指向?qū)ο螅珱Q不是對(duì)象本身。
循環(huán)引用很常見,而且通常是無害的,但如果循環(huán)引用中包含DOM對(duì)象或者ActiveX對(duì)象,那么就會(huì)發(fā)生內(nèi)存泄露。例子:
var a=document.createElement("div");
var b=new Object();
a.b=b;
b.a=a;
很多情況下循環(huán)引用不是這樣的明顯,下面就是著名的閉包(closure)造成內(nèi)存泄露的例子,每執(zhí)行一次函數(shù)A()都會(huì)產(chǎn)生內(nèi)存泄露。試試看,根據(jù)前面講的scope對(duì)象的知識(shí),能不能找出循環(huán)引用?
function A()...{
var a=document.createElement("div");
a.onclick=function()...{
alert("hi");
}
}
A();
OK, 讓我們來看看。假設(shè)A()執(zhí)行時(shí)創(chuàng)建的作用域?qū)ο蠼凶?/font>ScopeA 找到以下引用關(guān)系
ScopeA引用DOM對(duì)象document.createElement("div");
DOM對(duì)象document.createElement("div");引用函數(shù)function(){alert("hi")}
函數(shù)function(){alert("hi")}引用ScopeA
這樣就很清楚了,所謂closure泄露,只不過是幾個(gè)js特殊對(duì)象的循環(huán)引用而已。
自動(dòng)類型裝箱轉(zhuǎn)換:這種泄露存在于ie6 ie7中。這是極其匪夷所思的一個(gè)bug,看下面代碼
var s="lalalalala";
alert(s.length);
這段代碼怎么了?看看吧,"lalalalala"已經(jīng)泄露了。關(guān)鍵問題出在s.length上,我們知道js的類型中,string并非對(duì)象,但可以對(duì)它使用.運(yùn)算符,為什么呢?因?yàn)?/font>js的默認(rèn)類型轉(zhuǎn)換機(jī)制,允許js在遇到.運(yùn)算符時(shí)自動(dòng)將string轉(zhuǎn)換為object型中對(duì)應(yīng)的String對(duì)象。而這個(gè)轉(zhuǎn)換成的臨時(shí)對(duì)象100%會(huì)泄露(汗一下)。
某些DOM操作也可能導(dǎo)致泄露 這些惡心的bug只存在于ie系列中。在ie7中 因?yàn)樵噲Dfix循環(huán)引用bug而讓情況變得更糟,以至于我對(duì)寫這一段種滿了恐懼。
從ie6談起,下面是微軟的例子,
<html>
<head>
<script language="JScript">...
function LeakMemory()
...{
var hostElement = document.getElementById("hostElement");
// Do it a lot, look at Task Manager for memory response
for(i = 0; i < 5000; i++)
...{
var parentDiv =
document.createElement("<div onClick='foo()'>");
var childDiv =
document.createElement("<div onClick='foo()'>");
// This will leak a temporary object
parentDiv.appendChild(childDiv);
hostElement.appendChild(parentDiv);
hostElement.removeChild(parentDiv);
parentDiv.removeChild(childDiv);
parentDiv = null;
childDiv = null;
}
hostElement = null;
}
function CleanMemory()
...{
var hostElement = document.getElementById("hostElement");
// Do it a lot, look at Task Manager for memory response
for(i = 0; i < 5000; i++)
...{
var parentDiv =
document.createElement("<div onClick='foo()'>");
var childDiv =
document.createElement("<div onClick='foo()'>");
// Changing the order is important, this won't leak
hostElement.appendChild(parentDiv);
parentDiv.appendChild(childDiv);
hostElement.removeChild(parentDiv);
parentDiv.removeChild(childDiv);
parentDiv = null;
childDiv = null;
}
hostElement = null;
}
</script>
</head>
<body>
<button onclick="LeakMemory()">Memory Leaking Insert</button>
<button onclick="CleanMemory()">Clean Insert</button>
<div id="hostElement"></div>
</body>
</html>
看看結(jié)果吧,LeakMemory造成了內(nèi)存泄露,而CleanMemory沒有,循環(huán)引用了么?仔細(xì)看看沒有。那么是什么問題呢?MS的解釋是"插入順序不對(duì)",必須先將父級(jí)元素appendChild。這聽起來有些模糊,這里給出一個(gè)比較恰當(dāng)?shù)牡葍r(jià)描述:永遠(yuǎn)不要使用DOM節(jié)點(diǎn)樹之外元素的appendChild方法。
我曾經(jīng)看到過這樣的說法,創(chuàng)建dom的時(shí)候,先創(chuàng)建子節(jié)點(diǎn),當(dāng)子節(jié)點(diǎn)完善后一次性添加到頁面中,不要一點(diǎn)點(diǎn)朝頁面上加?xùn)|西,盡量減少document刷新次數(shù),這樣效率會(huì)高點(diǎn)。(打個(gè)比方就是應(yīng)該像 LeakMemory )可見這里我還是被某些書籍誤導(dǎo)了。至少他沒有告訴我內(nèi)存泄露的問題。
接下來是ie7和ie8 beta 1中運(yùn)行這段程序,看到什么?沒看錯(cuò)吧,2個(gè)都泄露了!別急,刷新一下頁面就好了。為什么呢?ie7改變了DOM元素的回收方式:在離開頁面時(shí)回收DOM樹上的所有元素,所以ie7下的內(nèi)存管理非常簡(jiǎn)單:在所有的頁面中只要掛在DOM樹上的元素,就不會(huì)泄露,沒掛在DOM樹上,肯定泄露。所以,ie7中記住一條原則:在離開頁面之前把所有創(chuàng)建的DOM元素掛到DOM樹上。
接下來談?wù)?font face="Arial">ie7的這個(gè)設(shè)計(jì)吧,坦白的說,這種做法純粹是偷懶的垃圾做法。動(dòng)態(tài)垃圾回收不是保證所有內(nèi)存都在離開頁面時(shí)收回,而是要保證內(nèi)存的充分利用,運(yùn)行時(shí)不回收,等到離開時(shí)回收有什么用?這只是名義上的避免泄露,其實(shí)是完全的泄露。況且還沒有回收DOM節(jié)點(diǎn)樹之外的元素。
4.內(nèi)存泄露的解決方案
內(nèi)存泄露怎么辦?真的以后不用閉包了么?沒法封裝控件了?這樣做還不如要了js程序員的命,嘿嘿。
事實(shí)上,通過一些很簡(jiǎn)單的小技巧,可以巧妙的繞開這些危險(xiǎn)的bug。
to be continued......
coming soon:
· 顯式類型轉(zhuǎn)換
· 避免事件導(dǎo)致的循環(huán)引用
· 不影響返回值地打破循環(huán)引用
· 延遲appendChild
· 代理DOM對(duì)象
· 顯式類型轉(zhuǎn)換
首先說說最容易處理的情況 對(duì)于類型轉(zhuǎn)換造成的錯(cuò)誤,我們可以通過顯式類型轉(zhuǎn)換來避免:
var s=newString("lalalalala");//此處將string轉(zhuǎn)換成object
alert(s.length);
這個(gè)太容易了,算不上正經(jīng)方案。不過類型轉(zhuǎn)換泄露也就這一種處理方法了。
· 避免事件導(dǎo)致的循環(huán)引用
在比較成熟的js程序員里,把事件函數(shù)寫成閉包是再正常不過了:
function A(){
var a=document.createElement("div");
a.onclick=function(){
alert("hi");
}
}
這將導(dǎo)致內(nèi)存泄露。按照IBM那兩位老大的說法,當(dāng)然是把函數(shù)放外面或者a=null就沒問題了,不過還要訪問A()里面的變量呢?假如有下面的代碼:
function A(){
var a=document.createElement("div");
var b=document.createElement("div");
a.onclick=function(){
alert(b.outerHTML);
}
return a;
}
如何將它的邏輯表達(dá)出來 還避免內(nèi)存泄露? 分析一下這個(gè)內(nèi)存泄露的形式:只要onclick的外部環(huán)境中不包含a那么,就不會(huì)泄露。那么辦法有2個(gè)一是將環(huán)境到a的引用斷開 另一個(gè)是將function到環(huán)境的引用斷開,但是,如果要在函數(shù)中訪問b就不能將Function放到外面,如果要返回a的值,就不能a=null,怎么辦呢?
解決方案1:
構(gòu)造一個(gè)不含a的新環(huán)境
function A(){
var a=document.createElement("div");
var b=document.createElement("div");
a.onclick=BuildEvent(b);
return a;
}
function BuildEvent(b)
{
return function(){
alert(b.outerHTML);
}
}
a本身可以通過this訪問,將其它需要訪問的外層函數(shù)變量傳遞給BuildEvent就可以了。保持BuildEvent定義和調(diào)用的參數(shù)名一致,會(huì)帶來方便。
解決方案2:
在return 之后a=null,不可能? 看看下面:
function A(){
try{
var a=document.createElement("div");
var b=document.createElement("div");
a.onclick= function(){
alert(b.outerHTML);
}
return a;
} finally {
a=null;
}
}
finally在try之后執(zhí)行,如果finall塊不返回值,才會(huì)返回try塊的返回值。
· 延遲appendChild
還記得函數(shù)的lazy initalize吧,對(duì)于ie惡心至極的DOM操作泄露,我們需要用類似的方法去處理。在一個(gè)函數(shù)中構(gòu)造一個(gè)復(fù)雜對(duì)象,在需要的時(shí)候?qū)⒅?/font>appendChild到DOM樹上,這是很常見的做法,但在IE6中,這樣做將導(dǎo)致所謂的"插入順序內(nèi)存泄露",沒有別的辦法,我們只能用一個(gè)數(shù)組parts保存子節(jié)點(diǎn),編寫一個(gè)appendTo方法先序遍歷節(jié)點(diǎn)樹,去把它掛在某個(gè)DOM節(jié)點(diǎn)上。
function appendTo(Element)
...{
Element.appendChild(this);
if(!this.parts)return;
for(var i=0;i<this.parts.length;i++)
parts.appendTo(this);
}
· 垃圾箱
對(duì)于ie7,我比較無可奈何,因?yàn)?/font>DOM對(duì)象不會(huì)被CG程序回收,只有離開頁面時(shí)會(huì)被回收,所以我的建議是:使用DOM要有節(jié)制,盡量多用innerHTML吧...... good luck.
一旦你使用了DOM對(duì)象,千萬不要試圖o=null,你可以設(shè)置一個(gè)叫做Garbage的div并且將其display設(shè)置為none,將不用的DOM對(duì)象存入其中(就是appendChild上去)就好了
· 代理對(duì)象
這是Ext的做法,這里只是順帶提一下。將每個(gè)元素用一個(gè)"代理對(duì)象"操作,不論appendChild還是其他操作都不是對(duì)DOM對(duì)象本身的操作,而是通過這個(gè)代理對(duì)象操作。這是一個(gè)很不錯(cuò)的Proxy模式,不過要想避免泄露還是需要一點(diǎn)功夫的,并非用了Proxy之后就不會(huì)泄露,有時(shí)反而更容易泄露。
5 .FAQ
1 內(nèi)存泄露是內(nèi)存占用很大么? 不是,即使1byte內(nèi)存也叫做內(nèi)存泄露。
2 程序中提示,內(nèi)存不足,是內(nèi)存泄露么?不是,這一般是無限遞歸函數(shù)調(diào)用導(dǎo)致棧內(nèi)存溢出。
3 內(nèi)存泄露是哪個(gè)區(qū)域泄露?堆區(qū),棧區(qū)是不會(huì)泄露的。
4 window對(duì)象是DOM對(duì)象么?不是,window對(duì)象參與的循環(huán)引用不會(huì)內(nèi)存泄露。
5 內(nèi)存泄露后果是什么?大多數(shù)時(shí)候后果不很嚴(yán)重,但過多DOM操作會(huì)導(dǎo)致網(wǎng)頁執(zhí)行變慢。
6 跳轉(zhuǎn)頁面后,內(nèi)存泄露仍然存在么?仍然存在,直到關(guān)閉瀏覽器。
7 FireFox也會(huì)內(nèi)存泄露么?FF2仍然有內(nèi)存泄露