淺析CLR的GC(垃圾回收器)

 

文章目錄:

  1. 瞭解託管堆和GC
  2. GC高效的處理方式—代
  3. 特殊類型的清理
  4. 手動監控和控制對象生命週期

1、瞭解託管堆和GC

  在面向對象環境中,每一個類型都代表了一種資源。我們要使用這些資源,就要爲這些代表資源的類型分配內存。在C#中,我們一般使用new關鍵字來完成。訪問資源包括以下幾步:

    • 使用new操作符爲類型分配內存(這個過程調用了IL指令newobj)
    • 初始化內存,設置資源的初始狀態,來讓這個資源可用(類型的實力構造器負責初始化類型狀態)
    • 訪問類型成員使用資源
    • 摧毀資源狀態進行清理
    • 釋放內存

  在C#中,我們的操作時基於CLR來完成的,我們所有對象都是從託管堆對來分配內存。當進程初始化(我們的程序)時,CLR會畫出一個地址空間區域來作爲託管堆。同時,CLR會維護一個指針NewObjPtr,這個指針指向下一個對象在託管堆中分配的地址。當這個區域被非垃圾的對象填滿後,CLR會分配更多的區域,這個過程將一直重複,直至整個進程的地址空間都被填滿。32位進程的地址空間爲1.5GB,64位進程爲8TB。(這裏順便提一下值類型的生命週期,值類型對象分配在線程棧上,當離開作於域時,自動銷燬。)

  C# new 操作符,會讓CLR執行以下步驟:

    • 計算類型字段的所需字節數(這裏的字段是所有字段,包括基類繼承的)
    • 添加創建對象的額外所需字節數 (每一個對象被初始化時,會創建類型對象指針和同步塊索引,32位程序爲8字節,64位程序16字節)
    • CLR檢查區域中空間是否足夠,假如足夠,在NewObjPtr指針位置放入對象。這時候,對象分配的字節會被清零,然後調用類型的構造器(計算字節,NewObjPtr指針會指向舊的位置加上這個字節的位置,爲下一個對象分配空間時候的位置),new操作符返回對象的引用。(例如在託管堆已經由A,B的情況下新構建了一個C)

  關於GC

  當程序調用new操作符創建對象時候,假如沒有足夠的地址空間來分配該對象,CLR就會執行垃圾回收(調用GC)。CLR採用引用跟蹤算法,這種算法只關心引用類型的變量,避免了類型循環導致對象不能被回收的問題。引用類型包括類的靜態和實例字段,方法的局部變量和參數。所有的引用類型被稱之爲根。

  CLR開始執行GC時候,會暫停進程中所有線程(防止CLR執行檢查期間對象的狀態被更改);然後CLR遍歷託管堆中的所有對象,將同步塊索引中字段的一位設置爲0(標識所有對象都應該刪除),然後CLR檢查所有活動根,這些根引用了哪些對象。

  任何一個根引用了堆的對象,CLR會將對象的同步塊索引的位設置位1。然後再檢查對象中的跟,標記它們引用的對象。假如在遍歷過程中發現對象被標記,就跳過這個對象,不再重新檢查這個對象的字段,這樣避免了循環引用。

  應用程序中所有的活動跟都檢查完畢以後,這時候堆中的對象要麼被標記了(稱之爲可達,由活動跟在引用),要麼沒有被標記(相應稱爲不可達)。CLR將不可達的對象內存回收。將可達對象進行內存整理,使對象內存在託管堆中是連續的(壓縮過程中CLR要從每個根減去所引用對象在堆中的偏移字節數),如下圖所示:

2、GC高效的處理方式—代

  CLR的垃圾回收基於代。

  同時GC回收垃圾時,做出了下面幾點假設(可以先記下,從下文中體會)

  • 對象越新,生存期越短
  • 對象越來,生存期越長
  • 回收堆一部分內存,比回收整個堆要快   

 

  解釋下代:託管進程中有兩種內存堆,分別是本機堆託管堆,CLR在託管堆上面爲.net 的所有對象分配內存(託管堆又稱爲GC堆)。託管堆又分爲兩種,小對象堆大對象堆(LOH),小對象堆用來分配常用的資源對象內存(如類,數組等等),小對象堆的內存段進一步劃分爲3代,0代,1代,2代。(大對象堆用來分配一些大對象和非託管資源,我們後文中專門來解釋)

 

   託管堆初始化時,不包含任何對象,當我們聲明一個對象時,這個對象稱爲第0代對象。也就是說,第0代對象就是那些新構造的對象,而且垃圾回收器沒有檢查過的對象。例如下圖中,託管堆中分配了A,B,C,D,E5個對象,它們就是第0代對象。

  接下來隨着我們不停地分配對象,第0代的堆內存使用完畢,且這隨着程序的流轉,C和E變得不可達,當我們分配下一個內存F時,CLR就會執行一次垃圾回收。此時,C,E對象內存被回收掉,我的的ABD對象從第0代對象變爲第1代對象。這時候,垃圾回收結束,第0代不包含任何對象。如下圖所示:

  接下來隨着程序的運行,又在0代中分配了對象F G H I J K,1代對象中B變得不可達。接下來給對象L分配內存時內存不足,將執行垃圾回收。CLR會爲第0代對象和第 1代對象選擇預算,由於第一代中的佔用內存遠少於預算,所以垃圾回收期只檢查第0代的對象(基越新的對象獲得越短),因爲第0代對象包含更多的垃圾可能性更大,可以回收更多的內存。忽略了第一代中的對象,所以加快了垃圾回收速度。

 

 

  隨着垃圾回收的不斷進行,第1代的內存將不斷增加,當第1代對象的內存增長到佔用了佔用了全部預算(0代給新對象分配內存就要進行GC),此時,會進行第1代的垃圾回收,倖存下來的對象被分配的第2代中去。託管堆只支持3代(0,1,2)。超過85000字節的對象稱之爲大對象,直接由第2代分配內存。

  代給GC帶來的性能提升主要體現在不必遍歷託管堆中的每一個對象。如果根或者對象引用了老一代的某個對象,垃圾回收期就可以忽略老對象內部所有引用(CLR的特徵,引用跟蹤算法,同步索引塊中的一位標識),在更短的時間內構造好可達對象圖。假如老對象字段引用了新對象,則由JIT編輯器內部的一個機制(單獨解釋)讓垃圾回收期跳過。微軟官方性能測試,0代執行一次GC,花費時間不少過1毫秒。

  • JIT的機制是在對象引用字段發生變化時候,設置一個對應位標誌。這樣,下一次GC回收資源內存時候,會知道上一次GC過後,哪些老對象被寫入位標誌,這樣,只有位標誌發生變化(也就是老對象字段發生變化)時候,才檢查老對象是否引用第0代對象。

