託管堆和垃圾回收(GC)

xiaoxiaotank發表於2019-07-16

一、基礎

首先,為了深入瞭解垃圾回收(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指標指向最後一個可達物件之後的位置,恢復應用程式的所有執行緒。

託管堆和垃圾回收(GC)

三、垃圾回收的具體細節

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程式碼為:
託管堆和垃圾回收(GC)

可以看到,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;
    }
}

相關文章