GC與JS內存泄露

作者: 阿里巴巴UED中文站  來源: 阿里巴巴UED中文站  發布時間: 2010-09-19 21:47  閱讀: 6140 次  推薦: 0   原文鏈接   [收藏]  
摘要:Javascript有沒有內存泄露?如果有,如何避免?鑒于最近有好幾個人問到我類似的問題,看來大家對這部分內容還沒有系統的研究過,因此,打算在這里把個人幾年前整理的一些資料和大家分享一下。

  原文發布于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造成的內存泄露不是程序和項目的瓶頸,我們需要在各方面進行權衡。

0
0
 
標簽:JS 內存泄漏
 
 

文章列表

全站熱搜
創作者介紹
創作者 大師兄 的頭像
大師兄

IT工程師數位筆記本

大師兄 發表在 痞客邦 留言(0) 人氣()