天天看點

.Net 垃圾回收機制原理(一)

英文原文:jeffrey richter

編譯:趙玉開

連結:http://www.cnblogs.com/yukaizhao/archive/2011/11/23/dot_net_gc_1.html

有了microsoft.net clr中的垃圾回收機制程式員不需要再關注什麼時候釋放記憶體,釋放記憶體這件事兒完全由gc做了,對程式員來說是透明的。盡管如此,作為一個.net程式員很有必要了解垃圾回收是如何工作的。這篇文章我們就來看下.net是如何配置設定和管理托管記憶體的,之後再一步一步描述垃圾回收器工作的算法機制。

為程式設計一個适當的記憶體管理政策是困難的也是乏味的,這個工作還會影響你專注于解決程式本身要解決的問題。有沒有一種内置的方法可以幫助開發人員解決記憶體管理的問題呢?當然有了,在.net中就是gc,垃圾回收。

讓我們想一下,每一個程式都要使用記憶體資源:例如螢幕顯示,網絡連接配接,資料庫資源等等。實際上,在一個面向對象環境中,每一種類型都需要占用一點記憶體資源來存放他的資料,對象需要按照如下的步驟使用記憶體:

1. 為類型配置設定記憶體空間

2. 初始化記憶體,将記憶體設定為可用狀态

3. 存取對象的成員

4. 銷毀對象,使記憶體變成清空狀态

5. 釋放記憶體

這種貌似簡單的記憶體使用模式導緻過很多的程式問題,有時候程式員可能會忘記釋放不再使用的對象,有時候又會試圖通路已經釋放的對象。這兩種bug通常都有一定的隐藏性,不容易發現,他們不像邏輯錯誤,發現了就可以修改掉。他們可能會在程式運作一段時間之後記憶體洩漏導緻意外的崩潰。事實上,有很多工具可以幫助開發人員檢測記憶體問題,比如:任務管理器,system

monitor acitviex control, 以及rational的purify。

而gc可以完全不需要開發人員去關注什麼時候釋放記憶體。然而,垃圾回收器并不是可以管理記憶體中的所有資源。有些資源垃圾回收器不知道該如何回收他們,這部分資源就需要開發人員自己寫代碼實作回收。在.net

framework中,開發人員通常會把清理這類資源的代碼寫到close、dispose或者finalize方法中,稍後我們會看下finalize方法,這個方法垃圾回收器會自動調用。

不過,有很多對象是不需要自己實作釋放資源的代碼的,比如:rectangle,清空它隻需要清空它的left,right,width,height字段就可以了,這垃圾回收器完全可以做。下面讓我們來看下記憶體是如何配置設定給對象使用的。

對象配置設定:

.net clr把所有的引用對象都配置設定到托管堆上。這一點很像c-runtime堆,不過你不需要關注什麼時候釋放對象,對象會在不用時自動釋放。這樣,就出現一個問題,垃圾回收器是怎麼知道一個對象不再使用該回收了呢?我們稍後解釋這個問題。

現在有幾種垃圾回收算法,每一種算法都為一種特定的環境做了性能優化,這篇文章我們關注的是clr的垃圾回收算法。讓我們從一個基礎概念談起。

當一個程序初始化之後,運作時會保留一段連續的空白記憶體空間,這塊記憶體空間就是托管堆。托管堆會記錄一個指針,我們叫它nextobjptr,這個指針指向下一個對象的配置設定位址,最初的時候,這個指針指向托管堆的起始位置。

應用程式使用new操作符建立一個新對象,這個操作符首先要确認托管堆剩餘空間能放得下這個對象,如果能放得下,就把nextobjptr指針指向這個對象,然後調用對象的構造函數,new操作符傳回對象的位址。

.Net 垃圾回收機制原理(一)

圖1托管堆

這時候,nextobjptr指向托管堆上下一個對象配置設定的位置,圖1顯示一個托管堆中有三個對象a、b和c。下一個對象會放在nextobjptr指向的位置(緊挨着c對象)

