理解并善用.NET的資源管理機制
.NET環境會提供垃圾回收器(GC)來幫助控制托管記憶體,這使得開發者無須擔心記憶體泄漏等記憶體管理問題,盡管如此,但如果開發者能夠把自己應該執行的那些清理作業做好,那么垃圾回收器會表現得更為出色,非托管的資源是需要由開發者控制的,例如資料庫連接、GDI+物件、IO等;此外,某些做法可能會令物件在記憶體中所待的時間比你預想的更長,這些都是需要我們去了解、避免的,
GC的檢測程序是從應用程式的根物件出發,把與該物件之間沒有通路相連的那些物件判定為不可達的物件,也就是說,凡是無法從應用程式中的活動物件(live object)出發而到達的那些物件都應該得到回收,應用程式如果不再使用某個物體,那么就不會繼續參考它,于是,GC就會發現這個物體是可以回收的,
垃圾回收器每次運行的時候,都會壓縮托管堆,以便把其中的活動物件安排在一起,使得空閑的記憶體能夠形成一塊連續的區域,
針對托管堆的記憶體管理作業完全是由垃圾回收器負責的,但是除此之外的其他資源則必須由開發者來管理,
有兩種機制可以控制非托管資源的生存期
- 一種是finalizer/destructure(解構式)
- 另一種是IDisposable介面,
在這兩種方式中,應該優先考慮通過IDisposable介面來更為順暢地將資源及時返還給系統,因為finalizer作為一種防護機制,雖然可以確保物件總是能夠把非托管資源釋放掉,但這種機制有一些缺陷:
-
首先,C#的finalizer執行得并不及時,當垃圾回收器把物件判定為垃圾之后,它會擇機呼叫該物件的finalizer,但開發者并不知道具體的時機,因此,finalizer只能保證由某個型別的物件所分配的非托管資源最終可以得到釋放,但并不保證這些資源能夠在確定的時間點上得到釋放,因此,設計與撰寫程式的時候,盡量不要創建finalizer,即便創建了,也不要過多地依賴于它的執行時機,
-
另外,依賴finalizer還會降低程式的性能,因為垃圾回收器需要執行更多的作業才能終結這些物件,如果GC發現某個物件已經成為垃圾,但該物件還有finalizer需要運行,那么就無法立刻把它從記憶體中移走,而是要等呼叫完finalizer之后,才能將其移除,呼叫finalizer的那個執行緒并不是GC所在的執行緒,GC在每一個周期里面會把包含finalizier但是尚未執行的那些物件放在佇列中,以便安排其finalizer的運行作業,而不含finalizer的物件則會直接從記憶體中清理掉,等到下一個周期,GC才會把已經執行了finalizer的那些物件刪掉,
宣告欄位時,盡量直接為其設定初始值
類的建構式有時不止一個,如果某個成員變數的初始化在建構式進行,就會有忘記給某些成員變數設定初始值的可能性,為了徹底杜絕這種情況,無論是靜態變數還是實體變數,最好都在宣告的時候直接初始化,而不要等實作每個建構式的時候再去賦值,
表面上看,在建構式初始化和在宣告的時候直接初始化等效,但實際上如果選擇在宣告的時候直接初始化,編譯器會把由這些陳述句所生成的程式碼放在類的建構式之前,這些陳述句的執行時機比基類的建構式更早,它們會按照本類宣告相關變數的先后順序來執行,
但也并不是說,如何時候都優先在宣告的時候直接初始化,在下面三種情況下,宣告的時候直接初始化是不建議的,甚至會帶來問題:
-
把物件初始化為0或null,系統在執行開發者所撰寫的代碼之前,本身就會生成初始化邏輯,以便把相關的內容全都設定成0,這是通過底層CPU指令來做的,這些指令會把整塊記憶體全都設定成0,因此,你如果還要撰寫初始化陳述句,讓編譯器會添加相關指令,把那些記憶體再度清零,那就顯得多余了,
-
如果不同的建構式需要按照各自的方式來設定某個欄位的初始值,那么就不應該再在宣告的時候初始化了,因為它只適用于那些總是按相同方式來初始化的變數,
就類似這樣的寫法:
public class MyClass
{
private List<string> labels = new List<string>();
public MyClass(int size)
{
labels = new List<string>(size);
}
}
這會在構造類實體的程序中創建出兩個不同的List物件,而且先創建出來的那個List馬上就會被后創建的List取代,實際上等于是白創建了一次,這是因為欄位的初始化陳述句會先于建構式而執行,于是,程式在初始化labels欄位時,會根據其初始化陳述句的要求創建出一個List,然后,等到執行建構式時,又會根據其中的賦值陳述句創建出另一個List,并導致前一個List失效,
編譯器所生成的代碼相當于下面這樣:
public class MyClass
{
private List<string> labels;
public MyClass(int size)
{
labels = new List<string>();
labels = new List<string>(size);
}
}
- 如果初始化變數的程序中有可能出現例外,那么就不應該使用初始化陳述句,而是應該把這部分邏輯移動到建構式里面,由于成員變數的初始化陳述句不能包裹在try-catch塊中,因此初始化的程序中一旦發生例外,就會傳播到物件之外,從而令開發者無法在類里面加以處理,應該把這種初始化代碼放在建構式中,以便通過適當的代碼將例外處理好,
用適當的方式初始化類中的靜態成員
通過靜態初始化陳述句或者靜態建構式都可以初始化類中的靜態成員,如果只需給靜態成員分配記憶體即可將其初始化,那么用一條簡單的初始化陳述句就足夠了,反之,若是必須通過復雜的邏輯才能完成初始化,則應考慮創建靜態建構式,
靜態初始化陳述句與實體欄位的初始化陳述句一樣,靜態欄位的初始化陳述句也會先于靜態建構式而執行,并且有可能比基類的靜態建構式執行得更早,如果靜態欄位的初始化作業比較復雜或是開銷比較大,那么可以考慮運用Lazy
靜態建構式是特殊的函式,會在初次訪問該類所定義的其他方法、變數或屬性之前執行,可以用來初始化靜態變數、實作單例(singleton)模式,或是執行其他一些必要的作業,以便使該類能夠正常運作,
當程式碼初次訪問應用程式空間(application space,也就是AppDomain)里面的某個型別之前,CLR會自動呼叫該類的靜態建構式,這種建構式每個類只能定義一個,而且不能帶有引數,
由于靜態建構式是由CLR自動呼叫的,因此必須謹慎處理其中的例外,如果例外跑到了靜態建構式外面,那么CLR就會拋出TypeInitialization-Exception以終止該程式,呼叫方如果想要捕獲這個例外,那么情況將會更加微妙,因為只要AppDomain還沒有卸載,這個型別就一直無法創建,也就是說,CLR根本就不會再次執行其靜態建構式,這導致該型別無法正確地加以初始化,并導致該類及其派生類的物件也無法獲得適當的定義,因此,不要令例外脫出靜態建構式的范圍,
不要創建無謂的物件
雖然垃圾回收器能夠有效地管理應用程式所使用的記憶體,但在堆上創建并銷毀物件仍需耗費一定的時間,因此應盡量避免過多地創建物件,也不要創建那些根本不用去重新構建的物件,此外,在函式中以區域變數的形式頻繁創建參考型別的物件也是不合適的,應該把這些變數提升為成員變數,或是考慮把最常用的那幾個實體設定成相關型別中的靜態物件,
絕對不要在建構式里面呼叫虛函式
這里有個建構式里面呼叫虛函式的demo,運行后列印出的結果是"VFunc in B",還是"VFunc in B1",還是"Msg from main"?答案是"VFunc in B1",
public class B
{
protected B()
{
VFunc();
}
protected virtual void VFunc()
{
Console.WriteLine("VFunc in B");
}
}
public class B1 : B
{
private readonly string msg = "VFunc in B1";
public B1(string msg)
{
this.msg = msg;
}
protected override void VFunc()
{
Console.WriteLine(msg);
}
public static void Init()
{
_ = new B1("Msg from main");
}
}
為什么會這樣呢,這要從構建某個型別的首個實體時系統所執行的操作說起,步驟如下:
- 把存放靜態變數的空間清零,
- 執行靜態變數的初始化陳述句,
- 執行基類的靜態建構式,
- 執行本類的靜態建構式,
- 把存放實體變數的空間清零,
- 執行實體變數的初始化陳述句,
- 適當地執行基類的實體建構式,
- 執行本類的實體建構式,
所以會先初始化B1.msg,然后執行基類B的建構式,基類的建構式呼叫了一個定義在本類中但是為派生類所重寫的虛函式VFunc,于是程式在運行的時候呼叫的就是派生類的版本,因為物件的運行期型別是B1,而不是B,在C#語言中,系統會認為這個物件是一個可以正常使用的物件,因為程式在進入建構式的函式體之前,已經把該物件的所有成員變數全都初始化好了,盡管如此,但這并不意味著這些成員變數的值與開發者最終想要的結果相符,因為程式僅僅執行了成員變數的初始化陳述句,而尚未執行建構式中與這些變數有關的邏輯,
在構建物件的程序中呼叫虛函式有可能令程式中的資料混亂,也會讓基類的代碼嚴重依賴于派生類的實作細節,而這些細節是無法控制的,這種做法很容易出問題,所以應該避免這樣做,
實作標準的dispose模式
dispose模式用于對非托管資源進行釋放,托管資源是指受GC管理的記憶體資源,而非托管資源與之相對,則不受GC的管理,當使用完非托管資源后,必須顯式釋放它們, 最常用的非托管資源型別是包裝作業系統資源的物件,如檔案、視窗、網路連接或資料庫連接, 雖然垃圾回收器可以跟蹤封裝非托管資源的物件的生存期,但無法了解如何發布并清理這些非托管資源,
比如System.IO.File中的FileStream,它屬于.NET的類被GC管理,但它的內部又依賴了作業系統提供的API,因此可以看作是一個Wrapper, 因此要實作dispose模式,在自身被GC銷毀的時候,釋放檔案句柄,
標準的dispose(釋放/處置)模式既會實作IDisposable介面,又會提供finalizer,以便在客戶端忘記呼叫IDisposable.Dispose()的情況下也可以釋放資源,
在類的繼承體系中,位于根部的那個基類應該做到以下幾點:
- 實作IDisposable介面,以便釋放資源,
- 如果本身含有非托管資源,那就添加finalizer,以防客戶端忘記呼叫Dispose()方法,若是沒有非托管資源,則不用添加finalizer,
- Dispose方法與finalizer(如果有的話)都把釋放資源的作業委派給虛方法,使得子類能夠重寫該方法,以釋放它們自己的資源,
繼承體系中的子類應該做到以下幾點:
- 如果子類有自己的資源需要釋放,那就重寫由基類所定義的那個虛方法,如果沒有則不必重寫,
- 如果子類自身的某個成員欄位表示的是非托管資源,那么就實作finalizer,否則就不必實作,
- 記得呼叫基類的同名函式,
下面兩個類UnManaged與MyUnManaged作為非托管資源的示例,假設UnManaged類中直接使用了非托管資源:
public class UnManaged : IDisposable
{
private bool alreadyDisposed;
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool isDisposing)
{
if (alreadyDisposed)
return;
if (isDisposing)
{
// free managed resource here
}
// free unmanaged resource here
alreadyDisposed = true;
}
public void ExampleMethod()
{
if (alreadyDisposed)
throw new ObjectDisposedException(nameof(UnManaged), "Call methods on disposed object");
// do something
}
~UnManaged()
{
Dispose(false);
}
}
public class MyUnManaged : UnManaged
{
private bool alreadyDisposedInDerived;
protected override void Dispose(bool isDisposing)
{
if (alreadyDisposedInDerived)
return;
if (isDisposing)
{
// free managed resource here
}
// free unmanaged resource here
base.Dispose(isDisposing); // call base.Disposes
alreadyDisposedInDerived = true;
}
}
UnManaged直接使用了非托管資源,因此需要解構式,雖然前面提到存在解構式的物件不會被GC立即回收,但作為一種防范機制是必須的,如果使用者忘呼叫Dispose,finalizer仍然確保非托管資源可以得到釋放,盡管程式性能或許會因此而有所下降,但只要客戶代碼能夠平常呼叫Dispose方法,就不會有這個問題,Dispose方法中通過GC.SuppressFinalize(this)來通知GC不必再執行finalizer,
實作IDisposable.Dispose()方法時,要注意以下四點:
- 把非托管資源全都釋放掉,
- 把托管資源全都釋放掉(這也包括不再訂閱早前關注的那些事件),
- 設定相關的狀態標志,用以表示該物件已經清理過了,如果物件已經清理過了之后還有人要訪問其中的公有成員,那么你可以通過此標志得知這一狀況,從而令這些操作拋出ObjectDisposedException,
- 阻止垃圾回收器重復清理該物件,這可以通過GC.SuppressFinalize(this)來完成,
但finalizer中執行的操作與Dispose有所區別,它只應釋放非托管資源,因此為了代碼復用,添加了Dispose的多載方法protected virtual void Dispose(bool isDisposing),它宣告為protected virtual,可以被子類重寫,被IDisposable.Dispose()方法呼叫時,isDisposing引數是true,那么應該同時清理托管資源與非托管資源,finalizer中呼叫時isDisposing為false,則只應清理非托管資源,
還有另外一些注意事項:
- 基類與子類物件采用獨立的disposed標志來表示其資源是否得到釋放,這么寫是為了防止出錯,假如共用同一個標志,那么子類就有可能在釋放自己的資源時率先把該標志設定成true,而等到基類運行Dispose(bool)方法時,則會誤以為其資源已經釋放過了,
- Dispose(bool)與finalizer都必須撰寫得很可靠,也就是要具備冪等(idempotent)的性質,這意味著多次呼叫Dispose(bool)的效果與只呼叫一次的效果應該是完全相同的,
- 在撰寫Dispose或finalizer等資源清理的方法時,只應該釋放資源,而不應該做其他的處理,否則極有可能導致記憶體泄漏等問題,
參考書籍
《Effective C#:改善C#代碼的50個有效方法(原書第3版)》 比爾·瓦格納
轉載請註明出處,本文鏈接:https://www.uj5u.com/net/254636.html
標籤:.NET技术
