相關文章連結
物以類聚:物件也有生命
物件的"生"到"死"一直是.NET程式設計中的難點,如果沒有正確地理解物件的生命週期,程式很容易就會出現"記憶體洩露"(Memory Leak)等異常,最終系統崩潰。很多人錯誤地認為呼叫了物件的Dispose方法之後,物件就"死亡"了,這些錯誤的觀點往往會給系統帶來致命的BUG。本章將從物件生命週期、物件使用資源管理等方面來說明程式設計過程中我們應該注意哪些陷阱,從而最大程度減少程式碼異常發生的機率。
4.1 堆和棧
堆(Heap)和棧(Stack)是.NET中存放資料的兩個主要地點,它們並不是首先在.NET中出現,C++中就有堆和棧的概念,只是C++中的堆需要人工維護,而.NET中的堆則由CLR管理。在.NET中,我們也稱堆為"託管堆"(Managed Heap),這裡的"託管"與本書前面說到的託管程式碼中的"託管"意思一樣,都指需要別人來輔助。
棧主要用來記錄程式的執行過程,它有嚴格的儲存和訪問順序,而堆主要儲存程式執行期間產生的一些資料,幾乎沒有順序的概念。堆跟棧的區別見下圖4-1:
圖4-1 堆與棧存放資料的區別
如上圖4-1,堆中的資料可以隨機存取,而棧中的資料必須要按照某一順序存取。
棧主要存放程式執行期間的一些臨時變數,包括值型別物件以及引用型別物件的引用部分,而堆中主要存放引用型別物件的例項部分。注意這裡的引用型別物件應該包含兩個部分:引用和例項,程式透過前者去訪問後者。
圖4-2 引用型別物件
我們說的一個引用型別物件其實包含兩個部分,圖4-2中的"引用"類似C++中的指標,存放在棧中,而圖中的"例項"則存放在堆中。C#語言中的值型別有類似bool、byte、int等等,而引用型別包括一些class、interface以及delegate等等。
1 //Code 4-1 2 class A 3 { 4 //… 5 } 6 class Program 7 { 8 static void Main() 9 { 10 int local1 = 0; 11 A local2 = new A(); 12 bool local3 = true; 13 } 14 }
上面程式碼Code 4-1中int和bool為值型別,所以local1和local3被分配在棧中,而A為引用型別(class),所以A類物件引用local2分配在棧中,A類物件例項分配在堆中,如下圖4-3:
圖4-3 棧和堆中的資料儲存
如上圖4-3,程式中如果需要訪問A類物件時,必須透過它的引用local2去訪問。
值型別物件與引用型別物件的引用如果屬於另一個引用型別的成員,那麼它們也是可以分配在堆中,
1 //Code 4-2 2 class A 3 { 4 //… 5 } 6 class B 7 { 8 //… 9 A a; 10 int aa; 11 public B() 12 { 13 a = new A(); 14 aa = 1; 15 } 16 //… 17 } 18 class Program 19 { 20 static void Main() 21 { 22 int local1 = 0; 23 B local2 = new B(); 24 bool local3 = true; 25 } 26 }
上面程式碼Code 4-2中,local1和local3依舊分配在棧中,由於a和aa屬於引用型別B的成員,所以它們會隨B物件例項一起,分配在堆中,下圖4-4為此時棧和堆中的資料分配情況:
圖4-4 棧和堆中的資料分配情況
如圖4-4中所示,雖然aa是值型別,a是物件引用,但是它們都是分配在堆中。另外A類物件例項也是分配在堆中。
棧中的資料會隨著程式的不停執行自動存入和移除,堆中的資料則由CLR管理,它們兩者不是同步的,也就是說,可能棧中的一個物件引用已經從棧中移除,但是它指向的物件例項卻還在堆中。
注:
[1]關於值型別與引用型別,請參見本書第三章介紹的"資料型別"。
[2]引用型別物件包括"物件引用"和"物件例項"兩部分,這種叫法可能跟其它地方不一樣,本書中統一稱棧中的為"物件引用",稱堆中的為"物件例項"。另外,堆跟棧的本質都是一段記憶體塊,本節以上幾幅配圖中,堆的"雜亂無章"只是為了說明堆中資料儲存和訪問可以是隨機的,突出與棧的區別。
4.2 堆中物件的出生與死亡
棧中的物件由系統負責自動存入和移除,正常情況下,跟我們程式開發的關聯並不大。本節主要討論堆中物件的生命期。
4.2.1 引用和例項
上一節中結尾說到過,對於引用型別物件而言,棧中的引用和堆中的例項並不是同步的。棧中引用已經被移除,堆中的例項可能還存在,我們該怎麼判斷一個物件的生命週期呢?是依據棧裡面的引用還是堆中的例項?
答案當然是堆裡面的資料,因為CLR管理堆中物件記憶體時,是根據該物件的當前引用數目(包括棧和堆中),如果引用數目為零,那麼CLR就會在某一個時間將該物件在堆中的記憶體回收。換句話說,堆中的物件例項存在的時間永遠要比棧中的物件引用存在的時間長。下圖4-5描述了物件例項與物件引用存在時間:
圖4-5 物件引用與物件例項的生存時間對比
圖4-5中物件例項生存時間要比物件引用生存時間長,由於在程式碼中我們只能透過引用去訪問例項,所以當引用從棧中移除後(圖中"可達"變"不可達"處),物件雖然沒有死亡,但是也基本沒什麼用處。
如果再也沒有任何一個引用(包括棧和堆中)指向堆中的某個例項,那麼CLR(具體應該是CLR中的GC,下同)就會在某一個時間點對該例項進行記憶體回收,CLR的此舉動便是第二章中提到的"管理物件託管資源"(堆中記憶體屬於託管資源,由CLR管理)。需要注意的是,CLR回收記憶體的時間點並不確定,並不是例項的引用數目一為零,CLR馬上就對該例項進行記憶體回收,因此圖4-5中堆中物件"不可達"狀態持續的時間不確定,有可能很短,也有可能很長。CLR將物件例項的記憶體回收後,堆中可能會出現不連續塊,這時CLR還有類似硬碟的"碎片整理"功能,它能合併堆中不連續部分。
圖4-6 堆中物件的生到死
圖4-6中物件引用R3存放在棧中,指向堆中的D物件例項;當棧中R3移除後(注意此時R4必須移除),堆中的D就處於"不可達"狀態,D也成為了CLR的回收目標;當D被CLR回收後,D的位置就空出來,堆中出現不連續塊;CLR隨後進行堆空間整理,合併不連續記憶體塊,這便是物件一次從生到死的全過程。注意上圖只是為了說明堆中D的生命週期,所以堆中其它物件例項的引用情況沒有完整繪製出來。另外,如果堆中還有其它的引用指向D,就算R3從棧中移除,D還是不能成為CLR的回收目標,如下圖4-7:
圖4-7 棧中的引用與堆中的引用同時存在
圖4-7中R3和B中的某一個成員同時指向D,就算R3從棧中移除,D還是處於"可達"狀態,不會成為CLR的回收目標。
很明顯,我們程式中如果要操作堆中的某個物件例項,只能在該物件例項處於"可達"狀態時,只有這個時候物件引用還在,其它任何時候都不行。換句話說,我們能夠控制堆中物件例項的時間是有限制的。
4.2.2 析構方法
CLR還有另外一個功能,它在準備回收一個"不可達"物件例項在堆中的記憶體之前,會呼叫這個物件例項的析構方法,呼叫時間點如下圖4-8:
圖4-8 CLR呼叫析構方法時間點
圖4-8中CLR呼叫不可達物件的析構方法是在它回收記憶體之前,換句話說,物件死亡前一刻,CLR還會呼叫它的析構方法,如此一來,我們真正能夠操作堆中物件例項的機會又多了一處,之前說到的當物件例項處於"可達"狀態時,我們可以透過物件引用訪問它,現在,當物件處於"不可達"狀態,我們還可以在析構方法中編寫程式碼操作它。由於CLR回收記憶體的時間點不確定,所以它呼叫析構方法的時間點也不確定。"不可達"物件的析構方法遲早要被呼叫,但是呼叫時間我們不可控。
圖4-9 CLR呼叫析構方法時間不確定
圖4-9中顯示CLR呼叫析構方法的時間點不確定。
雖然CLR呼叫析構方法的時間不確定,但是還是為我們提供了一個額外操作堆中物件例項的機會。下圖4-10中網格部分表示我們可以在程式碼中操作堆中物件例項的時間段:
圖4-10 可操作堆中物件的時間段
由於CLR是根據"可達"還是"不可達"來判斷是否應該回收物件例項的記憶體,因此,如果我們在析構方法中又將某個引用指向了物件例項,那麼等析構方法執行完畢後,這個物件例項又從"不可達"狀態變成了"可達"狀態,CLR會改變原來的計劃,不再把該物件例項當成回收的目標,這個就是所謂的"重生"。"重生"是指堆中物件例項在將要被CLR回收前一刻,狀態由"不可達"變成了"可達"。下圖4-11顯示了一個堆中物件例項的重生過程:
圖4-11 物件重生
理論上,一個物件可以無限次重生,但是為了避免程式設計的複雜性,"物件重生"在程式設計過程中是不提倡的,也就是說,我們應該謹慎編寫析構方法中的程式碼,不提倡在析構方法中再次引用堆中物件例項。
注:後面我們可以知道,正常情況下,析構方法除了用來釋放自己使用過的非託管資源之外,其餘什麼都不應該負責。
4.2.3 正確使用物件
實際程式設計過程中,引用型別物件使用頻率居高,導致堆中物件例項數量巨大,如果不正確地使用物件,很容易造成堆中物件例項佔用記憶體不能及時被CLR回收,引起記憶體不足。程式設計中我們應該遵循以下兩條規則:
(1)能使用區域性變數儘量使用區域性變數,也就是將引用型別物件定義成方法執行過程中的臨時變數,不要把它定義成一個型別的成員變數(全域性變數);
區域性變數儲存在棧中,方法執行完畢後,會直接從棧中移除。如果把一個引用型別物件定義成區域性變數,方法執行完畢後,物件引用從棧中移除,堆中的物件例項很快就會由"可達"狀態變為"不可達"狀態,從而成為CLR的回收目標。
1 //Code 4-3 2 class A 3 { 4 //… 5 } 6 class B 7 { 8 A _a; 9 public B() 10 { 11 _a = new A(); 12 } 13 //… 14 } 15 class C 16 { 17 B _b; 18 A _a; 19 public C() 20 { 21 _a = new A(); 22 } 23 public void DoSomething() 24 { 25 _b = new B(); 26 //deal with _b 27 //or use local member 28 //B b = new B(); NO.3 29 //deal with b 30 } 31 //… 32 } 33 class Program 34 { 35 //… 36 static void Main() 37 { 38 int local1 = 0; 39 C c = new C(); 40 c.DoSomething(); //NO.1 41 int local2 = 1; //NO.2 42 // do something else 43 //END 44 } 45 }
上述程式碼Code 4-3中,C的DoSomething方法中預設使用的是成員變數(全域性變數)_b,當DoSomething方法返回之後,_b指向的物件例項依舊處於"可達"狀態。執行DoSomething方法前和執行DoSomething方法之後,棧和堆中的情況分別如下圖4-12:
圖4-12 棧和堆中的資料分配
上圖4-12左邊為執行c.DoSomething方法時,也就是程式碼中NO.1處,棧跟堆中的資料情況;圖中右邊為c.DoSomething方法執行完畢返回後,也就是程式碼中NO.2處,棧跟堆中的資料情況。可以看出方法執行前後,堆中的B物件例項都是處於"可達"狀態,根本原因便是指向B物件例項的引用是C的成員變數,只有C被CLR回收了之後,B才由"可達"狀態變為"不可達"狀態。如果我們將DoSomething方法中的_b改為區域性變數(程式碼中註釋部分NO.3處),情況則完全不一樣:
圖4-13 棧跟堆中的資料分配
上圖4-13左邊為執行c.DoSomething方法時,也就是程式碼中NO.1處,棧跟堆中的資料情況,可以看出,指向B物件例項的引用b儲存在棧中;圖中右邊為c.DoSomething方法執行完畢返回後,也就是程式碼中NO.2處,棧跟堆中的資料情況,方法執行完畢返回後,棧中的b被移除,B物件例項處於"不可達"狀態,立刻成為CLR回收的目標。
(2)謹慎使用物件的析構方法。析構方法由CLR呼叫,不受我們程式控制,而且容易造成物件重生,除了下一節中介紹的管理物件非託管資源外,析構方法幾乎不能用作其它用途。
4.3 管理非託管資源
非託管資源和託管資源一樣,都是物件在使用過程中需要的必備條件。在.NET中,物件使用到的託管資源主要指它佔用堆中的記憶體,而非託管資源則指它使用到的CLR之外的資源(詳見第二章關於託管資源和非託管資源的介紹)。非託管資源不受CLR管理,因此管理好物件使用的非託管資源是每個程式開發人員的職責。
當一個物件不再使用時,我們就應該將它使用的非託管資源釋放掉,歸還給系統,不然等到CLR將它在堆中的記憶體回收之後,這些非託管資源只能等到整個應用程式執行結束之後才能歸還給系統。那麼什麼時候是我們釋放物件非託管資源的最佳時機呢?
4.3.1 釋放非託管資源的最佳時間
前面講到過,我們能夠操作堆中物件例項的機會有兩個,一個是該物件例項處於"可達"狀態時,即有物件引用指向它;第二個是在析構方法中。因此,我們可以在這兩處釋放物件的非託管資源。
由於析構方法呼叫時間不確定,所以我們最好不要完全依賴於析構方法,也就是說,只要我們不再使用某個物件,就應該在程式中馬上釋放掉它的非託管資源。為了避免忘記此操作而導致的非託管資源洩露,我們可以在析構方法中同樣也寫好釋放非託管資源的程式碼(作為釋放非託管資源的備選方案)。
1 //Code 4-4 2 class A 3 { 4 //… 5 public A() 6 { 7 //… 8 } 9 public void DoSomething() 10 { 11 //do something here 12 } 13 public void ReleaseUnManagedResource() 14 { 15 DoRelease(); 16 GC.SuppressFinalize(this); //NO.1 17 } 18 private void DoRelease() 19 { 20 //release unmanaged resource here 21 } 22 ~A() 23 { 24 DoRelease(); 25 } 26 } 27 class Program 28 { 29 static void Main() 30 { 31 A a = new A(); 32 a.DoSomething(); //NO.2 33 a.ReleaseUnManagedResource(); 34 } 35 }
程式碼Code 4-4中A型別使用了非託管資源,提供了一個公開ReleaseUnmanagedResource方法,程式中使用完A型別物件後,立刻呼叫ReleaseUnmanagedResource方法釋放它的非託管資源,同時,為了防止程式中沒有呼叫ReleaseUnmanagedResource方法而導致的非託管資源洩露,我們在析構方法中呼叫了DoRelease方法。注意GC.SuppressFinalize方法(NO.1處),它請求CLR不要再呼叫本物件的析構方法,原因很簡單,既然非託管資源已經釋放完成,那麼CLR就沒必要再繼續呼叫析構方法。
注:CLR呼叫物件的析構方法是一個複雜的過程,需要消耗非常大的效能,這也是儘量避免在析構方法中釋放非託管資源的一個重要原因,最好是徹底地不呼叫析構方法。
如果呼叫a.DoSomething(NO.2處)丟擲異常,那麼後面的a.ReleaseUnManagedResource就不能執行,因此可以改進程式碼:
1 //Code 4-5 2 class Program 3 { 4 static void Main() 5 { 6 A a = new A(); 7 try 8 { 9 a.DoSomething(); 10 } 11 finally 12 { 13 a.ReleaseUnManagedResource(); 14 } 15 } 16 }
將對a物件的操作放入try/finally塊中,確保a.ReleaseUnManagedResource一定執行。
4.3.2 繼承與組合中的非託管資源
物件導向程式設計(OOP)中有兩種擴充套件型別的方法,一種是繼承,另外一種便是組合。二者都可以以原有的型別為基礎建立一個新的型別,這就產生了一個問題,如果是繼承,派生類中使用了非託管資源,基類中也使用了非託管資源,這兩種怎麼統一管理?如果是組合,型別本身使用了非託管資源,型別中的成員物件也使用了非託管資源,這兩種又怎麼統一管理?如果繼承與組合兩者結合起來,又該怎麼去管理它們的非託管資源呢?
在繼承模式中,我們可以將釋放非託管資源的方法定義為虛方法,派生類中只需要重寫該虛方法,在方法裡面新增釋放派生類的非託管資原始碼,再呼叫基類中釋放非託管資源的方法即可。上一小節中型別A的程式碼改為:
1 //Code 4-6 2 class ABase 3 { 4 //… 5 public ABase() 6 { 7 //… 8 } 9 public void ReleaseUnManagedResource() 10 { 11 DoRelease(); 12 GC.SuppressFinalize(this); //NO.1 13 } 14 protected virtual void DoRelease() 15 { 16 //release ABase's unmanaged resource here 17 } 18 ~ABase() 19 { 20 DoRelease(); 21 } 22 } 23 class A:Abase 24 { 25 public A() 26 { 27 //… 28 } 29 protected override void DoRelease() 30 { 31 // release A's unmanaged resource here 32 base.DoRelease(); //NO.2 33 } 34 }
程式碼Code 4-6中Abase和A型別都使用到了非託管資源,A型別重寫了父類Abase的DoRelease虛方法,在其中釋放A型別的非託管資源,然後再呼叫父類的DoRelease方法去釋放父類的非託管資源(NO.2處)。
注:虛方法DoRelease必須宣告為protected,因為派生類需要呼叫基類的該方法。
基類和派生類非託管資源關係如下圖4-14:
圖4-14 基類與派生類非託管資源關係
在組合模式中,一個型別可能有許多成員物件,這些成員物件也可能使用到了非託管資源。如果該型別物件釋放非託管資源,那麼其成員物件也應該釋放它們各自的非託管資源,因為它們是一個整體,
1 //Code 4-7 2 class A 3 { 4 //… 5 public A() 6 { 7 //… 8 } 9 public void DoSomething() 10 { 11 //do something here 12 } 13 public void ReleaseUnManagedResource() 14 { 15 DoRelease(); 16 GC.SuppressFinalize(this); //NO.1 17 } 18 private void DoRelease() 19 { 20 //release A's unmanaged resource here 21 } 22 ~A() 23 { 24 DoRelease(); 25 } 26 } 27 class B 28 { 29 //… 30 A _a1; 31 A _a2; 32 public B() 33 { 34 //… 35 _a1 = new A(); 36 _a2 = new A(); 37 } 38 public void DoSomething() 39 { 40 //do something here 41 } 42 public void ReleaseUnManagedResource() 43 { 44 DoRelease(); 45 GC.SuppressFinalize(this); //NO.2 46 } 47 private void DoRelease() 48 { 49 //release B's unmanaged resource here 50 //then release children unmanaged resource 51 _a1.ReleaseUnManagedResource(); //NO.3 52 _a2.ReleaseUnManagedResource(); //NO.4 53 } 54 ~B() 55 { 56 DoRelease(); 57 } 58 }
程式碼Code 4-7中B型別中包含兩個A型別成員_a1和_a2,_a1和_a2都需要釋放非託管資源,由於它們兩個跟B型別是一個整體,所以在B型別釋放非託管資源的時候,我們也應該編寫釋放_a1和_a2的非託管資原始碼(NO.3和NO.4處)。
圖4-15 組合模式中的非託管資源
上圖4-15中一個物件可以包含許多成員物件,這些成員物件又可以包含更多的成員物件,圖中每個物件都有可能使用了非託管資源,我們的職責就是在parent釋放非託管資源的時候,將它下級以及下下級(甚至更多)的所有成員物件的非託管資源全部釋放。
注:圖4-15中的parent是相對的,也就是說,parent也有可能成為另外一個物件的成員物件。縱觀程式中各個物件之間的關係,幾乎都是這種結構,我們列舉出來的只是其中的一小部分,圖中childN也可能成為另外一個parent。
繼承與組合同時存在的情況就很簡單了,將兩種釋放非託管資源的方法合併,程式碼如下(不完整):
1 //Code 4-8 2 class A:ABase 3 { 4 //… 5 B _b1; //member 6 B _b2; //member 7 public A() 8 { 9 //… 10 _b1 = new B(); 11 _b2 = new B(); 12 } 13 public override void DoRelease() 14 { 15 // 1.release A's unmanaged resource 16 // 2.release member's unmanaged resource 17 // 3.release ABase's unmanaged resource 18 19 ReleaseMyUnManagedResource(); //NO.1 20 _b1.ReleaseUnManagedResource(); //NO.2 21 _b2.ReleaseUnManagedResource(); 22 base.DoRelease(); //NO.3 23 } 24 private void ReleaseMyUnManagedResource() 25 { 26 //… 27 //release A's unmanaged resource here 28 } 29 }
程式碼Code 4-8中,A型別派生自ABase型別,同時A型別中包含_b1和_b2兩個成員物件,在A型別內部,我們重寫DoRelease虛方法,首先釋放A中的非託管資源(NO.1),然後釋放成員物件的非託管資源(NO.2),最後釋放基類的非託管資源(NO.3)。
注意,型別ABase的內部結構跟A型別一樣(只要它們的最頂層基類中有ReleaseUnManagedResource公開方法和對應的析構方法),型別B的內部結構也跟A型別一樣,也就是說,每個型別的內部結構都與A型別一樣。另外,程式碼中NO1. NO2以及NO3的順序是可以改變的,換句話說,非託管資源的釋放順序也是可以改變的。
例項程式碼對應的非託管資源釋放順序如下圖4-16:
圖4-16 非託管資源的釋放順序
圖4-16中順序號為非託管資源的釋放順序,對於每一個單獨的物件而言,都是遵循"自己-成員物件-基類"這樣的一個順序。圖4-16中非託管資源的釋放順序並不是固定的。
4.4 正確使用IDisposable介面
上一節講到了管理物件非託管資源的方法。如果一個型別需要使用非託管資源,那麼我們可以這樣去做:
(1)在型別中提供類似ReleaseUnManagedResource這樣的公開方法,當物件不再使用時,開發者可在程式中人工顯式釋放非託管資源;
(2)編寫型別的析構方法,在析構方法中編寫釋放非託管資源的程式碼,防止開發者沒有人工顯式釋放非託管資源而造成資源洩露異常。
既然這些都是總結出來管理非託管資源的有效方法,那麼我們在程式設計過程中就應該把它當做一個規則去遵守。.NET類庫中有一個IDisposable介面,幾乎每個使用非託管資源的型別都應該實現該介面。
4.4.1 Dispose模式
"Dispose模式"便是管理物件非託管資源的一種原則,微軟官方釋出類庫中所有使用了非託管資源的型別都遵循了這一原則。該模式很簡單,定義一個IDisposable介面,該介面包含一個Dispose方法,所有使用了非託管資源的型別均應該實現該介面,類似程式碼如下:
1 //Code 4-9 2 interface IDisposable 3 { 4 void Dispose(); 5 } 6 class ABase:IDisposable 7 { 8 //… 9 bool _disposed = false; //check if released or not 10 public bool Disposed 11 { 12 get 13 { 14 return _disposed; 15 } 16 } 17 public ABase() 18 19 { 20 21 //… 22 23 } 24 public void Dispose() //NO.1 25 { 26 if(!_disposed) 27 { 28 Dispose(true); 29 GC.SuppressFinalize(this); 30 _disposed = true; 31 } 32 } 33 protected virtual void Dispose(bool disposing) 34 { 35 if(disposing) 36 { 37 //release member's unmanaged resource 38 } 39 //release ABase's unmanaged resource 40 //no base class 41 } 42 ~ABase 43 { 44 Dispose(false); //call the virtual Dispose method,maybe override in derived class 45 } 46 } 47 class A:ABase 48 { 49 //… 50 public A() 51 { 52 //… 53 } 54 protected override void Dispose(bool disposing) 55 { 56 if(disposing) 57 { 58 //release member's unmanaged resource 59 } 60 //release A's unmanaged resource 61 base.Dispose(disposing); //NO.2 62 } 63 } 64 65 class B:A 66 { 67 //… 68 public B() 69 { 70 //… 71 } 72 public void DoSomething() 73 { 74 if(Disposed) //if released, throw exception 75 { 76 throw new ObjectDisposedException(...); 77 } 78 // do something here 79 } 80 81 protected override void Dispose(bool disposing) 82 { 83 if(disposing) 84 { 85 //release member's unmanaged resource 86 } 87 //release B's unmanaged resource 88 base.Dispose(disposing); //NO.3 89 } 90 }
程式碼Code 4-9中Abase類實現了IDisposable介面,並提供了兩個Dispose方法,一個不帶引數的普通方法和一個帶有一個bool型別引數的虛方法。如果程式中人工顯式呼叫Dispose()方法去釋放非託管資源,那麼同時會釋放所有成員物件的非託管資源(disposing引數為true);如果不是人工顯式呼叫Dispose()方法釋放非託管資源而是交給析構方法去負責,那麼就不會釋放成員物件的非託管資源(disposing引數為false),這樣一來,所有成員物件都得由自己的析構方法去釋放各自的非託管資源。
ABase型別的所有派生類,如果使用到了非託管資源,只需要重寫Dispose(bool disposing)虛方法,在其中編寫釋放自己使用的非託管資原始碼。如果有必要(disposing為true),則釋放自己成員物件的非託管資源,最後再呼叫基類的Dispose(bool disposing)虛方法(NO.2和NO.3)。另外物件中每一個方法執行之前需要判斷自己是否已經Disposed,如果已經Disposed,說明物件已經釋放非託管資源,大多數時候該物件不會再正常工作,因此丟擲異常。
注:析構方法只需要在ABase類中編寫一次,其派生類中不需要再有析構方法。如果派生類中有析構方法,也一定不能再呼叫Dispose(bool)虛方法,因為析構方法預設是按照"底層-頂層"這樣的順序依次呼叫,多個析構方法多次呼叫Dispose(bool),會重複釋放非託管資源,引起不可預料異常。
前面提到過,為了確保程式中人工顯式釋放非託管資源的程式碼在任何情況中一定執行,需要把程式碼放在try/finally塊中。C#中還有一種更為簡潔的寫法,只要我們的型別實現了IDisposable介面,那麼就可以這樣編寫程式碼:
1 //Code 4-10 2 using(A a = new A()) 3 { 4 a.DoSomething(); 5 }
這段程式碼Code 4-10編譯之後相當於:
1 //Code 4-11 2 A a = new A(); 3 try 4 { 5 a.DoSomething(); 6 } 7 finally 8 { 9 a.Dispose(); 10 }
這樣就能確保a物件使用完畢後,a.Dispose方法總能夠被呼叫。在使用FileStream、SqlDataConnection等實現了IDisposable介面型別的物件時,我們幾乎都可以這麼使用:
1 //Code 4-12 2 using(FileStream fs = new FileStream(…)) 3 { 4 //deal with fs 5 }
在實際開發過程中,我們經常能遇見應用了"Dispose模式"的場合,比如Winform開發中,新建一個窗體類Form1,Form1.Designer.cs中預設生成的程式碼如下:
1 //Code 4-13 2 protected override void Dispose(bool disposing) 3 { 4 if (disposing && (components != null)) 5 { 6 components.Dispose(); //NO.1 7 } 8 // add your code to release Form1's unmanaged resource //NO.2 9 base.Dispose(disposing); //NO.3 10 }
因為Form1派生自Form,Form又間接派生自Control,Control派生自Component,最後Component實現了IDisposable介面,換句話說,Form1間接實現了IDisposable介面,遵循"Dispose模式",那麼它就應該重寫Dispose(bool disposing)虛方法,並在Dispose(bool disposing)虛方法中釋放成員物件的非託管資源(NO.1處)、釋放本身使用的非託管資源(NO.2處)以及呼叫基類的Dispose(bool disposing)虛方法(NO.3處)。
注:Form1中使用了非託管資源的成員物件幾乎都派生自Component型別,並存放在components容器中,這些程式碼基本都由窗體設計器(Form Designer)自動生成,本書第七章中有講到。
總之,記住如果一個型別使用了非託管資源,或者它包含使用了非託管資源的成員,那麼我們就應該應用"Dispose模式":正確地實現(間接或直接)IDisposable介面,正確的重寫Dispose(bool disposing)虛方法。
4.4.2 物件的Dispose()和Close()
很多實現了IDisposable介面的型別同時包含Dispose()和Close()方法,那麼它們究竟有什麼區別呢?都是用來釋放非託管資源的嗎?
事實上,它兩沒有絕對的確定關係,有的時候Close的字面意思更形象。比如一個大門Gate類,使用Close作為方法名稱比使用Dispose更直白,因此有時候把Dispose()方法遮蔽,用Close()方法代替Dispose()方法,作用跟Dispose方法完全一樣。
1 //Code 4-14 2 class Gate:IDisposable 3 { 4 //… 5 public Gate() 6 { 7 //… 8 } 9 void IDisposable.Dispose() //implement IDisposable explicitly 10 { 11 Close(); 12 } 13 public void Close() 14 { 15 if(!_disposed) 16 { 17 Dispose(true); 18 GC.SuppressFinalize(this); 19 _disposed = true; 20 } 21 } 22 //other methods 23 }
上面程式碼Code 4-14中Close方法就起到與Dispose方法一樣的作用,意思便是釋放非託管資源。另外還有一些情況Close()與Dispose()方法同時存在,但是作用卻並不一樣。
總之,凡是談到Dispose(),與它的字面意思一樣,意思是釋放非託管資源,Dispose()方法呼叫後物件就不能再使用。而談到Close(),它有時候與Dispose()的功能一樣,比如FileStream型別,而有的時候卻跟Dispose()不一樣,它僅僅表示"關閉"的意思,說不定還會有一個Open()方法與它對應,比如SqlConnection。
不管是Dispose還是Close,我們需要注意一點,如果釋放了物件的非託管資源,那麼這個物件就不能再使用,否則就會丟擲異常。我們除錯程式碼的時候偶爾會碰到類似"無法使用已經釋放的例項"的錯誤資訊,意思便是不能再使用已經釋放非託管資源的物件,原因很簡單,非託管資源是物件正常工作時不可缺少的一部分,釋放了它,物件肯定不能正常工作。下圖4-17表示人工顯式釋放物件非託管資源在物件整個生命週期的時間點位置:
圖4-17 釋放非託管資源時間點
圖4-17中呼叫物件Dispose()方法是在物件處於"可達"狀態時。Dispose()方法呼叫之前,物件可以正常使用,呼叫之後,雖然仍然有引用指向它,但是物件已經不能再使用。在圖4-17中"無效"區域操作物件時,大部分都會丟擲異常。
注:物件釋放非託管資源後,也就是呼叫了Dispose()或者Close()方法之後,不代表該物件死亡,這時候還是可以有引用指向它,繼續訪問它,雖然此時所有的訪問幾乎都已無效。
4.5 本章回顧
本章開頭介紹了程式設計中"堆"和"棧"的概念,它們是兩種不同的資料結構,程式執行期間起著非同一般的作用;之後著重介紹了存放在堆中物件以及它的生命週期,該部分的配圖比較多也比較詳細,讀者可以仔細閱讀配圖,相信能更清楚地理解物件在堆中的"從生到死";之後介紹了.NET物件使用到的"非託管資源"以及怎樣去正確地管理好這些資源,章節最後提到了"Dispose模式",它是管理物件非託管資源的有效方式,同時還解釋了為什麼某些型別同時具備Close()和Dispose()方法。物件生命期是.NET程式設計中的重點,清楚瞭解物件的"生"到"死"是能夠編寫出穩定程式的前提。
4.6 本章思考
1."當棧中沒有引用指向堆中物件例項時,GC會對堆中例項進行記憶體回收"這句話是否準確?為什麼?
A:不準確。因為GC不但會檢查棧中的引用,還會檢查堆中是否有引用。因此,只有當沒有任何引用指向堆中物件例項時,GC才會考慮回收物件例項所佔用的記憶體。
2.如果一個型別正確地實現了IDisposable介面,那麼呼叫該型別物件的Dispose()方法後,是否意味著該物件已經死亡?為什麼?
A:呼叫一個物件的Dispose()方法後,並不表明該物件死亡,只有GC將物件例項佔用的記憶體回收後,才可以說物件死亡。但是通常情況下,呼叫物件的Dispose()方法後,由於釋放了該物件的非託管資源,因此該物件幾乎就處於"無用"狀態,"等待死亡"是它正確的選擇。
3.如果一個型別使用了非託管資源,那麼釋放非託管資源的最佳時機是什麼時候?
A:當物件使用完畢後,就應該及時釋放它的非託管資源,比如呼叫它的Dispose()方法(如果有),物件的非託管資源釋放後,物件基本上就處於"無用"狀態,因此一般不能再繼續使用該物件。為了防止遺忘手動釋放物件的非託管資源,我們應該在物件的析構方法中編寫釋放非託管資源的程式碼,這樣一來,假如我們沒有手動釋放物件的非託管資源,GC也會在適當時機呼叫析構方法,物件的非託管資源總能正確被釋放。