這裡我們將解釋為什麼 JavaScript 會產生記憶體洩漏的問題,並示範會產生這個問題的程式寫法,讓大家知道該如何處理這類的問題。
JavaScript 是一種功能強大的語言,在現今許多的網頁中都扮演著重要的角色,雖然其語法簡單、撰寫容易,但是在某些瀏覽器上會產生記憶體洩漏(memory leak)的問題,卻很讓人頭痛。
本文是假設讀者已經對於 JavaScript 與 DOM 都非常熟悉,如果你是使用 JavaScript 撰寫網頁應用程式的開發者,這篇文章應該會很有用。
JavaScript 的記憶體洩漏(Memory Leak)
JavaScript 是一種會自動回收記憶體(garbage collection)的程式語言,也就是記憶體會在 JavaScript 物件被宣告與建立的時候動態配置,而當該 JavaScript 物件不會再被使用時,瀏覽器就會將其收回,而 JavaScript 這樣的記憶體回收方式基本上是沒有什麼問題的,問題會出在部分瀏覽器處理配置與回收 DOM 物件記憶體的方式上。
Internet Explorer 與 Mozilla Firefox 是兩個以參照計數(reference counting)來處理 DOM 物件記憶體的瀏覽器,在這樣的架構下,每一個 DOM 物件都會記錄有多少其他的 DOM 物件有使用到它,當這個數字變成零的時候,就代表這個 DOM 物件已經不會被任何其他 DOM 物件所使用,這時候瀏覽器就會將這個 DOM 物件的記憶體收回至 heap,雖然這種記憶體處理方式非常有效率,但是如果碰到循環式的參照(circular references 或 cyclic references)就會有問題。
循環式參照(circular references)
循環式參照是指兩個物件互相使用對方,所以讓兩個物件的參照計數都至少維持在 1 以上,在單純使用自動回收記憶體的系統中,這個不會有問題,只要檢查這兩個物件,看看是否有被其他的物件所使用,如果發現都沒有其餘的物件會使用它們兩個,就直接把這兩個物件都回收即可。
但是在一個混合式(hybrid)的系統中,同時使用自動回收記憶體與參照計數兩種機制,當系統無法判定循環式參照的出現時,就會發生記憶體洩漏,這樣的狀況下不管是 DOM 物件或是 JavaScript 物件都無法被回收:
<html> <body> <script type="text/javascript"> document.write("Circular references between JavaScript and DOM!"); var obj; window.onload = function(){ obj=document.getElementById("DivElement"); document.getElementById("DivElement").expandoProperty=obj; obj.bigString=new Array(1000).join(new Array(2000).join("XXXXX")); }; </script> <div id="DivElement">Div Element</div> </body> </html>
在這段程式碼中,obj
這個 JavaScript 物件保有一個 DOM 物件的參照 DivElement
,而接著這個 DOM 物件又透過 expandoProperty
參照該 JavaScript 物件,形成了一個 JavaScript 與 DOM 物件之間循環式的參照,這時候因為 DOM 物件是使用參照計數的方式來管理的,所以這樣的情況下兩個物件都不會被回收。
下面這是一個呼叫外部 JavaScript 函數的例子,同樣會形成循環式參照的問題,最後造成記憶體的洩漏:
<html> <head> <script type="text/javascript"> document.write("Circular references between JavaScript and DOM!"); function myFunction(element) { this.elementReference = element; // 這裡會形成循環式參照 // DOM-->JS-->DOM element.expandoProperty = this; } function Leak() { // 造成記憶體的洩漏 new myFunction(document.getElementById("myDiv")); } </script> </head> <body onload="Leak()"> <div id="myDiv"></div> </body> </html>
從上面兩個例子可以看出來,循環式參照其實很容易會發生,另外在 JavaScript 的 closure 中也常常出現。
JavaScript 的 Closures
JavaScript 語言可以允許巢狀(nested)的函數結構,也就是在一個函數中定義另外一個內部函數(inner function),許多 JavaScript 的程式設計者會使用這樣的技術,在一個大函數中定義一些小的 utility 函數來使用:
function parentFunction(paramA) { var a = paramA; function childFunction() { return a + 2; } return childFunction(); }
在內部的函數中可以直接使用外部函數中的變數,而內部函數中所宣告的變數對於外部而言則是私有的(private)。在這個例子中,內部的 childFunction()
函數直接存取外部函數 parentFunction()
中的變數,這樣的狀況就稱為 closure。
接著看下面這個例子:
<html> <body> <script type="text/javascript"> document.write("Closure Demo!!"); window.onload= function closureDemoParentFunction(paramA) { var a = paramA; return function closureDemoInnerFunction (paramB) { alert( a +" "+ paramB); }; }; var x = closureDemoParentFunction("outer x"); x("inner x"); </script> </body> </html>
在上面的例子中,closureDemoInnerFunction()
是一個定義在 closureDemoParentFunction()
函數中的內部函數,當 closureDemoParentFunction("outer x")
被呼叫時,外層函數 closureDemoParentFunction()
中的 a
變數會被指定為 "outer x"
,然後該外層函數會傳回一個指向內部函數 closureDemoInnerFunction()
的指標,然後儲存至 x
變數中。
這裡要注意一點,在 closureDemoParentFunction()
函數中的區域變數 a
在整個函數執行結束並返回之後,還是會持續存在,這跟 C/C++ 這類的語言是不一樣的,在 C/C++ 語言中,區域變數在整個函數執行完成且返回之後就會消失,而在 JavaScript 中當 closureDemoParentFunction
被呼叫時,會產生一個俱有 a
屬性的 scope 物件,這個 a
屬性會儲存 paramA
的值(也就是 "outer x"
)。由於 x
變數所儲存的 closureDemoInnerFunction()
這個內部函數包含一個指向外層變數的指標,所以先前所產生的 scope 物件在這個時候也就不會被回收,而當 x("inner x")
被呼叫時,就會顯示 "outer x inner x"
這個訊息。
關於 closure 詳細說明,亦可參考 MDN 的教學。
上面這個例子是一個很簡單的 JavaScript closure 範例,由於 clusure 可以讓內部函數中所有會用到的變數都保留下來,即便是外層的函數已經返回了,這些變數還是可以繼續保留下來,雖然 clusure 很好用,但是他也很容易在背後造成 JavaScript 與 DOM 物件間的循環式參照。
Closures 與循環式參照
這個例子中有一個 clusure 的情況,JavaScript 物件 obj
透過 "element"
這個 id
保留一份 DOM 物件的參照,而 DOM 物件也保留一份 JavaScript 物件 obj
的參照,形成一個循環式的參照,造成記憶體洩漏。
<html> <body> <script type="text/javascript"> document.write("Program to illustrate memory leak via closure"); window.onload=function outerFunction(){ var obj = document.getElementById("element"); obj.onclick=function innerFunction(){ alert("Hi! I will leak"); }; // 這個是為了讓記憶體洩漏更明顯 obj.bigString=new Array(1000).join(new Array(2000).join("XXXXX")); }; </script> <button id="element">Click Me</button> </body> </html>
避免記憶體洩漏
雖然 JavaScript 很容易造成記憶體洩漏,但是在了解會出問題的狀況之後,我們就可以避開這些狀況,像上面 closures 與循環式參照的問題,就可以改用下面幾種方式解決。
第一種方式就是將 JavaScript 物件 obj
設定為 null
,這樣就可以直接破壞循環式參照:
<html> <body> <script type="text/javascript"> document.write("破壞循環式參照,避免記憶體洩漏"); window.onload = function outerFunction(){ var obj = document.getElementById("element"); obj.onclick = function innerFunction() { alert("這樣可以避免記憶體洩漏"); // ... 一些程式碼 ... }; obj.bigString = new Array(1000).join(new Array(2000).join("XXXXX")); obj = null; // 這裡破壞循環式參照 }; </script> <button id="element">"Click Here"</button> </body> </html>
另外一個方式是加入另外一個 clusure,破壞循環式參照:
<html> <body> <script type="text/javascript"> document.write("加入另外一個 clusure,破壞循環式參照。"); window.onload=function outerFunction(){ var anotherObj = function innerFunction() { // ... 一些程式碼 ... alert("這樣可以避免記憶體洩漏"); }; (function anotherInnerFunction(){ var obj = document.getElementById("element"); obj.onclick = anotherObj; })(); }; </script> <button id="element">"Click Here"</button> </body> </html>
最後一種方式是直接避開 closure,不要讓循環式參照形成:
<html> <head> <script type="text/javascript"> document.write("直接避開 closure!"); window.onload=function() { var obj = document.getElementById("element"); obj.onclick = doesNotLeak; } function doesNotLeak() { // ... 一些程式碼 ... alert("這樣可以避免記憶體洩漏"); } </script> </head> <body> <button id="element">"Click Here"</button> </body> </html>
結論
這裡我們解釋了循環式參照造成記憶體洩漏的原因,並且討論 clusure 會造成記憶體洩漏的狀況,如果你想要更深入研究相關的主體,可以參考 IBM Developer Works 網頁上的 Resources。