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