GC與JS內存泄露
原文發布于2010年9月19日
Javascript有沒有內存泄露?如果有,如何避免?鑒于最近有好幾個人問到我類似的問題,看來大家對這部分內容還沒有系統的研究過,因此,打算在這里把個人幾年前整理的一些資料和大家分享一下。
首先,可以肯定的說,javascript的一些寫法會造成內存泄露的,至少在IE6下如此。因此,在IE6遲遲不肯退休的今天,我們還是有必要了解相關的知識(雖然大部分情況下,js造成的這點內存泄露不是致使電腦運行變慢的主要原因)。相關的研究主要集中在05-07這幾年,本文并沒有什么新的觀點,如果當年有研究過的朋友,可以直接忽略。
作為前端開發人員,了解這些問題的時候,需要知其然也知其所以然,因此,在介紹js內存泄露前,我們先從為什么會有內存泄露談起。
說到內存泄露,就不得不談到內存分配的方式。內存分配有三種方式,分別是:
一、靜態分配( Static Allocation ):靜態變量和全局變量的分配形式。如果把房間看做一個程序,我們可以把靜態分配的內存當成是房間里的耐用家具。通常,它們無需釋放和回收,因為沒人會天天把大衣柜當作垃圾扔到窗外。
二、自動分配( Automatic Allocation ):在棧中為局部變量分配內存的方法。棧中的內存可以隨著代碼塊退出時的出棧操作被自動釋放。這類似于到房間中辦事的人,事情一旦完成,就會自己離開,而他們所占用的空間,也隨著這些人的離開而自動釋放了。
三、動態分配( Dynamic Allocation ):在堆中動態分配內存空間以存儲數據的方式。也就是程序運行時用malloc或new申請的內存,我們需要自己用free或delete釋放。動態內存的生存期由程序員自己決定。一旦忘記釋放,勢必造成內存泄露。這種情況下,堆中的內存塊好像我們日常使用的餐巾紙,用過了就得扔到垃圾箱里,否則屋內就會滿地狼藉。因此,懶人們做夢都想有一臺家用機器人跟在身邊打掃衛生。在軟件開發中,如果你懶得釋放內存,那么你也需要一臺類似的機器人——這其實就是一個由特定算法實現的垃圾收集器。而正是垃圾收集機制本身的一些缺陷,導致了javascript內存泄露。
幾年前看過一篇叫《垃圾回收趣史》的文章,里面對垃圾回收機制進行了深入淺出的說明。
就像機械增壓這種很多豪車作為賣點的技術,其實上個世紀10年代奔馳就在使用了一樣,垃圾回收技術誕生也有很長的時間了。1960 年前后誕生于 MIT 的 Lisp 語言是第一種高度依賴于動態內存分配技術的語言,Lisp 中幾乎所有數據都以“表”的形式出現,而“表”所占用的空間則是在堆中動態分配得到的。 Lisp 語言先天就具有的動態內存管理特性要求 Lisp 語言的設計者必須解決堆中每一個內存塊的自動釋放問題(否則, Lisp 程序員就必然被程序中不計其數的 free 或 delete 語句淹沒),這直接導致了垃圾收集技術的誕生和發展。
而三種最基本的垃圾回收算法,也在那個時候一起出現了。下面我們一個一個了解一下:
引用計數(Reference Counting)算法:這個可能是最早想到的方法。形象點說,引用計數可以這么理解,房子里放了很多白紙,這些紙就好比是內存。使用內存,就好比在這些紙上寫字。內存可以隨便使用,但是,有個條件,任何使用一張紙的人,必須在紙的一角寫上計數1,如果2個人同時使用一張紙,那么計數就變成2,以此類推。當一個人使用完某張紙的時候,必須把角上的計數減1,這樣,一旦當計數變為0,就滿足了垃圾回收條件,等在一旁的機器人會立即把這張紙扔進垃圾箱。基于引用計數器的垃圾收集器運行較快,不會長時間中斷程序執行,適宜必須實時運行的程序。但引用計數器增加了程序執行的開銷;同時,還有個最大的問題,這個算法存在一個缺陷,就是一旦產生循環引用,內存就會被泄露。舉個例子,我們new了2個對象a和b,這時,a和b的計數都是1,然后,我們把a的一個屬性指向b,b的一個屬性指向a,此時,由于引用的關系,a和b的計數都變成了2,當程序運行結束時,退出作用域,程序自動把a的計數減1,由于最后a的計數仍然為1,因此,a不會被釋放,同樣,b最后的計數也為1,b也不會被釋放,內存就這么泄露了!
標記-清除(Mark-Sweep)算法:同樣是房間和白紙的例子,這次規則有所修改。白紙仍然隨便用,并且,一開始,不需要做什么記號,但是用到某個時候,機器人會突然命令所有人停下來,這時,需要每個人在自己仍然需要使用的白紙上做一個記號,大家都做完記號后,機器人會把那些沒有記號的白紙全部扔進垃圾箱。正如其名稱所暗示的那樣,標記-清除算法的執行過程分為“標記”和“清除”兩大階段。這種分步執行的思路奠定了現代垃圾收集算法的思想基礎。與引用計數算法不同的是,標記-清除算法不需要運行環境監測每一次內存分配和指針操作,而只要在“標記”階段中跟蹤每一個指針變量的指向——用類似思路實現的垃圾收集器也常被后人統稱為跟蹤收集器( Tracing Collector )。當然,標記-清楚算法的缺陷也很明顯,首先是效率問題,為了標記,必須暫停程序,長時間進行等待,其次,標記清除算法會造成內存碎片,比如被標記清除的只是一些很小的內存塊,而我們接下來要申請的都是一些大塊的內存,那么剛才清除掉的內存,其實還是無法使用。解決方案,常見的有2種,一是清除后對內存進行復制整理,就像磁盤整理程序那樣,把所有還在使用的內存移到一起,把釋放掉的內存移到一起,如圖:
但是,這樣一來效率就更低了。
第二種方案是不移動內存,而是按大小分類,建立一系鏈表,把這些碎片按大小連接并管理起來,(4個字節的內存一個鏈表,8個字節的內存一個鏈表……)如果我們需要4個字節的內存,就從4個字節的鏈表里面去取,需要16個字節,就從16字節的鏈表里面去取,只有到了一定時候,比如程序空閑或者大塊的內存空間不足,才會去整理合并這些碎片。
為什么重點談mark-sweep算法呢,主要是ie對javascript的垃圾回收,采用的就是這種算法。
復制(copying)算法:mark-sweep算法效率低下,由此,又產生了一種新的奇思妙想,我們再把規則換一下:還是房間和白紙的例子,這次我們把房間分成左右2部分,一開始,所有人都在左邊,白紙仍然隨便用,一定時候,機器人又會叫大家停下來,這次不做記號了,你只要帶著你還需要的白紙轉移到右邊去就可以了(相當于把現有的程序復制一份,無法使用的部分自然不會被復制),那些沒用的紙自然就剩了下來,然后機器人會把左邊所有的垃圾打掃干凈(相當于把原先使用的那一半內存直接清空),下次執行垃圾回收的時候采用同樣的方式,只不過這次從右邊向左邊遷移。這種算法的效率奇高,可惜,對內存的消耗太大,尤其是在1960年,內存可比黃金貴多了,直接砍掉一半的內存,顯然是無法接受的。
了解完垃圾回收算法,再來看看IE下為什么會產生內存泄露。
在IE 6中,對于javascript object內部,javascript使用的是mark-and-sweep算法,這點前面也有提到,因此,純粹的javascript對象的使用,不會造成內存泄露,但是對于javascript object與外部object(包括native object和vbscript object等等)的引用時,IE 6使用引用計數,這樣一來,內存泄露就產生了。這點在犀牛書第八章函數部分有提到。
以下是常見的幾種javascript內存泄露的情況:
一、循環引用:
< 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; } </ script > </ head > < body onload = ” SetupLeak() ” > < div id = ” LeakedDiv ” ></ div > </ body > </ html >
我們可以看到,myGlobalObject指向了一個DOM對象,而這個DOM對象的一個屬性又指向了myGlobalObject,循環引用出現,內存泄露,其原理如下:
解決方案很簡單,在確保屬性不再使用后,加入以下代碼就可以了:
function BreakLeak() // 解開循環引用,解決內存泄露問題 { document.getElementById( ” LeakedDiv ” ).expandoProperty = null ; }
說起來容易,不過當我們程序非常復雜的時候,發現和修改就沒有這么容易了。
二、閉包(Closures)
仍然先看一段代碼:
< 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 “ )); } </ script > </ head > < body onload = “ SetupLeak() “ > < div id = “ LeakedDiv “ ></ div > </ body > </ html >
閉包的一個內部方法賦給了element對象,產生了一個作用域的循環引用,從而造成內存泄露。其原理圖如下:
function BreakLeak() { document.getElementById(”LeakedDiv”).detachEvent(”onclick”, document.getElementById(”LeakedDiv”).expandoClick); document.getElementById(”LeakedDiv”).expandoClick = null; }
通常情況下,常用的js框架都幫我們解決了這個問題,不需要我們自己處理,這也是使用框架的一個好處。
三、Cross-Page-Leaks
仍然先看一個例子:
< 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這兩段函數的唯一區別就在于他們的代碼的循序,從代碼上看,兩段代碼的邏輯都沒有錯。
但LeakMemory卻會造成泄露。原因是LeakMemory()會先建立起parentDiv和childDiv之間的連接,這時候,為了讓 childDiv能夠獲知parentDiv的信息,因此IE需要先建立一個臨時的scope對象。而后parentDiv建立了和 hostElement對象的聯系,parentDiv和childDiv直接使用頁面document的scope。可惜的是,IE不會釋放剛才那個臨時的scope對象的內存空間,直到我們跳轉頁面,這塊空間才能被釋放。而CleanMemory函數不同,他先把parentDiv和 hostElement建立聯系,而后再把childDiv和parentDiv建立聯系,這個過程不需要單獨建立臨時的scope,只要直接使用頁面 document的scope就可以了,所以也就不會造成內存泄露了。但是,需要特別說明一下,如果LeakMemory方法里面,創建的div對象上不綁定script事件,那么也不會有泄漏,這個可以理解為ie的bug,大家記住就可以了,不需要過分深究。其原理如下:
四、Pseudo-Leaks:
同樣可以理解為ie的bug的一種泄露:
<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>
沒什么特別的好解釋,記住就可以了。
關于這四種泄漏的具體描述,還是請各位參照原文:http://msdn.microsoft.com/en-us/library/Bb250448
以上是幾種主要的泄露,當然,除此之外,網上還有一些其他的討論,比如var str = "lalala";alert(str.length);這個簡單的語句也會造成內存泄露,原因是類型轉換的時候,ie生成了一個臨時對象,這個臨時對象被泄漏了。類似情況還有很多,大家有興趣可以自己去搜集整理。
最后說一下,只要ie6還健在,作為前端開發人員,就不能逃避這些問題,當然,也不必過分深究,比如閉包的情況就比較難避免,就像我一開始說的,畢竟,javascript造成的內存泄露不是程序和項目的瓶頸,我們需要在各方面進行權衡。