3、特殊類型的清理

  特殊類型:大多數對象只要分配內存就可以使用。但是,還有部分對象需要分配本機資源(例如文件,網絡連接,套接字,互斥體),我們稱這部分對象爲特殊類型的資源。

  特殊類型的回收過程和特點:包含本機資源的類型被GC時,GC在回收內存之前,需要將本機資源終結(Finalization)。當CLR判定一個特殊類型的對象不可達時,對象將終結自己,釋放包裹的本機資源,然後由GC回收其內存。

  Object基類型定義了虛方法Finalize,GC判定對象時垃圾後,調用對象的Finalize方法,這個方法一般以析構函數的形式出現。(ILSpy 反編譯後的析構函數代碼爲protected override Finalize)。

   特殊類型注意事項:

    1. Finalize執行在GC之後,所以特殊類型的對象不是馬上被GC回收,因爲Finalize方法可能要訪問對象字段。這可能使對象提升到另一級別的代,增加內存耗用。所以,儘量避免引用類型的字段定義爲可終結對象。
    2. Finalize方法執行時無順序的。所以不要在Finalize方法中訪問定義了其他Finalize方法的類型,因爲另一個類型對象可能已被終結。
    3. CLR用一個特殊的、高優先級專用線程調用Finalize方法避免死鎖。
    4. 自定義包含了本機資源的託管類型時要繼承自SafeHandle(派生自它保證本機資源在GC時被釋放)。

  控制包裝了本機資源類型對象的生存期:

      例如這裏我們要往D盤的1.txt中寫入一部分文本,然後寫完後想把這個文件刪除,此時就會報 “System.IO.IOException:“文件“d:\1.txt”正由另一進程使用,因此該進程無法訪問此文件。”這樣一個異常,這是因爲本機資源未被釋放(Finalize)。假如我們想控制包裝本機資源的類型對象的生命週期,就要實現IDispose接口。(如果類型對象的其中一個字段實現了這個接口,那麼這個類型也就實現了Dispose模式。)然後我們修改我們的代碼,成功刪除文件。

   終結的內部實現原理:

    包裝了本機資源的對象被回收時,會調用Finalize方法。

    包裝了本機資源的對象創建的時候(定義了Finalize方法),在從堆中分配內存前,會將這個對象的指針添加到一個終結列表(由GC控制的內部數據結構)中。這個列表中的每一項,都指向一個定義了Finalize方法的對象,回收這些對象內存之前應該先調用它的Finalize方法(這裏注意,雖然Object也定義了Finalize方法,但是CLR會忽略它,只有重寫了Finalize方法的類型對象纔會加入到終結列表)。如下圖所示,C,E,F,I,J是定義了Finalize方法的類型對象,指向它們的指針被加入到終結列表中:

    

    垃圾回收開始進行,B,E,G,H,I,J被判定爲垃圾,這時候垃圾回收器會掃描終結列表來查找這些對象的引用(這裏找到了E,I,J),然後把這些引用從終結列表中移除,附加到freachable隊列(也是GC的一個內部數據結構)。在freachable隊列中的每一個引用都代表即將進行Finalize調用的對象。經歷過一輪GC後,堆內存如下所示:

    

    CLR使用一個高優先級的,專用的線程來調用Finalize方法,這個線程避免潛在的線程同步問題。當freachable隊列爲空時候,這個線程將休眠,freachable隊列出現記錄項,將喚醒這個線程。這樣來看,包裝了本機資源的託管對象至少要進行兩次GC才能回收它的內存,第一次由專用線程來執行Finalize方法,第二次才由GC回收這個對象的內存(大於2次是因爲這些對象可能被提高到老的一代)。

4、手動監控和控制對象生命週期

  CLR爲每一個AppDomain都提供了一個GC Handle table,允許程序監視或者控制對象的生命週期。這個表中的每一條記錄項都包含託管堆中一個對象的引用監視控制對象標誌。這裏注意一個類GCHandle和一個枚舉對象 GCHandleType。

  GCHandle調用Alloc方法時候,會掃描AppDomain的GC Handle table,查找一個可用的記錄項存儲對象的生命週期並且傳回給對象引用。GChandle的Target屬性,返回句柄表示的對象,如下圖所示:

  GC發生時候會使用GC Handle table,首先,GC將所有對象標識爲將要回收,掃描GC Handle table,所有GCHandleType爲Normal和Pinned對象標識爲根;然後查找GCHandleType爲Weak的項,如果引用了未標記的對象,那麼這個對象就是垃圾,且把這個項賦值爲null;GC繼續掃描中介列表,將無引用標識對象的引用放入freachable隊列;GC再掃描GC Handle table,查找GCHandleType 爲WeakTrackResurrection的記錄想,這些記錄想引用了未標記的對象(freachable隊列中)變爲垃圾,這些記錄項賦值爲Null。最後GC對內存進行壓縮。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章