基於.NET平台的開發語言中,最讓開發人員爽的一點就是垃圾回收處理機制,在編碼過程中,終於可以解放你的雙手來關注更重要的事情。很多的資料中在講到.NET中的垃圾回收機制時都說“CLR在合適的時候進行垃圾回收”,但什么時候才是“合適的時候”?內存又是如何分配的?CLR是如何對內存進行回收的?這一章我們來討論有關垃圾回收的相關內容。
早期的C/C++開發中,一個對象的生命周期大概像這樣:計算對象大小——查找可用內存——初始化對象——使用對象——摧毀對象。如果在上面的過程中,開發人員忘記了“摧毀對象”這一步驟,則很有可能導致內存泄露!這是一個非常可怕的事情!幸好,CLR的開發人員為我們解決了這一問題,在.NET Framework中引入了垃圾回收機制,使得開發人員不需要再過多地關注內存釋放的問題,CLR會在合適的時候進行執行垃圾回收來釋放不再使用的內存。這里就像一個邪惡的男人所說的話:給我一個女人,我能創造一個民族!其實一個新世界你都可以去創造,前提是要有一個足夠大的星球內存來容納你的子孫!CLR就是這么認為的。
在激活一個進程時,CLR會先保留一塊連續的內存,在主線程啟動過程中,可能會初始化一系列對象,CLR先計算對象大小及其開銷所占用的字節數,接着會在連續的內存塊中為這些對象分配內存,這些對象被配置在第0代內存,在構造第0代內存的時候會分配一個默認大小的內存,隨着程序的運行,可能會初始化更多的對象,CLR發現第0代內存不能裝載更多的新生對象,此時CLR會啟動垃圾回收器對第0代內存進行回收,不再使用的對象所占用的內存會被釋放,接着把0代對象提升為第1代,然后把新生對象配置在第0代內存區中。CLR使用了3個階段的代,每次新分配的對象都會被配置在第0代內存中,最老的對象在第2代內存中,每次為新對象分配內存時,都可能會進行垃圾回收以釋放內存,很顯然CLR認為“內存永遠也使用不完”,很顯然CLR為我們自動管理了內存垃圾,很顯然CLR的這個“認為”在我們開發人員看來是不成立的,我們從以下幾個方面來解讀垃圾回收機制。
垃圾回收是對引用類型而言的。
CLR要求引用類型的對象從托管堆中分配內存的,值類型是從棧中分配內存。在C#中通常使用new操作符來創建一個對象,編譯器將會在IL中生成newobj指令,執行一個newobj指令會有以下過程:(在前一節中我們已經知道,在一個進程啟動時會先保留一個連續的內存塊)先計算類型及其基類型的字段所需要的字節數A,再計算類型對象的指針和一個同步索引塊共8或16個字節,到此總共需要(A+8或18)字節的內存,CLR會檢查當前進程區是否有足夠的內存來容納(A+8或16)個字節的對象,如果有,則將新對象放其中,否則CLR進行垃圾回收,釋放不再使用的內存來容納新的對象,在整個進程的生命周期中,CLR會維護一個指針P,它一直指向當前進程所分配的最后一個對象內存的結尾處而不會跑出當前進程內存區邊界,如圖:
每次計算新的將要創建的對象所需要的字節數時,CLR都是通過P加上新的需要的字節數進行檢查可用內存區,如果超出了地址末尾,則表示當前的托管堆已經被用完,准備進行垃圾回收了。由於進程擁有一個獨立連續的內存區,所以CLR能保證創建的新對象基本上都是緊挨着放置的。
當托管堆的內存被用完,新生的對象無處放置時,CLR就要開始進行垃圾回收了,隨着程序的持續運行,托管堆可能越來越大,如果要對整個托管堆進行垃圾回收(下面會講到如何回收),勢必會嚴重影響性能,因為有時可能僅僅需要數十個字節就能容納新的對象,有時候可能要對可達的對象進行搬遷,為了小范圍有目的性地進行垃圾回收,CLR使用了“代”概念來優化垃圾回收器,代是垃圾回收機制使用的一個邏輯技術,也是一種算法,它把托管堆中的內存分為3個代(截止到目前.NET Framework4.0有3個代:0、1、2)。
進程在初始化時,CLR為托管堆初始化為包含0個對象的一塊內存區域,新添加到堆中的對象為第0代對象,CLR在初始化第0代內存區時會分配一個默認的配額,假設為512K,不同的.NET框架和版本,可能這個配額不相同。假設進程及其線程初始化完成后分配了4個對象,如下圖:
這4個對象占據了512K的內存,程序繼續運行,當再分配第5個對象Obj5的時候,發現第0代已無可用內存,此時CLR會啟動垃圾回收器進行垃圾回收,假如上面的Obj3已經無效,此是Obj3的內存會被釋放出來,接着搬遷Obj4對象到Obj3的位置(在Obj2的內存地址末尾處),存活下來的對象Obj1、Obj2和Obj4會被提升為第1代對象,第1代的內存區域根據程序運行的情況,CLR可能會為其分配20M(也可能是其他值)大小的內存區,第0代內存暫時為空,接着將Obj5分配到第0代內存區,如下:
程序繼續運行,並又新分配了4個對象Obj6-Obj9,且此時Obj2和Obj5都不再使用,即為不可達對象,此時需要再創建一個新對象Obj10,但發現第0代的512K內存已經用完,所以CLR再一次啟動垃圾回收器進行垃圾回收,這一次垃圾回收器會認為第0代的新對象生命周期短,所以先對第0代進行回收,並將存活對象提升到第1代中,垃圾回收器發現此時第1代中的對象遠遠小於20M,所以放棄對第1代的回收,程序繼續運行,分配N多的新對象,當把第0代的對象提升到第1代,而第1代對象超20M時,則會對第1代的對象進行回收,第1代存活的對象被提升為第2代,第0代存活的對象被提升為第1代,如下圖:
每一次垃圾回收的過程,垃圾回收器會根據實際使用情況自動調整第0、1、2代的默認配額大小,比如可能將第2代調整為200M,幾分鍾過后可能將其調整為120M,也有可能是1024M,程序繼續運行,當對3個全部進行了垃圾回收且重新調整配額后,可用內存還不足以放置新對象,CLR就會拋出OutOfMemoryException異常,此時活神仙也無法施救了。原來CLR認為“內存永遠也使用不完”也是有條件的啊!
托管堆中的一個對象,當線程中有變量對其引用則為可達對象,否則為不可達對象。
在一次垃圾回收過程開始時,垃圾回收器會認為堆中的所有對象都是垃圾。
第一步是標記對象,垃圾回收器沿着線程棧上行檢查所有根,靜態字段、方法參數、活動中的局部變量以及寄存器指向的對象等都是根,當發現有根引用了托管堆中的對象A時,垃圾回收器會對此對象A進行標記,在標記A時,如果檢測到對象A內又引用了另一個對象B,則也對B進行標記,對一個根檢測完畢后會接着檢測下一個根,執行同樣的標記過程,代碼中很有可能多個對象中引用了同一個對象C,垃圾回收器只要檢測到對象C已經被標記過,則不再對對象C內所引用的對象進行檢測,以防止無限循環標記。有標記的對象就是可達對象,未標記的對象就是不可達對象。
第二步是搬遷對象壓縮堆,垃圾回收器遍歷堆中的所有對象來尋找未標記的對象,因為未標記的對象是垃圾對象,可以進行回收,如果發現對象較小,則忽略,否則會先釋放這些垃圾對象所占的內存,再把可達對象搬遷到這里以壓縮堆,在搬遷可達對象之后,所有指向這些對象的變量將無效,接着垃圾回收器要重新遍歷應用程序的所有根來修改它們的引用。在這個過程中如果各個線程正在執行,很可能導致變量引用到無效的對象地址,所以整個進程的正在執行托管代碼的線程是被掛起的。
其實在垃圾回收器准備開始一次回收時,正在執行托管代碼的所有線程都必須被掛起,掛起時,CLR會記錄每個線程的指令指針以確定線程當前執行到哪里以便將來在垃圾回收結束后進行恢復。如果一個線程的指令指針恰好到達了一個安全點,則可以掛起該線程,否則CLR會嘗試劫持該線程,如果還未到達安全點,則等待幾百毫秒后CLR會嘗試再一次劫持該線程,有可能經過多次嘗試,最終掛起該線程,當當前進程的所有執行托管代碼的線程都掛起后,垃圾回收器就可以開始工作了。(有關線程劫持可查找相關資料)。垃圾回收器回收完畢后,CLR恢復所有線程,程序繼續運行。可見,垃圾回收對性能影響之巨大!
在創建新對象時,任何大於等於85000字節的對象都被認為是大對象,這些對象的內存是從大對象堆中分配的,大對象總是被認為是第2代對象,要盡量避免分配大對象來減少性能損傷,為了提高性能,垃圾回收器不對大對象進行搬遷壓縮,只在回收第2代內存時進行回收。
一般的情況下,CLR會智能地在必要的時候更行垃圾回收,但我們也可以在我們願意的情況下手動啟動垃圾回收器,System.GC類提供了重載版本的靜態方法來啟動垃圾回收器:
//對所有代進行垃圾回收。 GC.Collect(); //對指定的代進行垃圾回收。 GC.Collect(int generation); //強制在 System.GCCollectionMode 值所指定的時間對零代到指定代進行垃圾回收。 GC.Collect(int generation, GCCollectionMode mode);
在上一節中我們已經知道,每一次垃圾回收過程都會導致性能損傷,所以我們盡量避免調用這3個方法進行垃圾回收,當然必要的時候也可以調用。
不僅僅以上談到幾種情況下會啟動垃圾回收器,當CLR接到Windwos發出內存告急通知時也會啟動垃圾回收、CLR卸載AppDomain時也會啟動垃圾回收。