深刻理解C#中的IDisposable接口

寫在前面

在開始以前,咱們須要明確什麼是C#(或者說.NET)中的資源,打碼的時候咱們常常說釋放資源,那麼到底什麼是資源,簡單來說,C#中的每一種類型都是一種資源,而資源又分爲託管資源和非託管資源,那這又是什麼?!java

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

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

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

 

如何正確的顯式釋放資源

假設咱們要使用FileStream,咱們一般的作法是將其using起來,或者是更老式的try…catch…finally…這種作法,由於它的實現調用了非託管資源,因此咱們必須用完以後要去顯式釋放它,若是不去釋放它,那麼可能就會形成內存泄漏。c#

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

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

咱們實例化的時候就能夠將這個類using起來:網絡

1
2
3
using (var mc =  new  MyClass())
{
}

看上去很簡單嘛,可是,要是就這麼簡單的話,也沒有這篇文章的必要了。若是要實現IDisposable接口,咱們其實應該這樣作:dom

  1. 實現Dispose方法;socket

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

  3. 添加析構函數;

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
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 ;
     }
}

這裏面每行代碼都有它獨自的含義,文章裏不可能每句話都講解透徹,爲了突出重點,因此接下來就挑出幾個重要的地方逐一解釋咯,固然截止如今,咱們只須要記住:

若是類型須要顯式的釋放資源,那麼就必定要實現IDisposable接口。

實現IDisposable接口其實也是爲了方便使用using這個語法糖,以方便編譯器幫咱們自動生成Dispose的IL代碼:

1
2
3
using (var mc =  new  MyClass())
{
}

就至關於:

1
2
3
4
5
6
7
8
9
10
11
12
MyClass mc =  null ;
try
{
     mc =  new  MyClass();
}
finally
{
     if  (mc !=  null )
     {
         mc.Dispose();
     }
}

若是要同時管理多個相同類型的對象:

1
2
3
using (MyClass mc1= new  MyClass(),mc2= new  MyClass())
{
}

若是類型不一致:

1
2
3
4
5
6
using (var client =  new  HttpClient())
{
     using (var stream = File.Create( "" ))
     {
     }
}
 

爲何須要析構方法?

在以前咱們實現的更標準的Dispose模式中,咱們注意到了,類裏面包含了一個~開頭的析構方法:

1
2
3
4
5
~MyClass()
{
     //必須爲false
     Dispose( false );
}

這個析構方法更規範的說法叫作終結器,它的意義在於,若是咱們忘記了顯式調用Dispose方法,垃圾回收器在掃描內存的時候,會做爲釋放資源的一種補救措施。

爲何加了析構方法就會有這種效果,咱們知道在new對象的時候,CLR會爲對象建立一塊內存空間,一旦對象再也不被引用,就會被垃圾回收器回收掉,對於沒有實現IDisposable接口的類來講,垃圾回收時將直接回收掉這片內存空間,而對於實現了IDisposable接口的類來講,因爲析構方法的存在,在建立對象之初,CLR會將該對象的一個指針放到終結器列表中,在GC回收內存以前,會首先將終結器列表中的指針放到一個freachable隊列中,同時,CLR還會分配專門的內存空間來讀取freachable隊列,並調用對象的終結器,只有在這個時候,對象才被真正的被標識爲垃圾,在下一次垃圾回收的時候纔回收這個對象所佔用的內存空間。

那麼,實現了IDisposable接口的對象在回收時要通過兩次GC才能被真正的釋放掉,由於GC要先安排CLR調用終結器,基於這個特色,若是咱們顯式調用了Dispose方法,那麼GC就不會再進行第二次垃圾回收了,固然,若是忘記了Dispose,也避免了忘記調用Dispose方法形成的內存泄漏。

提示:析構方法是在C++中的一種說法,由於終結器和析構方法二者特色很像,爲了沿襲C++的叫法,稱之爲析構方法也沒有什麼不妥,但它們又不徹底一致,因此微軟後來又肯定它叫終結器。

還有一點咱們也注意到了,若是已經顯式的調用了Dispose方法,那麼隱式釋放資源就再不必運行了,GC的SuppressFinalize方法就是通知GC的這一點:

1
2
3
4
5
6
7
public  void  Dispose()
{
     //必須爲true
     Dispose( true );
     //通知垃圾回收器再也不調用終結器
     GC.SuppressFinalize( this );
}

因此在實現的Dispose方法中先調用咱們正常的資源釋放代碼,再通知GC不要調用終結器了。

 

爲何須要提供一個Dispose虛方法?

咱們注意到了,實現自Idisposable接口的Dispose方法並無作實際的清理工做,而是調用了咱們這個受保護的Dispose虛方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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 ;
}

之因此是虛方法,就是考慮到它若是被其餘類繼承時,子類也實現了Dispose模式,這個虛方法能夠提醒子類,清理的時候要注意到父類的清理工做,即若是子類從新該方法,必須調用base.Dispose方法,假設如今咱們有個子類,繼承自MyClass:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
public  class  MyClassChild : MyClass
{
     /// <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>
     /// <param name="disposing"></param>
     protected  override  void  Dispose( bool  disposing)
     {
         if  (disposed)
         {
             return ;
         }
         //清理託管資源
         if  (disposing)
         {
             if  (ManagedResource !=  null )
             {
                 ManagedResource =  null ;
             }
         }
         //清理非託管資源
         if  (NativeResource != IntPtr.Zero)
         {
             Marshal.FreeHGlobal(NativeResource);
             NativeResource = IntPtr.Zero;
         }
         base .Dispose(disposing);
     }
}

若是不是虛方法,那麼就頗有可能讓開發者在子類繼承的時候忽略掉父類的清理工做,因此,基於繼承體系的緣由,咱們要提供這樣的一個虛方法。

其次,提供的這個虛方法是一個帶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接口,代碼以下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
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已經幫咱們隱式的釋放了資源,爲何還要主動地釋放資源,咱們先來看一個例子:

1
2
3
4
5
6
7
8
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起來:

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

需不須要將再也不使用的對象置爲null

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
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:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
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這或許不是頗有必要,但這絕對是一個好的習慣,試想一個項目中,若是將某個靜態變量做爲全局的緩存,若是沒有作過時策略,一旦項目運行,那麼它所佔的內存空間只增不減,最終頂爆機器內存,因此,有個建議就是:儘可能地少用靜態變量。

相關文章
相關標籤/搜索