C#托管堆和垃圾回收(GC)


一、基礎

首先,為了深入了解垃圾回收(GC),我們要了解一些基礎知識:

  • CLR:Common Language Runtime,即公共語言運行時,是一個可由多種面向CLR的編程語言使用的“運行時”,包括內存管理、程序集加載、安全性、異常處理和線程同步等核心功能。
  • 托管進程中的兩種內存堆:
    • 托管堆:CLR維護的用於管理引用類型對象的堆,在進程初始化時,由CLR划出一個地址空間區域作為托管堆。當區域被非垃圾對象填滿后,CLR會分配更多的區域,直到整個進程地址空間(受進程的虛擬地址空間限制,32位進程最多分配1.5GB,而64位最多可分配8TB)被填滿。
    • 本機堆:由名為VirtualAlloc的Windows API分配的,用於非托管代碼所需的內存。
  • NextObjPtr:CLR維護的一個指針,指向下一個對象在堆中的分配位置。初始為地址空間區域的基地址。
  • CLR將對象分為大對象和小對象,兩者分配的地址空間區域不同。我們下方的講解更關注小對象。
    • 大對象:大於等於85000字節的對象。“85000”並非常數,未來可能會更改。
    • 小對象:小於85000字節 的對象。

然后明確幾個前提:

  • CLR要求所有引用類型對象都從托管堆分配。
  • C#是運行於CLR之上的。

C#new一個新對象時,CLR會執行以下操作:

  1. 計算類型的字段(包括從基類繼承的字段)所需的字節數。
  2. 加上對象開銷所需的字節數。每個對象都有兩個開銷字段:類型對象指針和同步塊索引,32位程序為8字節,64位程序為16字節。
  3. CLR檢查托管堆是否有足夠的可用空間,如果有,則將對象放入NextObjPtr指向的地址,並將對象分配的字節清零。接着調用構造器,對象引用返回之前,NextObjPtr加上對象真正占用的字節數得到下一個對象的分配位置。

弄清楚以上知識點后,我們繼續來了解CLR是如何進行“垃圾回收”的。

二、垃圾回收的流程

我們先來看垃圾回收的算法與主要流程:
算法:引用跟蹤算法。因為只有引用類型的變量才能引用堆上的對象,所以該算法只關心引用類型的變量,我們將所有引用類型的變量稱為
主要流程:
1.首先,CLR暫停進程中的所有線程。防止線程在CLR檢查期間訪問對象並更改其狀態。
2.然后,CLR進入GC的標記階段。
 a. CLR遍歷堆中的對象(實際上是某些代的對象,這里可以先認為是所有對象),將同步塊索引字段中的一位設為0,表示對象是不可達的,要被刪除。
 b. CLR遍歷所有,將所引用對象的同步塊索引位設為1,表示對象是可達的,要保留。
3.接着,CLR進入GC的碎片整理階段。
 a. 將可達對象壓縮到連續的內存空間(大對象堆的對象不會被壓縮)
 b. 重新計算所引用對象的地址。
4.最后,NextObjPtr指針指向最后一個可達對象之后的位置,恢復應用程序的所有線程。

三、垃圾回收的具體細節

CLR的GC是基於代的垃圾回收器,它假設:

  • 對象越新,生存期越短
  • 對象越老,生存期越長
  • 回收堆的一部分,速度快於回收整個堆

托管堆最多支持三代對象:

  • 第0代對象:新構造的未被GC檢查過的對象
  • 第1代對象:被GC檢查過1次且保留下來的對象
  • 第2代對象:被GC檢查大於等於2次且保留下來的對象

第0代回收只會回收第0代對象,第1代回收則會回收第0代和第1代對象,而第2代回收表示完全回收,會回收所有對象。

CLR初始化時,會為第0代和第1代對象選擇一個預算容量(單位:KB)。如下圖,CLR為ABCD四個第0代對象分配了空間,如果創建一個新的對象導致第0代容量超過預算時,CLR會進行GC。

A0 B0 C0(不可達) D0       

GC后的堆如下圖,ABD三個對象提升為第1代對象,此時無第0代對象

A1 B1 D1               