現在讓我們再看一下c-runtime堆如何配置設定記憶體。在c-runtime堆,配置設定記憶體需要周遊一個連結清單的資料結構,直到找到一個足夠大的記憶體塊,這個記憶體塊有可能會被拆分,拆分後連結清單中的指針要指向剩餘記憶體空間,要確定連結清單的完好。對于托管堆,配置設定一個對象隻是修改nextobjptr指針的指向,這個速度是非常快的。事實上,在托管堆上配置設定一個對象和線上程棧上配置設定記憶體的速度很接近。

到目前為止,托管堆上配置設定記憶體的速度似乎比在c-runtime堆上的更快,實作上也更簡單一些。當然,托管堆獲得這個優勢是因為做了一個假設:位址空間是無限的。很顯然這個假設是錯誤的。必須有一種機制保證這個假設成立。這個機制就是垃圾回收器。讓我們看下它如何工作。

當應用程式調用new操作符建立對象時,有可能已經沒有記憶體來存放這個對象了。托管堆可以檢測到nextobjptr指向的空間是否超過了堆的大小,如果超過了就說明托管堆滿了,就需要做一次垃圾回收了。

在現實中,在0代堆滿了之後就會觸發一次垃圾回收。“代”是垃圾回收器提升性能的一種實作機制。“代”的意思是:新建立的對象是年輕一代,而在回收操作發生之前沒有被回收掉的對象是較老的對象。将對象分成幾代可以允許垃圾回收器隻回收某一代的對象,而不是回收所有對象。

垃圾回收算法:

垃圾回收器檢檢視是否存在應用程式不再使用的對象。如果這樣的對象存在,那麼這些對象占用的空間就可以被回收(如果堆上沒有足夠的記憶體可用,那麼new操作符就會抛出outofmemoryexception)。你可能會問垃圾回收器是怎樣判斷一個對象是否還在用呢?這個問題不太容易得到答案。

每個應用程式都有一組根對象,根是一些存儲位置,他們可能指向托管堆上的某個位址,也可能是null。例如,所有的全局和靜态對象指針是應用程式的根對象,另外線上程棧上的局部變量/參數也是應用程式的根對象,還有cpu寄存器中的指向托管堆的對象也是根對象。存活的根對象清單由jit(just-in-time)編譯器和clr維護,垃圾回收器可以通路這些根對象的。

當垃圾回收器開始運作,它會假設托管堆上的所有對象都是垃圾。也就是說,假定沒有根對象,也沒有根對象引用的對象。然後垃圾回收器開始周遊根對象并建構一個由所有和根對象之間有引用關系對象構成的圖。

圖2顯示,托管堆上應用程式的根對象是a,c,d和f,這幾個對象就是圖的一部分,然後對象d引用了對象h,那麼對象h也被添加到圖中;垃圾回收器會循環周遊所有可達對象。

.Net 垃圾回收機制原理(一)

圖2

托管堆上的對象

垃圾回收器會挨個周遊根對象和引用對象。如果垃圾回收器發現一個對象已經在圖中就會換一個路徑繼續周遊。這樣做有兩個目的:一是提高性能,二是避免無限循環。

所有的根對象都檢查完之後,垃圾回收器的圖中就有了應用程式中所有的可達對象。托管堆上所有不在這個圖上的對象就是要做回收的垃圾對象了。建構好可達對象圖之後垃圾回收器開始線性的周遊托管堆,找到連續垃圾對象塊(可以認為是空閑記憶體)。然後垃圾回收器将非垃圾對象移動到一起(使用c語言中的memcpy函數),覆寫所有的記憶體碎片。當然,移動對象時要禁用所有對象的指針(因為他們都可能是錯誤的了)。是以垃圾回收器必須修改應用程式的根對象使他們指向對象的新記憶體位址。此外,如果某個對象包含另一個對象的指針,垃圾回收器也要負責修改引用。圖3顯示了一次回收之後的托管堆。

.Net 垃圾回收機制原理(一)

圖3

回收之後的托管堆

如圖3所示在回收之後,所有的垃圾對象都被辨別出來,而所有的非垃圾對象被移動到一起。所有的非垃圾對象的指針也被修改成移動後的記憶體位址,nextobjptr指向最後一個非垃圾對象的後面。這時候new操作符就可以繼續成功的建立對象了。

如你看到的,垃圾回收會有顯著的性能損失,這是使用托管堆的一個明顯的缺點。 不過,要記着記憶體回收操作旨在托管堆慢了之後才會執行。在滿之前托管堆的性能比c-runtime堆的性能好要好。運作時垃圾回收器還會做一些性能優化,我們在下一篇文章中談論這個。

