這裡我們將解釋為什麼 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