假設程序繼續執行到某個時刻時,托管堆如下,其中FGHIJ為第0代對象

A1 B1 D1(不可達) F0 G0(不可達) H0 I0 J0

根據GC假設的前兩條可知,它會優先檢查第0代對象,那么GC第0代回收后的托管堆如下,FHIJ提升為第1代對象

A1 B1 D1(不可達) F1 H1 I1 J1       

隨着第1代的增加,GC會發現其占用了太多內存,所以會同時檢查第0代和第1代對象,如某個時刻的托管堆如下,其中K為第0代對象

A1 B1 D1(不可達) F1 H1(不可達) I1 J1 K0

GC第1代回收后的托管堆如下,其中ABFIJ都為第2代對象,K為第1代對象。

A2 B2 F2 I2 J2 K1                 

還有一些額外的規則需要注意:

  • 在進行第1代回收之前,一般都已經對第0代對象回收了好幾次了。
  • 如果對象提升到了第2代,它會長期保持存活,基本上只有當GC進行完全垃圾回收(包括0、1、2代的對象)時才會進行回收。
  • 如果GC回收第0代時發現回收了大量內存,則會縮減第0代的預算,這意味着GC更頻繁,但做的事情也減少了;反之,如果發現沒有多少內存被回收,就會增大第0代的預算,這意味着GC次數更少,但每次回收的內存相對要多。對於第1代和第2代對象來說,也是如此。
  • 如果回收后發現仍然沒有得到足夠的內存且無法增大預算,GC就會執行一次完全垃圾回收,如果還不夠,就會拋出OutOfMemoryException異常。

四、何時進行垃圾回收

  • 應用程序new一個對象時,CLR發現沒有足夠的第0代對象預算來分配該對象時
  • 代碼顯式調用System.GC.Collect()方法時。注意不要濫用該方法
  • Windows報告低內存情況時
  • CLR正在卸載AppDomain時。會回收該AppDomain的所有代對象
  • CLR正在關閉時。CLR在進程正常終止(而不是通過任務管理器等外部終止)時關閉,會回收進程中的所有對象。

五、垃圾回收模式

CLR啟動時,會選擇一個GC主模式,該模式不會更改,直到進程終止。

  • 工作站:默認的,針對客戶端應用程序進行優化。GC造成的時延很低,不會導致UI線程出現明顯的假死狀態
  • 服務器:針對服務器端應用程序進行優化,主要是優化吞吐量和資源利用。

可以在配置文件中告訴CLR使用服務器回收模式:

<configuration>
    <runtime>
        <gcServer enabled="true"/>
    </runtime>
</configuration>

另外,GC還支持兩種子模式:並發(默認)和非並發。主要區別在於並發模式中GC有一個額外的后台線程,它能在應用程序運行時並發標記對象。可以在配置文件中告訴CLR不要使用並發回收模式:

<configuration>
    <runtime>
        <gcConcurrent enabled="false"/>
    </runtime>
</configuration>

當然,你也可以通過GCSetting類的GCLatencyMode屬性對垃圾回收進行某些控制(在你沒有完全了解影響的情況下,強烈建議不要更改):

模式 說明
Batch 關閉並發GC,.net framework 版本服務器模式默認值
Interactive 打開並發GC,工作站模式與 .net core 版本服務器模式的默認值
LowLatency 在短期的、時間敏感的操作中(如動畫繪制)使用這個低延遲模式,該模式會盡力阻止第2代垃圾回收,因為花費時間較多,只有當內存過低時才會回收第2代。
SustainedLowLatency 這個低延遲模式不會導致長時間的GC暫停,該模式會盡力阻止非並發GC線程對第2代垃圾回收(但是允許后台GC線程對其的回收),只有當內存過低時才會阻塞回收第2代,適用於需要迅速響應的應用程序(如股票等)。

另外,還有一個模式叫做NoGCRegion,用於在程序執行關鍵路徑時將GC線程掛起。但是你不能將該值直接賦值給GCLatencyMode屬性,要通過調用System.GC.TryStartGCRegion方法才可以,並調用System.GC.EndGCRegion方法結束。