下面的代碼說明了對象是如何被建立管理的:

也許你會問,gc這麼好,為什麼ansi c++中沒有它呢? 原因是垃圾回收器必須能找到應用程式的根對象清單,必須找到對象的指針。而在c++中對象的指針之間是可以互相轉換的,沒有辦法知道指針指向的是一個什麼對象的指針。在clr中,托管堆知道對象的實際類型。而中繼資料(metadata)資訊可以用來判斷對象引用了什麼成員對象。

垃圾回收和finalization

垃圾回收器提供了一個額外的功能,它可以在對象被辨別為垃圾後自動調用其finalize方法(前提是對象重寫了object的finalize方法)。

finalize方法是object對象的一個虛方法,如果需要你可以重寫這個方法,但是這個方法隻能通過類似c++析構函數的方式重寫。例如:

這裡用過c++的程式員要特别注意,finalize方法的寫法和c++的析構函數完全一樣,但是,.net 中的finalize方法和析構函數的卻是不一樣的,托管對象是不能被析構的,隻能通過垃圾回收回收。

當你設計一個類時,最好避免重寫finalize方法,原因如下:

1. 實作finalize的對象會被提升到更老的“代”,這會增加記憶體壓力,使對象和此對象的關聯對象不能在成為垃圾的第一時間回收掉。

2. 這些對象配置設定時間會更長

3. 讓垃圾回收器執行finalize方法會明顯的損耗性能。請記住,每一個實作了finalize方法的對象都需要執行finalize方法,如果有一個長度為10000的數組對象,每個對象都需要執行finalize方法

4. 重寫finalize方法的對象可能會 引用其他沒有實作finalize方法的對象,這些對象也會延遲回收

5. 你沒有辦法控制什麼時候執行finalize方法。如果要在finalize方法中釋放類似資料庫連接配接之類的資源,就有可能導緻資料庫資源在時候後很久才得以釋放

6. 當程式崩潰時,一些對象還被引用,他們的finalize方法就沒有機會執行了。這種情況會在背景線程使用對象,或者對象在程式退出時,或者appdomain解除安裝時。另外,預設情況下,當應用程式被強制結束時finalize方法也不會執行。當然所有的作業系統資源會被回收;但是在托管堆上的對象不會回收。你可以通過調用gc的requestfinalizeonshutdown方法改變這種行為。

7. 運作時不能控制多個對象finalize方法執行的順序。而有時候對象的銷毀可能有順序性

如果你定義的對象必須實作finalize方法,那麼要確定finalize方法盡可能快的執行,要避免所有可能引起阻塞的操作,包括任何線程同步操作。另外,要確定finalize方法不會引起任何異常,如果有異常垃圾回收器會繼續執行其他對象的finalize方法直接忽略掉異常。

當編譯器生成代碼時會自動在構造函數上調用基類的構造函數。同樣c++的編譯器也會為析構函數自動添加基類析構函數的調用。但是,.net中的finalize函數不是這樣子,編譯器不會對finalize方法做特殊處理。如果你想在finalize方法中調用父類的finalize方法,必須自己顯示添加調用代碼。

請注意在c#中finalize方法的寫法和c++中的析構函數一樣,但是c#不支援析構函數,不要讓這種寫法欺騙你。

gc調用finalize方法的内部實作

表面看,垃圾回收器嗲用finalize方法很簡單,你建立一個對象,當對象回收時調用它的finalize方法。但是事實上要複雜一些。

當應用程式建立一個新對象時,new操作符在堆上配置設定記憶體。如果對象實作了finalize方法。對象的指針會放到終結隊列中。終結隊列是由垃圾回收器控制的内部資料結構。在隊列中每一個對象在回收時都需要調用它們的finalize方法。

下圖顯示的堆上包含幾個對象,其中一些對象是跟對象,一些對象不是。當對象c、e、f、i和j建立時,系統會檢測這些對象實作了finalize方法,并将它們的指針放到終結隊列中。

.Net 垃圾回收機制原理(一)

finalize方法要做的事情通常是回收垃圾回收器不能回收的資源,例如檔案句柄,資料庫連接配接等等。

