1. 程式人生 > >深入理解C#中的IDisposable接口

深入理解C#中的IDisposable接口

了解 重置 ptr lar 連接 itl 正常 ring 寫在前面

寫在前面

在開始之前,我們需要明確什麽是C#(或者說.NET)中的資源,打碼的時候我們經常說釋放資源,那麽到底什麽是資源,簡單來講,C#中的每一種類型都是一種資源,而資源又分為托管資源和非托管資源,那這又是什麽?!

托管資源:由CLR管理分配和釋放的資源,也就是我們直接new出來的對象;

非托管資源:不受CLR控制的資源,也就是不屬於.NET本身的功能,往往是通過調用跨平臺程序集(如C++)或者操作系統提供的一些接口,比如Windows內核對象、文件操作、數據庫連接、socket、Win32API、網絡等。

我們下文討論的,主要也就是非托管資源的釋放,而托管資源.NET的垃圾回收已經幫我們完成了。其實非托管資源有部分.NET的垃圾回收也幫我們實現了,那麽如果要讓.NET垃圾回收幫我們釋放非托管資源,該如何去實現。

如何正確的顯式釋放資源

假設我們要使用FileStream,我們通常的做法是將其using起來,或者是更老式的try…catch…finally…這種做法,因為它的實現調用了非托管資源,所以我們必須用完之後要去顯式釋放它,如果不去釋放它,那麽可能就會造成內存泄漏。

這聽上去貌似很簡單,但我們編碼的時候可能很多時候會忽略掉釋放資源這個問題,.NET的垃圾回收又如何幫我們釋放非托管資源,接下來我們一探究竟吧,一個標準的釋放非托管資源的類應該去實現IDisposable接口:

public class MyClass:IDisposable
{
    
/// <summary>執行與釋放或重置非托管資源關聯的應用程序定義的任務。</summary> public void Dispose() { } }

我們實例化的時候就可以將這個類using起來:

using(var mc = new MyClass())
{
}

看上去很簡單嘛,但是,要是就這麽簡單的話,也沒有這篇文章的必要了。如果要實現IDisposable接口,我們其實應該這樣做:

  1. 實現Dispose方法;

  2. 提取一個受保護的Dispose虛方法,在該方法中實現具體的釋放資源的邏輯;

  3. 添加析構函數;