六、注意事項

  • 靜態字段引用的對象會一直存在,直到用於加載類型的AppDomain卸載為止
  • 由於碎片整理的開銷相對較大,因此GC在划算時才會進行碎片整理,並非每次都會執行。
  • 大對象始終為第2代,而且目前版本GC不會壓縮大對象,因為移動代價過高。
  • 第0代和第1代總是位於同一個內存段,而第2代可能跨越多個內存段。

七、特殊的Finalize(終結器)

包含本機資源的類型被GC時,GC會回收對象在托管堆中使用的內存。但這樣會造成本機資源的泄漏,為了處理這種情況,CLR提供了稱為終結的機制——允許對象在判定為垃圾之后,但在對象內存被回收前執行一些代碼。在C#中的表示如下:

class SomeType
{
    // 這是一個 Finalize 方法
    ~SomeType() { }
}

其生成的IL代碼為:

可以看到,C#編譯器實際是在模塊的元數據中生成了名為Finalizeprotected override方法,並且方法主體的代碼被放置在try塊中,並在finally塊中調用base.Finalize(本例調用了Object的終結器)。

那么,終結的內部是如何工作的呢?

  1. new新對象時,如果該對象的類型定義了Finalize方法,那么在該類型的實例構造器被調用之前,會將指向該對象的指針放到一個終結列表中,該列表由GC內部控制。
  2. 當可終結對象被回收時,會將引用從終結列表移動到freachable隊列中,該隊列由GC內部控制。
  3. CLR會啟用一個特殊的高優先級線程來專門調用Finalze方法。freachable隊列為空時,該線程將睡眠;但一旦隊列中有記錄項出現,線程就會被喚醒,將每一項都從freachable隊列中移除,並調用每個對象的Finalize方法。

如果類型的Finalize方法是從System.Object繼承的,CLR就不認為該對象是“可終結”的,只有當類型重寫了ObjectFinalize方法時,才會將類型及其派生類型的對象視為“可終結”的。

注意,除非有必要,否則應盡量避免定義終結器。原因如下:

  • 可終結對象在回收時,必須保證存活,這就可能導致其被提升為另一代,生存期延長,導致內存無法及時回收。另外,其內部引用的所有對象也必須保證都存活,一些被認為是垃圾的對象在可終結對象回收后也無法直接回收,直到下一次(甚至多次)GC時才會被回收。
  • Finalize 方法在GC完成后才會執行,而GC的執行時機無法控制,也就導致該方法的執行時間也無法控制。
  • Finalize 方法中不要訪問其他可終結對象,因為CLR無法保證多個 Finalize 方法的執行順序。如果訪問了已終結的對象,Finalize 方法拋出未處理的異常,導致進程終止,無法捕捉異常。

在實際項目開發中,想要避免釋放本機資源基本不可能,但是我們可以通過規范代碼來規避異常,這就需要用到IDisposable接口了。示例代碼如下:

public class MyResourceHog : IDisposable
{
    //標識資源是否已被釋放
    private bool _hasDisposed = false;

    public void Dispose()
    {
        Dispose(true);
        //阻止GC調用 Finalize
        GC.SuppressFinalize(this);
    }

    /// <summary>
    /// 如果類本身包含非托管資源,才需要實現 Finalize
    /// </summary>
    ~MyResourceHog()
    {
        Dispose(false);
    }

    protected virtual void Dispose(bool isDisposing)
    {
        if (_hasDisposed) return;

        //表明由 Dispose 調用
        if (isDisposing)
        {
            //釋放托管資源
        }
        //釋放非托管資源。無論 Dispose 還是 Finalize 調用,都應該釋放非托管資源

        _hasDisposed = true;
    }
}

public class DerivedResourceHog : MyResourceHog
{
    //基類與繼承類應該使用各自的標識,防止子類設置為true時無法執行基類
    private bool _hasDisposed = false;

    protected override void Dispose(bool isDisposing)
    {
        if (_hasDisposed) return;

        if (isDisposing)
        {
            //釋放托管資源
        }
        //釋放非托管資源

        base.Dispose(isDisposing);

        _hasDisposed = true;
    }
}


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM