在過去一些的時候,Web開發人員并沒有太多的去關注記憶體洩露問題。那時的頁面間聯系大都比較簡單,并主要使用不同的連接配接位址在同一
個站點中導航,這樣的設計方式是非常有利于浏覽器釋放資源的。即使Web頁面運作中真的出現了資源洩漏,那它的影響也是非常有限而且常常
是不會被人在意的。
今天人們對Web應用有了高更的要求。一個頁面很可能數小時不會發生URL跳轉,并同時通過Web服務動态的更新頁面内容。複雜的事件關聯
設計、基于對象的JScript和DHTML技術的廣泛采用,使得代碼的能力達到了其承受的極限。在這樣的情況和改變下,弄清楚記憶體洩露方式變得
非常的急迫,特别是過去這些問題都被傳統的頁面導航方法給屏蔽了。
還算好的事情是,當你明确了希望尋找什麼時,記憶體洩露方式是比較容易被确定的。大多數你能遇到的洩露問題我們都已經知道,你隻需
要少量額外的工作就會給你帶來好處。雖然在一些頁面中少量的小洩漏問題仍會發生,但是主要的問題還是很容易解決的。
洩露方式
在接下來的内容中,我們會讨論記憶體洩露方式,并為每種方式給出示例。其中一個重要的示例是JScript中的Closure技術,另一個示例是
在事件執行中使用Closures。當你熟悉本示例後,你就能找出并修改你已有的大多數記憶體洩漏問題,但是其它Closure相關的問題可能又會被忽
視。
現在讓我們來看看這些個方式都有什麼:
1、循環引用(Circular References) — IE浏覽器的COM元件産生的對象執行個體和網頁腳本引擎産生的對象執行個體互相引用,就會造成記憶體洩漏。
這也是Web頁面中我們遇到的最常見和主要的洩漏方式;
2、内部函數引用(Closures) — Closures可以看成是目前引起大量問題的循環應用的一種特殊形式。由于依賴指定的關鍵字和文法結構,
Closures調用是比較容易被我們發現的;
3、頁面交叉洩漏(Cross-Page Leaks) — 頁面交叉洩漏其實是一種較小的洩漏,它通常在你浏覽過程中,由于内部對象薄計引起。下面我們
會讨論DOM插入順序的問題,在那個示例中你會發現隻需要改動少量的代碼,我們就可以避免對象薄計對對象建構帶來的影響;
4、貌似洩漏(Pseudo-Leaks) — 這個不是真正的意義上的洩漏,不過如果你不了解它,你可能會在你的可用記憶體資源變得越來越少的時候極
度郁悶。為了示範這個問題,我們将通過重寫Script元素中的内容來引發大量記憶體的"洩漏"。
循環引用
循環引用基本上是所有洩漏的始作俑者。通常情況下,腳本引擎通過垃圾收集器(GC)來處理循環引用,但是某些未知因數可能會妨礙從其
環境中釋放資源。對于IE來說,某些DOM對象執行個體的狀态是腳本無法得知的。下面是它們的基本原則:

Figure 1: 基本的循環引用模型
本模型中引起的洩漏問題基于COM的引用計數。腳本引擎對象會維持對DOM對象的引用,并在清理和釋放DOM對象指針前等待所有引用的移除
。在我們的示例中,我們的腳本引擎對象上有兩個引用:腳本引擎作用域和DOM對象的expando屬性。當終止腳本引擎時第一個引用會釋放,DOM
對象引用由于在等待腳本擎的釋放而并不會被釋放。你可能會認為檢測并修複假設的這類問題會非常的容易,但事實上這樣基本的的示例隻是
冰山一角。你可能會在30個對象鍊的末尾發生循環引用,這樣的問題排查起來将會是一場噩夢。
如果你仍不清楚這種洩漏方式在HTML代碼裡到底怎樣,你可以通過一個全局腳本變量和一個DOM對象來引發并展現它。
<html>
<head>
<script language="JScript">
var myGlobalObject;
function SetupLeak()
{
// First set up the script scope to element reference
myGlobalObject = document.getElementById("LeakedDiv");
// Next set up the element to script scope reference
document.getElementById("LeakedDiv").expandoProperty = myGlobalObject;
}
function BreakLeak()
document.getElementById("LeakedDiv").expandoProperty = null;
</script>
</head>
<body onunload="BreakLeak()">
<div id="LeakedDiv"></div>
</body>
</html>
提示:您可以先修改部分代碼再運作
你可以使用直接賦null值得方式來破壞該洩漏情形。在頁面文檔解除安裝前賦null值,将會讓腳本引擎知道對象間的引用鍊沒
有了。現在它将能正常的清理引用并釋放DOM對象。在這個示例中,作為Web開發員的你因該更多的了解了對象間的關系。
作為一個基本的情形,循環引用可能還有更多不同的複雜表現。對基于對象的JScript,一個通常用法是通過封裝JScript對象來擴充DOM對
象。在建構過程中,你常常會把DOM對象的引用放入JScript對象中,同時在DOM對象中也存放上對新近建立的JScript對象的引用。你的這種應
用模式将非常便于兩個對象之間的互相通路。這是一個非常直接的循環引用問題,但是由于使用不用的文法形式可能并不會讓你在意。要破環
這種使用情景可能變得更加複雜,當然你同樣可以使用簡單的示例以便于清楚的讨論。
function Encapsulator(element)
// Set up our element
this.elementReference = element;
// Make our circular reference
element.expandoProperty = this;
// The leak happens all at once
new Encapsulator(document.getElementById("LeakedDiv"));
更複雜的辦法還有記錄所有需要解除引用的對象和屬性,然後在Web文檔解除安裝的時候統一清理,但大多數時候你可能會再造
成額外的洩漏情形,而并沒有解決你的問題。
閉包函數(Closures)由于閉包函數會使程式員在不知不覺中建立出循環引用,是以它對資源洩漏常常有着不可推卸的責任。而在閉包函數自己被釋放前,我們很難判斷父函數的參數以及它的局部變量是否能被釋放。實際上閉包函數的使用已經很普通,以緻人們頻繁的遇到這類問題時我們卻束手無策。在詳細了解了閉包背後的問題和一些特殊的閉包洩漏示例後,我們将結合循環引用的圖示找到閉包的所在,并找出這些不受歡迎的引用來至何處。
普通的循環引用,是兩個不可探知的對象互相引用造成的,但是閉包卻不同。代替直接造成引用,閉包函數則取而代之從其父函數作用域中引入資訊。通常,函數的局部變量和參數隻能在該被調函數自身的生命周期裡使用。當存在閉包函數後,這些變量和參數的引用會和閉包函數一起存在,但由于閉包函數可以超越其父函數的生命周期而存在,是以父函數中的局部變量和參數也仍然能被通路。在下面的示例中,參數1将在函數調用終止時正常被釋放。當我們加入了一個閉包函數後,一個額外的引用産生,并且這個引用在閉包函數釋放前都不會被釋放。如果你碰巧将閉包函數放入了事件之中,那麼你不得不手動從那個事件中将其移出。如果你把閉包函數作為了一個expando屬性,那麼你也需要通過置null将其清除。
同時閉包會在每次調用中建立,也就是說當你調用包含閉包的函數兩次,你将得到兩個獨立的閉包,而且每個閉包都分别擁有對參數的引用。由于這些顯而易見的因素,閉包确實非常用以帶來洩漏。下面的示例将展示使用閉包的主要洩漏因素:
function AttachEvents(element)
// This structure causes element to ref ClickEventHandler
element.attachEvent(" ClickEventHandler);
function ClickEventHandler()
{
// This closure refs element
}
AttachEvents(document.getElementById("LeakedDiv"));
如果你對怎麼避免這類洩漏感到疑惑,我将告訴你處理它并不像處理普通循環引用那麼簡單。"閉包"被看作函數作用域中的一個臨時對象。一旦函數執行退出,你将失去對閉包本身的引用,那麼你将怎樣去調用detachEvent方法來清除引用呢?在Scott Isaacs的MSN Spaces上有一種解決這個問題的有趣方法。這個方法使用一個額外的引用(原文叫second closure,可是這個示例裡緻始緻終隻有一個closure)協助window對象執行onUnload事件,由于這個額外的引用和閉包的引用存在于同一個對象域中,于是我們可以借助它來釋放事件引用,進而完成引用移除。為了簡單起見我們将閉包的引用暫存在一個expando屬性中,下面的示例将向你示範釋放事件引用和清除expando屬性。
// In order to remove this we need to put
// it somewhere. Creates another ref
element.expandoClick = ClickEventHandler;
element.attachEvent(" element.expandoClick);
document.getElementById("LeakedDiv").detachEvent(" document.getElementById("LeakedDiv").expandoClick);
document.getElementById("LeakedDiv").expandoClick = null;
在這篇KB文章中,實際上建議我們除非迫不得已盡量不要建立使用閉包。文章中的示例,給我們示範了非閉包的事件引用方式,即把閉包函數放到頁面的全局作用域中。當閉包函數成為普通函數後,它将不再繼承其父函數的參數和局部變量,是以我們也就不用擔心基于閉包的循環引用了。在非必要的時候不使用閉包這樣的程式設計方式可以盡量使我們的代碼避免這樣的問題。
最後,腳本引擎開發組的Eric Lippert,給我們帶來了一篇關于閉包使用通俗易懂的好文章。他的最終建議也是希望在真正必要的時候才使用閉包函數。雖然他的文章沒有提及閉包會使用的真正場景,但是這兒已有的大量示例非常有助于大家起步。
頁面交叉洩漏(Cross-Page Leaks)這種基于插入順序而常常引起的洩漏問題,主要是由于對象建立過程中的臨時對象未能被及時清理和釋放造成的。它一般在動态建立頁面元素,并将其添加到頁面DOM中時發生。一個最簡單的示例場景是我們動态建立兩個對象,并建立一個子元素和父元素間的臨時域(譯者注:這裡的域(Scope)應該是指管理元素之間層次結構關系的對象)。然後,當你将這兩個父子結構元素構成的的樹添加到頁面DOM樹中時,這兩個元素将會繼承頁面DOM中的層次管理域對象,并洩漏之前建立的那個臨時域對象。下面的圖示示例了兩種動态建立并添加元素到頁面DOM中的方法。在第一種方法中,我們将每個子元素添加到它的直接父元素中,最後再将建立好的整棵子樹添加到頁面DOM中。當一些相關條件合适時,這種方法将會由于臨時對象問題引起洩漏。在第二種方法中,我們自頂向下建立動态元素,并使它們被建立後立即加入到頁面DOM結構中去。由于每個被加入的元素繼承了頁面DOM中的結構域對象,我們不需要建立任何的臨時域。這是避免潛在記憶體洩漏發生的好方法。
接下來,我們将給出一個躲避了大多數洩漏檢測算法的洩漏示例。因為我們實際上沒有洩漏任何可見的元素,并且由于被洩漏的對象太小進而你可能根本不會注意這個問題。為了使我們的示例産生洩漏,在動态建立的元素結構中将不得不内聯的包含一個腳本函數指針。在我們設定好這些元素間的互相隸屬關系後這将會使我們洩漏内部臨時腳本對象。由于這個洩漏很小,我們不得不将示例執行成千上萬次。事實上,一個對象的洩漏隻有很少的位元組。在運作示例并将浏覽器導航到一個空白頁面,你将會看到兩個版本代碼在記憶體使用上的差別。當我們使用第一種方法,将子元素加入其父元素再将構成的子樹加入頁面DOM,我們的記憶體使用量會有微小的上升。這就是一個交叉導航洩漏,隻有當我們重新啟動IE程序這些洩漏的記憶體才會被釋放。如果你使用第二種方法将父元素加入頁面DOM再将子元素加入其父元素中,同樣運作若幹次後,你的記憶體使用量将不會再上升,這時你會發現你已經修複了交叉導航洩漏的問題。
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 =
// 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()
// Changing the order is important, this won't leak
<body>
<button Leaking Insert</button>
<button Insert</button>
<div id="hostElement"></div>
這類洩漏應該被澄清,因為這個解決方法有悖于我們在IE中的一些有益經驗。建立帶有腳本對象的DOM元素,以及它們已進行的互相關聯是了解這個洩漏的關鍵點。這實際上這對于洩漏來說是至關重要的,因為如果我們建立的DOM元素不包含任何的腳本對象,同時使用相同的方式将它們進行關聯,我們是不會有任何洩漏問題的。示例中給出的第二種技巧對于關聯大的子樹結構可能更有效(由于在那個示例中我們一共隻有兩個元素,是以建立一個和頁面DOM不相關的樹結構并不會有什麼效率問題)。第二個技巧是在建立元素的開始不關聯任何的腳本對象,是以你可以安全的建立子樹。當你把你的子樹關聯到頁面DOM上後,再繼續處理你需要的腳本事件。牢記并遵守關于循環引用和閉包函數的使用規則,你不會再在挂接事件時在你的代碼中遇到不同的洩漏。
我真的要指出這個問題,因為我們可以看出不是所有的記憶體洩漏都是可以很容易發現的。它們可能都是些微不足道的問題,但往往需要成千上萬次的執行一個更小的洩漏場景才能使問題顯現出來,就像DOM元素插入順序引起的問題那樣。如果你覺得使用所謂的"最佳"經驗來程式設計,那麼你就可以高枕無憂,但是這個示例讓我們看到,即使是"最佳"經驗似乎也可能帶來洩漏。我們這裡的解決方案希望能提高這些已有的好經驗,或者介紹一些新經驗使我們避免洩漏發生的可能。
貌似洩漏(Pseudo-Leaks)在大多數時候,一些APIs的實際的行為和它們預期的行為可能會導緻你錯誤的判斷記憶體洩漏。貌似洩漏大多數時候總是出現在同一個頁面的動态腳本操作中,而在從一個頁面跳轉到空白頁面的時候發生是非常少見的。那你怎麼能象排除頁面間洩漏那樣來排除這個問題,并且在新任務運作中的記憶體使用量是否是你所期望的。我們将使用腳本文本的重寫來作為一個貌似洩漏的示例。
象DOM插入順序問題那樣,這個問題也需要依賴建立臨時對象來産生"洩漏"。對一個腳本元素對象内部的腳本文本一而再再而三的反複重寫,慢慢地你将開始洩漏各種已關聯到被覆寫内容中的腳本引擎對象。特别地,和腳本調試有關的對象被作為完全的代碼對象形式保留了下來。
hostElement.text = "function foo() { }";
<script id="hostElement">function foo() { }</script>
如果你運作上面的示例代碼并使用任務管理器檢視,當從"洩漏"頁面跳轉到空白頁面時,你并不會注意到任何腳本洩漏。因為這種腳本洩漏完全發生在頁面内部,而且當你離開該頁面時被使用的記憶體就會回收。對于我們原本所期望的行為來說這樣的情況是糟糕的。你希望當重寫了腳本内容後,原來的腳本對象就應該徹底的從頁面中消失。但事實上,由于被覆寫的腳本對象可能已用作事件處理函數,并且還可能有一些未被清除的引用計數。正如你所看到的,這就是貌似洩漏。在表面上記憶體消耗量可能看起來非常的糟糕,但是這個原因是完全可以接受的。
總結每一位Web開發員可能都整理有一份自己的代碼示例清單,當他們在代碼中看到如清單中的代碼時,他們會意識到洩漏的存在并會使用一些開發技巧來避免這些問題。這樣的方法雖然簡單便捷,但這也是今天Web頁面記憶體洩漏普遍存在的原因。考慮我們所讨論的洩漏情景而不是關注獨立的代碼示例,你将會使用更加有效的政策來解決洩漏問題。這樣的觀念将使你在設計階段就把問題估計到,并且確定你有計劃來處理潛在的洩漏問題。使用編寫加強代碼(譯者注:就是異常處理或清理對象等的代碼)的習慣并且采取清理所有自己占用記憶體的方法。雖然對這個問題來說可能太誇張了,你也可能幾乎從沒有見到編寫腳本卻需要自己清理自己占用的記憶體的情況;使這個問題變得越來越顯著的是,腳本變量和expando屬性間存在的潛在洩漏可能。
如果對模式和設計感興趣,我強烈推薦Scott的這篇blog,因為其中示範了一個通用的移除基于閉包洩漏的示例代碼。當然這需要我們使用更多的代碼,但是這個實踐是有效的,并且改進的場景非常容易在代碼中定位并進行調試。類似的注入設計也可以用在基于expando屬性引起的循環引用中,不過需要注意所注冊的方法自身不要讓洩漏(特别使用閉包的地方)跑掉。
About the author
Justin Rogers recently joined the Internet Explorer team as an Object Model developer working on extensibility and previously worked on such notable projects as the .NET QuickStart Tutorials, .NET Terrarium, and SQL Reporting Services Management Studio in SQL Server 2005.