  4. 添加一個私有的bool類型的字段,作為釋放資源的標記

接下來,我們來實現這樣的一個Dispose模式:

public class MyClass : IDisposable
{
    /// <summary>
    /// 模擬一個非托管資源
    /// </summary>
    private IntPtr NativeResource { get; set; } = Marshal.AllocHGlobal(100);
    /// <summary>
    /// 模擬一個托管資源
    /// </summary>
    public Random ManagedResource { get; set; } = new Random();
    /// <summary>
    /// 釋放標記
    /// </summary>
    private bool disposed;
    /// <summary>
    /// 為了防止忘記顯式的調用Dispose方法
    /// </summary>
    ~MyClass()
    {
        //必須為false
        Dispose(false);
    }
    /// <summary>執行與釋放或重置非托管資源關聯的應用程序定義的任務。</summary>
    public void Dispose()
    {
        //必須為true
        Dispose(true);
        //通知垃圾回收器不再調用終結器
        GC.SuppressFinalize(this);
    }
    /// <summary>
    /// 非必需的,只是為了更符合其他語言的規範,如C++、java
    /// </summary>
    public void Close()
    {
        Dispose();
    }
    /// <summary>
    /// 非密封類可重寫的Dispose方法,方便子類繼承時可重寫
    /// </summary>
    /// <param name="disposing"></param>
    protected virtual void Dispose(bool disposing)
    {
        if (disposed)
        {
            return;
        }
        //清理托管資源
        if (disposing)
        {
            if (ManagedResource != null)
            {
                ManagedResource = null;
            }
        }
        //清理非托管資源
        if (NativeResource != IntPtr.Zero)
        {
            Marshal.FreeHGlobal(NativeResource);
            NativeResource = IntPtr.Zero;
        }
        //告訴自己已經被釋放
        disposed = true;
    }
}

如果不是虛方法,那麽就很有可能讓開發者在子類繼承的時候忽略掉父類的清理工作,所以,基於繼承體系的原因,我們要提供這樣的一個虛方法。

其次,提供的這個虛方法是一個帶bool參數的,帶這個參數的目的,是為了釋放資源時區分對待托管資源和非托管資源,而實現自IDisposable的Dispose方法調用時,傳入的是true,而終結器調用的時候,傳入的是false,當傳入true時代表要同時處理托管資源和非托管資源;而傳入false則只需要處理非托管資源即可。

那為什麽要區別對待托管資源和非托管資源?在這個問題之前,其實我們應該先弄明白:托管資源需要手動清理嗎?不妨將C#的類型分為兩類:一類實現了IDisposable,另一類則沒有。前者我們定義為非普通類型,後者為普通類型。非普通類型包含了非托管資源,實現了IDisposable,但又包含有自身是托管資源,所以不普通,對於我們剛才的問題,答案就是:普通類型不需要手動清理,而非普通類型需要手動清理。

而我們的Dispose模式設計思路在於:如果顯式調用Dispose,那麽類型就該按部就班的將自己的資源全部釋放,如果忘記了調用Dispose,那就假定自己的所有資源(哪怕是非普通類型)都交給GC了,所以不需要手動清理,所以這就理解為什麽實現自IDisposable的Dispose中調用虛方法是傳true,終結器中傳false了。

同時我們還註意到了,虛方法首先判斷了disposed字段,這個字段用於判斷對象的釋放狀態,這意味著多次調用Dispose時,如果對象已經被清理過了,那麽清理工作就不用再繼續。

但Dispose並不代表把對象置為了null,且已經被回收徹底不存在了。但事實上,對象的引用還可能存在的,只是不再是正常的狀態了,所以我們明白有時候我們調用數據庫上下文有時候為什麽會報“數據庫連接已被釋放”之類的異常了。

所以,disposed字段的存在,用來表示對象是否被釋放過。

如果對象包含非托管類型的字段或屬性的類型應該是可釋放的

這句話讀起來可能有點繞啊,也就是說,如果對象的某些字段或屬性是IDisposable的子類,比如FileStream,那麽這個類也應該實現IDisposable。

之前我們說過C#的類型分為普通類型和非普通類型,非普通類型包含普通的自身和非托管資源。那麽,如果類的某個字段或屬性的類型是非普通類型,那麽這個類型也應該是非普通類型,應該也要實現IDisposable接口。

舉個栗子,如果一個類型,組合了FileStream,那麽它應該實現IDisposable接口,代碼如下:

public class MyClass2 : IDisposable
{
    ~MyClass2()
    {
        Dispose(false);
    }
    public FileStream FileStream { get; set; }
    /// <summary>
    /// 釋放標記
    /// </summary>
    private bool disposed;
    /// <summary>執行與釋放或重置非托管資源關聯的應用程序定義的任務。</summary>
    public void Dispose()
    {
        Dispose(true);
        GC.SuppressFinalize(this);
    }
    /// <summary>
    /// 非密封類可重寫的Dispose方法,方便子類繼承時可重寫
    /// </summary>
    /// <param name="disposing"></param>
    protected virtual void Dispose(bool disposing)
    {
        if (disposed)
        {
            return;
        }
        //清理托管資源
        if (disposing)
        {
            //todo
        }
        //清理非托管資源
        if (FileStream != null)
        {
            FileStream.Dispose();
            FileStream = null;
        }
        //告訴自己已經被釋放
        disposed = true;
    }
}

因為類型包含了FileStream類型的字段,所以它包含了非普通類型,我們仍舊需要為這個類型實現IDisposable接口。

及時釋放資源

可能很多人會問啊,GC已經幫我們隱式的釋放了資源,為什麽還要主動地釋放資源,我們先來看一個例子:

private void button6_Click(object sender, EventArgs e)
{
    var fs = new FileStream(@"C:\1.txt",FileMode.OpenOrCreate,FileAccess.ReadWrite);
}
private void button7_Click(object sender, EventArgs e)
{
    GC.Collect();
}

上面的代碼在WinForm程序中,單擊按鈕6,打開一個文件流,單擊按鈕7執行GC回收所有“代”(下文將指出代的概念)的垃圾,如果連續單擊兩次按鈕6,將會拋異常:

技術分享圖片

如果單擊按鈕6再單擊按鈕7,然後再單擊按鈕6則不會出現這個問題。

我們來分析一下:在單擊按鈕6的時候打開一個文件,方法已經執行完畢,fs已經沒有被任何地方引用了,所以被標記為了垃圾,那麽什麽時候被回收呢,或者GC什麽時候開始工作?微軟官方的解釋是,當滿足以下條件之一時,GC才會工作:

  1. 系統具有較低的物理內存;

  2. 由托管堆上已分配的對象使用的內存超出了可接受的範圍;

