寫在前面
在開始之前,我們需要明確什么是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接口,我們其實應該這樣做:
-
實現Dispose方法;
-
提取一個受保護的Dispose虛方法,在該方法中實現具體的釋放資源的邏輯;
-
添加析構函數;
-
添加一個私有的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才會工作:
-
系統具有較低的物理內存;
-
由托管堆上已分配的對象使用的內存超出了可接受的范圍;
-
手動調用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這或許不是很有必要,但這絕對是一個好的習慣,試想一個項目中,如果將某個靜態變量作為全局的緩存,如果沒有做過期策略,一旦項目運行,那么它所占的內存空間只增不減,最終頂爆機器內存,所以,有個建議就是:盡量地少用靜態變量。