Web開發的發展
在過去一些的時候,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 onload="SetupLeak()" onunload="BreakLeak()">
<div id="LeakedDiv"></div>
</body>
</html>
你可以使用直接賦null值得方式來破壞該泄漏情形。在頁面文檔卸載前賦null值,將會讓指令碼引擎知道對象間的引用鏈沒有了。現在它將能正常的清理引用並釋放DOM對象。在這個樣本中,作為Web開發員的你因該更多的瞭解了對象間的關係。
作為一個基本的情形,循環參考可能還有更多不同的複雜表現。對基於對象的JScript,一個通常用法是通過封裝JScript對象來擴充DOM對象。在構建過程中,你常常會把DOM對象的引用放入JScript對象中,同時在DOM對象中也存放上對新近建立的JScript對象的引用。你的這種應用模式將非常便於兩個對象之間的相互訪問。這是一個非常直接的循環參考問題,但是由於使用不用的文法形式可能並不會讓你在意。要破環這種使用情景可能變得更加複雜,當然你同樣可以使用簡單的樣本以便於清楚的討論。
<html>
<head>
<script language="JScript">
function Encapsulator(element)
{
// Set up our element
this.elementReference = element;
// Make our circular reference
element.expandoProperty = this;
}
function SetupLeak()
{
// The leak happens all at once
new Encapsulator(document.getElementById("LeakedDiv"));
}
function BreakLeak()
{
document.getElementById("LeakedDiv").expandoProperty = null;
}
</script>
</head>
<body onload="SetupLeak()" onunload="BreakLeak()">
<div id="LeakedDiv"></div>
</body>
</html>
更複雜的辦法還有記錄所有需要解除引用的對象和屬性,然後在Web文檔卸載的時候統一清理,但大多數時候你可能會再造成額外的泄漏情形,而並沒有解決你的問題。
閉包函數(Closures)
由於閉包函數會使程式員在不知不覺中建立出循環參考,所以它對資源泄漏常常有著不可推卸的責任。而在閉包函數自己被釋放前,我們很難判斷父函數的參數以及它的局部變數是否能被釋放。實際上閉包函數的使用已經很普通,以致人們頻繁的遇到這類問題時我們卻束手無策。在詳細瞭解了閉包背後的問題和一些特殊的閉包泄漏樣本後,我們將結合循環參考的圖示找到閉包的所在,並找出這些不受歡迎的引用來至何處。
Figure 2. 閉包函數引起的循環參考
普通的循環參考,是兩個不可探知的對象相互引用造成的,但是閉包卻不同。代替直接造成引用,閉包函數則取而代之從其父函數範圍中引入資訊。通常,函數的局部變數和參數只能在該被調函數自身的生命週期裡使用。當存在閉包函數後,這些變數和參數的引用會和閉包函數一起存在,但由於閉包函數可以超越其父函數的生命週期而存在,所以父函數中的局部變數和參數也仍然能被訪問。在下面的樣本中,參數1將在函數調用終止時正常被釋放。當我們加入了一個閉包函數後,一個額外的引用產生,並且這個引用在閉包函數釋放前都不會被釋放。如果你碰巧將閉包函數放入了事件之中,那麼你不得不手動從那個事件中將其移出。如果你把閉包函數作為了一個expando屬性,那麼你也需要通過置null將其清除。
同時閉包會在每次調用中建立,也就是說當你調用包含閉包的函數兩次,你將得到兩個獨立的閉包,而且每個閉包都分別擁有對參數的引用。由於這些顯而易見的因素,閉包確實非常用以帶來泄漏。下面的樣本將展示使用閉包的主要泄漏因素:
<html>
<head>
<script language="JScript">
function AttachEvents(element)
{
// This structure causes element to ref ClickEventHandler
element.attachEvent("onclick", ClickEventHandler);
function ClickEventHandler()
{
// This closure refs element
}
}
function SetupLeak()
{
// The leak happens all at once
AttachEvents(document.getElementById("LeakedDiv"));
}
function BreakLeak()
{
}
</script>
</head>
<body onload="SetupLeak()" onunload="BreakLeak()">
<div id="LeakedDiv"></div>
</body>
</html>
如果你對怎麼避免這類泄漏感到疑惑,我將告訴你處理它並不像處理普通循環參考那麼簡單。"閉包"被看作函數範圍中的一個臨時對象。一旦函數執行退出,你將失去對閉包本身的引用,那麼你將怎樣去調用detachEvent方法來清除引用呢?在Scott Isaacs的MSN Spaces上有一種解決這個問題的有趣方法。這個方法使用一個額外的引用(原文叫second closure,可是這個樣本裡致始致終只有一個closure)協助window對象執行onUnload事件,由於這個額外的引用和閉包的引用存在於同一個對象域中,於是我們可以藉助它來釋放事件引用,從而完成引用移除。為了簡單起見我們將閉包的引用暫存在一個expando屬性中,下面的樣本將向你示範釋放事件引用和清除expando屬性。
<html>
<head>
<script language="JScript">
function AttachEvents(element)
{
// In order to remove this we need to put
// it somewhere. Creates another ref
element.expandoClick = ClickEventHandler;
// This structure causes element to ref ClickEventHandler
element.attachEvent("onclick", element.expandoClick);
function ClickEventHandler()
{
// This closure refs element
}
}
function SetupLeak()
{
// The leak happens all at once
AttachEvents(document.getElementById("LeakedDiv"));
}
function BreakLeak()
{
document.getElementById("LeakedDiv").detachEvent("onclick",
document.getElementById("LeakedDiv").expandoClick);
document.getElementById("LeakedDiv").expandoClick = null;
}
</script>
</head>
<body onload="SetupLeak()" onunload="BreakLeak()">
<div id="LeakedDiv"></div>
</body>
</html>
在這篇KB文章中,實際上建議我們除非迫不得已盡量不要建立使用閉包。文章中的樣本,給我們示範了非閉包的事件引用方式,即把閉包函數放到頁面的全域範圍中。當閉包函數成為普通函數後,它將不再繼承其父函數的參數和局部變數,所以我們也就不用擔心基於閉包的循環參考了。在非必要的時候不使用閉包這樣的編程方式可以盡量使我們的代碼避免這樣的問題。
最後,指令碼引擎開發組的Eric Lippert,給我們帶來了一篇關於閉包使用通俗易懂的好文章。他的最終建議也是希望在真正必要的時候才使用閉包函數。雖然他的文章沒有提及閉包會使用的真正情境,但是這兒已有的大量樣本非常有助於大家起步。
頁面交叉泄漏(Cross-Page Leaks)
這種基於插入順序而常常引起的泄漏問題,主要是由於對象建立過程中的臨時對象未能被及時清理和釋放造成的。它一般在動態建立頁面元素,並將其添加到頁面 DOM中時發生。一個最簡單的樣本情境是我們動態建立兩個對象,並建立一個子項目和父元素間的臨時域(譯者註:這裡的域(Scope)應該是指管理元素之間階層關係的對象)。然後,當你將這兩個父子結構元素構成的的樹添加到頁面DOM樹中時,這兩個元素將會繼承頁面DOM中的層次管理域對象,並泄漏之前建立的那個臨時域對象。下面的圖示樣本了兩種動態建立並添加元素到頁面DOM中的方法。在第一種方法中,我們將每個子項目添加到它的直接父元素中,最後再將建立好的整棵子樹添加到頁面DOM中。當一些相關條件合適時,這種方法將會由於臨時對象問題引起泄漏。在第二種方法中,我們自頂向下建立動態元素,並使它們被建立後立即加入到頁面DOM結構中去。由於每個被加入的元素繼承了頁面DOM中的結構域對象,我們不需要建立任何的臨時域。這是避免潛在記憶體流失發生的好方法。
Figure 3. DOM插入順序泄漏模型
接下來,我們將給出一個躲避了大多數泄漏檢測演算法的泄漏樣本。因為我們實際上沒有泄漏任何可見的元素,並且由於被泄漏的對象太小從而你可能根本不會注意這個問題。為了使我們的樣本產生泄漏,在動態建立的元素結構中將不得不內聯的包含一個指令碼函數指標。在我們設定好這些元素間的相互隸屬關係後這將會使我們泄漏內部臨時指令碼對象。由於這個泄漏很小,我們不得不將樣本執行成千上萬次。事實上,一個對象的泄漏只有很少的位元組。在運行樣本並將瀏覽器導航到一個空白頁面,你將會看到兩個版本代碼在記憶體使用量上的區別。當我們使用第一種方法,將子項目加入其父元素再將構成的子樹加入頁面DOM,我們的記憶體使用量量會有微小的上升。這就是一個交叉導航泄漏,只有當我們重新啟動IE進程這些泄漏的記憶體才會被釋放。如果你使用第二種方法將父元素加入頁面DOM再將子項目加入其父元素中,同樣運行若干次後,你的記憶體使用量量將不會再上升,這時你會發現你已經修複了交叉導航泄漏的問題。
<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>
這類泄漏應該被澄清,因為這個解決方案有悖於我們在IE中的一些有益經驗。建立帶有指令碼對象的DOM元素,以及它們已進行的相互關聯是瞭解這個泄漏的關鍵點。這實際上這對於泄漏來說是至關重要的,因為如果我們建立的DOM元素不包含任何的指令碼對象,同時使用相同的方式將它們進行關聯,我們是不會有任何泄漏問題的。樣本中給出的第二種技巧對於關聯大的子樹結構可能更有效(由於在那個樣本中我們一共只有兩個元素,所以建立一個和頁面DOM不相關的樹結構並不會有什麼效率問題)。第二個技巧是在建立元素的開始不關聯任何的指令碼對象,所以你可以安全的建立子樹。當你把你的子樹關聯到頁面DOM上後,再繼續處理你需要的指令碼事件。牢記並遵守關於循環參考和閉包函數的使用規則,你不會再在掛接事件時在你的代碼中遇到不同的泄漏。
我真的要指出這個問題,因為我們可以看出不是所有的記憶體流失都是可以很容易發現的。它們可能都是些微不足道的問題,但往往需要成千上萬次的執行一個更小的泄漏情境才能使問題顯現出來,就像DOM元素插入順序引起的問題那樣。如果你覺得使用所謂的"最佳"經驗來編程,那麼你就可以高枕無憂,但是這個樣本讓我們看到,即使是"最佳"經驗似乎也可能帶來泄漏。我們這裡的解決方案希望能提高這些已有的好經驗,或者介紹一些新經驗使我們避免泄漏發生的可能。
貌似泄漏(Pseudo-Leaks)
在大多數時候,一些APIs的實際的行為和它們預期的行為可能會導致你錯誤的判斷記憶體流失。貌似泄漏大多數時候總是出現在同一個頁面的動態指令碼操作中,而在從一個頁面跳轉到空白頁面的時候發生是非常少見的。那你怎麼能象排除頁面間泄漏那樣來排除這個問題,並且在新任務運行中的記憶體使用量量是否是你所期望的。我們將使用指令碼文本的重寫來作為一個貌似泄漏的樣本。
象DOM插入順序問題那樣,這個問題也需要依賴建立臨時對象來產生"泄漏"。對一個指令碼元素對象內部的指令碼文本一而再再而三的反覆重寫,慢慢地你將開始泄漏各種已關聯到被覆蓋內容中的指令碼引擎對象。特別地,和指令碼調試有關的對象被作為完全的代碼對象形式保留了下來。
<html>
<head>
<script language="JScript">
function LeakMemory()
{
// Do it a lot, look at Task Manager for memory response
for(i = 0; i < 5000; i++)
{
hostElement.text = "function foo() { }";
}
}
</script>
</head>
<body>
<button onclick="LeakMemory()">Memory Leaking Insert</button>
<script id="hostElement">function foo() { }</script>
</body>
</html>
如果你運行上面的範例程式碼並使用工作管理員查看,當從"泄漏"頁面跳轉到空白頁面時,你並不會注意到任何指令碼泄漏。因為這種指令碼泄漏完全發生在頁面內部,而且當你離開該頁面時被使用的記憶體就會回收。對於我們原本所期望的行為來說這樣的情況是糟糕的。你希望當重寫了指令碼內容後,原來的指令碼對象就應該徹底的從頁面中消失。但事實上,由於被覆蓋的指令碼對象可能已用作事件處理函數,並且還可能有一些未被清除的引用計數。正如你所看到的,這就是貌似泄漏。在表面上記憶體消耗量可能看起來非常的糟糕,但是這個原因是完全可以接受的。
總結
每一位Web開發員可能都整理有一份自己的程式碼範例列表,當他們在代碼中看到如列表中的代碼時,他們會意識到泄漏的存在並會使用一些開發技巧來避免這些問題。這樣的方法雖然簡單便捷,但這也是今天Web頁面記憶體流失普遍存在的原因。考慮我們所討論的泄漏情景而不是關注獨立的程式碼範例,你將會使用更加有效策略來解決泄漏問題。這樣的觀念將使你在設計階段就把問題估計到,並且確保你有計劃來處理潛在的泄漏問題。使用編寫加固代碼(譯者註:就是異常處理或清理對象等的代碼)的習慣並且採取清理所有自己佔用記憶體的方法。雖然對這個問題來說可能太誇張了,你也可能幾乎從沒有見到編寫指令碼卻需要自己清理自己佔用的記憶體的情況;使這個問題變得越來越顯著的是,指令碼變數和expando屬性間存在的潛在泄漏可能。
如果對模式和設計感興趣,我強烈推薦Scott的這篇blog,因為其中示範了一個通用的移除基於閉包泄漏的範例程式碼。當然這需要我們使用更多的代碼,但是這個實踐是有效,並且改進的情境非常容易在代碼中定位並進行調試。類似的注入設計也可以用在基於expando屬性引起的循環參考中,不過需要注意所註冊的方法自身不要讓泄漏(特別使用閉包的地方)跑掉。