  3. 手動調用GC.Collect方法,但幾乎所有的情況下,我們都不必調用,因為垃圾回收器會自動調用它,但在上面的例子中,為了體驗一下不及時回收垃圾帶來的危害,所以手動調用了GC.Collect,大家也可以仔細體會一下運行這個方法帶來的不同。

GC還有個“代”的概念,一共分3代:0代、1代、2代。而這三代,相當於是三個隊列容器,第0代包含的是一些短期生存的對象,上面的例子fs就是個短期對象,當方法執行完後,fs就被丟到了GC的第0代,但不進行垃圾回收,只有當第0代滿了的時候,系統認為此時滿足了低內存的條件,才會觸發垃圾回收事件。所以我們永遠不知道fs什麽時候被回收掉,在回收之前,它實際上已經沒有用處了,但始終占著系統資源不放(占著茅坑不拉屎),這對系統來說是種極大的浪費,而且這種浪費還會幹擾整個系統的運行,比如我們的例子,由於它始終占著資源,就導致了我們不能再對文件進行訪問了。

不及時釋放資源還會帶來另外的一個問題,雖然之前我們說實現IDisposable接口的類,GC可以自動幫我們釋放,但這個過程被延長了,因為它不是在一次回收中完成所有的清理工作,即使GC自動幫我們釋放了,那也是先調用FileStream的終結器,在下一次的垃圾回收時才會真正的被釋放。

了解到危害後,我們在打碼過程中,如果我們明知道它應該被using起來時,一定要using起來:

using (var fs = new FileStream(@"C:\1.txt", FileMode.OpenOrCreate, FileAccess.ReadWrite))
{
}

需不需要將不再使用的對象置為null

在上文的內容中,我們都提到要釋放資源,但並沒有說明需不需要將不再使用的對象置為null,而這個問題也是一直以來爭議很大的問題,有人認為將對象置為null能讓GC更早地發現垃圾,也有人認為這並沒有什麽卵用。其實這個問題首先是從方法的內部被提起的,為了更好的說明這個問題,我們先來段代碼來檢驗一下:

private void button6_Click(object sender, EventArgs e)
{
    var mc1 = new MyClass() { Name = "mc1" };
    var mc2 = new MyClass() { Name = "mc2" };
    mc1 = null;
}
private void button7_Click(object sender, EventArgs e)
{
    GC.Collect();
}
public class MyClass
{
    public string Name { get; set; }
    ~MyClass()
    {
        MessageBox.Show(Name + "被銷毀了");
    }
}

單擊按鈕6,再單擊按鈕7,我們發現:

沒有置為null的mc2會先被釋放,雖然它在mc1被置為null之後;

在CLR托管的應用程序中,有一個“根”的概念,類型的靜態字段、方法參數以及局部變量都可以被作為“根”存在(值類型不能作為“根”,只有引用類型才能作為“根”)。

上面的代碼中,mc1和mc2在代碼運行過程中分別會在內存中創建一個“根”。在垃圾回收的過程中,GC會沿著線程棧掃描“根”(棧的特點先進後出,也就是mc2在mc1之後進棧,但mc2比mc1先出棧),檢查完畢後還會檢查所有引用類型的靜態字段的集合,當檢查到方法內存在“根”時,如果發現沒有任何一個地方引用這個局部變量的時候,不管你是否已經顯式的置為null這都意味著“根”已經被停止,然後GC就會發現該根的引用為空,就會被標記為可被釋放,這也代表著mc1和mc2的內存空間可以被釋放,所以上面的代碼mc1=null沒有任何意義(方法的參數變量也是如此)。

其實.NET的JIT編譯器是一個優化過的編譯器,所以如果我們代碼裏面將局部變量置為null,這樣的語句會被忽略掉:

s=null;

如果我們的項目是在Release配置下的,上面的代碼壓根就不會被編譯到dll,正是由於我們上面的分析,所以很多人都會認為將對象賦值為null完全沒有必要,但是,在另一種情況下,就完全有必要將對象賦值為null,那就是靜態字段或屬性,但這斌不意味著將對象賦值為null就是將它的靜態字段賦值為null:

private void button6_Click(object sender, EventArgs e)
{
    var mc = new MyClass() { Name = "mc" };
}
private void button7_Click(object sender, EventArgs e)
{
    GC.Collect();
}
public class MyClass
{
    public string Name { get; set; }
    public static MyClass2 MyClass2 { get; set; } = new MyClass2();
    ~MyClass()
    {
        //MyClass2 = null;
        MessageBox.Show(Name + "被銷毀了");
    }
}
public class MyClass2
{
    ~MyClass2()
    {
        MessageBox.Show("MyClass2被釋放");
    }
}

上面的代碼運行我們會發現,當mc被回收時,它的靜態屬性並沒有被GC回收,而我們將MyClass終結器中的MyClass2=null的註釋取消,再運行,當我們兩次點擊按鈕7的時候,屬性MyClass2才被真正的釋放,因為第一次GC的時候只是在終結器裏面將MyClass屬性置為null,在第二次GC的時候才當作垃圾回收了,之所以靜態變量不被釋放(即使賦值為null也不會被編譯器優化),是因為類型的靜態字段一旦被創建,就被作為“根”存在,基本上不參與GC,所以GC始終不會認為它是個垃圾,而非靜態字段則不會有這樣的問題。

所以在實際工作當中,一旦我們感覺靜態變量所占用的內存空間較大的時候,並且不會再使用,便可以將其置為null,最典型的案例就是緩存的過期策略的實現了,將靜態變量置為null這或許不是很有必要,但這絕對是一個好的習慣,試想一個項目中,如果將某個靜態變量作為全局的緩存,如果沒有做過期策略,一旦項目運行,那麽它所占的內存空間只增不減,最終頂爆機器內存,所以,有個建議就是:盡量地少用靜態變量

深入理解C#中的IDisposable接口