當垃圾回收時,對象b、e、g、h、i和j被标記為垃圾。垃圾回收器掃描終結隊列找到這些對象的指針。當發現對象指針時,指針會被移動到freachable隊列。freachable隊列是另一個由垃圾回收器控制的内部資料結構。在freachable隊列中的每一個對象的finalize方法将執行。

垃圾回收之後,托管堆如圖6所示。你可以看到對象b、g、h已經被回收了,因為這幾個對象沒有finalize方法。然而對象e、i、j還沒有被回收掉,因為他們的finalize方法還沒有執行。

.Net 垃圾回收機制原理(一)

圖5

垃圾回收後的托管堆

程式運作時會有一個專門的線程負責調用freachable隊列中對象的finalize方法。當freachable隊列為空時,這個線程會休眠,當隊列中有對象時,線程被喚醒,移除隊列中的對象,并調用它們的finalize方法。是以在執行finalize方法時不要企圖通路線程的local

storage。

終結隊列(finalization queue)和freachable隊列之間的互動很巧妙。首先讓我告訴你freachable的名字是怎麼來的。f顯然是finalization;在此隊列中的每一個對象都在等待執行他們的finalize方法;reachable意思是這些對象來了。另一種說法,freachable隊列中的對象被認為是跟對象,就像是全局變量或靜态變量。是以,如果一個對象在freachable隊列中,那麼這個對象就不是垃圾。

簡短點說,當一個對象是不可達的,垃圾回收器會認為這個對象是垃圾。那麼,當垃圾回收器将對象從終結隊列移動到freachable隊列中,這些對象就不再是垃圾了,它們的記憶體也不會回收。從這一點上來講,垃圾回收器已經完成辨別垃圾,一些對象被辨別成垃圾又被重新認為成非垃圾對象。垃圾回收器回收壓縮記憶體,清空freachable隊列,執行隊列中每一個對象的finalize方法。

.Net 垃圾回收機制原理(一)

圖6 再次執行垃圾回收後的托管堆

再次出發垃圾回收之後,實作finalize方法的對象才被真正的回收。這些對象的finalize方法已經執行過了,freachable隊列清空了。

垃圾回收讓對象複活

在前面部分我們已經說了,當程式不使用某個對象時,這個對象會被回收。然而,如果對象實作了finalize方法,隻有當對象的finalize方法執行之後才會認為這個對象是可回收對象并真正回收其記憶體。換句話說,這類對象會先被辨別為垃圾,然後放到freachable隊列中複活,然後執行finalize之後才被回收。正是finalize方法的調用,讓這種對象有機會複活,我們可以在finalize方法中讓某個對象強引用這個對象;那麼垃圾回收器就認為這個對象不再是垃圾了,對象就複活了。

如下複活示範代碼:

在這種情況下,當對象的finalize方法執行之後,對象被application的靜态字段objholder強引用,成為根對象。這個對象就複活了,而這個對象引用的對象也就複活了,但是這些對象的finalize方法可能已經執行過了,可能會有意想不到的錯誤發生。

事實上,當你設計自己的類型時,對象的終結和複活有可能完全不可控制。這不是一個好現象;處理這種情況的常用做法是在類中定義一個bool變量來表示對象是否執行過了finalize方法,如果執行過finalize方法,再執行其他方法時就抛出異常。

現在,如果有其他的代碼片段又将application.objholder設定為null,這個對象變成不可達對象。最終垃圾回收器會把對象當成垃圾并回收對象記憶體。請注意這一次對象不會出現在finalization隊列中,它的finalize方法也不會再執行了。

複活隻有有限的幾種用處,你應該盡可能避免使用複活。盡管如此,當使用複活時,最好重新将對象添加到終結隊列中,gc提供了靜态方法reregisterforfinalize方法做這件事:

如下代碼:

當對象複活時,重新将對象添加到複活隊列中。需要注意的時如果一個對象已經在終結隊列中,然後又調用了gc.reregisterforfinalize(obj)方法會導緻此對象的finalize方法重複執行。

垃圾回收機制的目的是為開發人員簡化記憶體管理。

下一篇我們談一下弱引用的作用,垃圾回收中的“代”,多線程中的垃圾回收和與垃圾回收相關的性能計數器。