《Effective C#》筆記(2) - .NET的資源管理

理解並善用.NET的資源管理機制

.NET環境會提供垃圾回收器(GC)來幫助控制託管內存,這使得開發者無須擔憂內存泄漏等內存管理問題。儘管如此,但若是開發者可以把本身應該執行的那些清理工做作好,那麼垃圾回收器會表現得更爲出色。非託管的資源是須要由開發者控制的,例如數據庫鏈接、GDI+對象、IO等;此外,某些作法可能會令對象在內存中所待的時間比你預想的更長,這些都是須要咱們去了解、避免的。數據庫

GC的檢測過程是從應用程序的根對象出發,把與該對象之間沒有通路相連的那些對象斷定爲不可達的對象,也就是說,凡是沒法從應用程序中的活動對象(live object)出發而到達的那些對象都應該獲得回收。應用程序若是再也不使用某個實體,那麼就不會繼續引用它,因而,GC就會發現這個實體是能夠回收的。
垃圾回收器每次運行的時候,都會壓縮託管堆,以便把其中的活動對象安排在一塊兒,使得空閒的內存可以造成一塊連續的區域。網絡

針對託管堆的內存管理工做徹底是由垃圾回收器負責的,可是除此以外的其餘資源則必須由開發者來管理。
有兩種機制能夠控制非託管資源的生存期app

  • 一種是finalizer/destructure(析構函數)
  • 另外一種是IDisposable接口。

在這兩種方式中,應該優先考慮經過IDisposable接口來更爲順暢地將資源及時返還給系統,由於finalizer做爲一種防禦機制,雖然能夠確保對象老是可以把非託管資源釋放掉,但這種機制有一些缺陷ide

  • 首先,C#的finalizer執行得並不及時。當垃圾回收器把對象斷定爲垃圾以後,它會擇機調用該對象的finalizer,但開發者並不知道具體的時機,所以,finalizer只能保證由某個類型的對象所分配的非託管資源最終能夠獲得釋放,但並不保證這些資源可以在肯定的時間點上獲得釋放,所以,設計與編寫程序的時候,儘可能不要建立finalizer,即使建立了,也不要過多地依賴於它的執行時機。函數

  • 另外,依賴finalizer還會下降程序的性能,由於垃圾回收器須要執行更多的工做才能終結這些對象。若是GC發現某個對象已經成爲垃圾,但該對象還有finalizer須要運行,那麼就沒法馬上把它從內存中移走,而是要等調用完finalizer以後,才能將其移除。調用finalizer的那個線程並非GC所在的線程。GC在每個週期裏面會把包含finalizier可是還沒有執行的那些對象放在隊列中,以便安排其finalizer的運行工做,而不含finalizer的對象則會直接從內存中清理掉。等到下一個週期,GC纔會把已經執行了finalizer的那些對象刪掉。性能

聲明字段時,儘可能直接爲其設定初始值

類的構造函數有時不止一個,若是某個成員變量的初始化在構造函數進行,就會有忘記給某些成員變量設定初始值的可能性。爲了完全杜絕這種狀況,不管是靜態變量仍是實例變量,最好都在聲明的時候直接初始化,而不要等實現每一個構造函數的時候再去賦值。this

表面上看,在構造函數初始化和在聲明的時候直接初始化等效,但實際上若是選擇在聲明的時候直接初始化,編譯器會把由這些語句所生成的程序碼放在類的構造函數以前。這些語句的執行時機比基類的構造函數更早,它們會按照本類聲明相關變量的前後順序來執行。spa

但也並非說,如什麼時候候都優先在聲明的時候直接初始化,在下面三種狀況下,聲明的時候直接初始化是不建議的,甚至會帶來問題:操作系統

  1. 把對象初始化爲0或null。系統在執行開發者所編寫的代碼以前,自己就會生成初始化邏輯,以便把相關的內容全都設置成0,這是經過底層CPU指令來作的。這些指令會把整塊內存全都設置成0,所以,你若是還要編寫初始化語句,讓編譯器會添加相關指令,把那些內存再度清零,那就顯得多餘了。線程

  2. 若是不一樣的構造函數須要按照各自的方式來設定某個字段的初始值,那麼就不該該再在聲明的時候初始化了,由於它只適用於那些老是按相同方式來初始化的變量。
    就相似這樣的寫法:

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);
  }
}
  1. 若是初始化變量的過程當中有可能出現異常,那麼就不該該使用初始化語句,而是應該把這部分邏輯移動到構造函數裏面。因爲成員變量的初始化語句不能包裹在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");
    }
}

爲何會這樣呢,這要從構建某個類型的首個實例時系統所執行的操做提及,步驟以下:

  1. 把存放靜態變量的空間清零。
  2. 執行靜態變量的初始化語句。
  3. 執行基類的靜態構造函數。
  4. 執行本類的靜態構造函數。
  5. 把存放實例變量的空間清零。
  6. 執行實例變量的初始化語句。
  7. 適當地執行基類的實例構造函數。
  8. 執行本類的實例構造函數。

因此會先初始化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()方法時,要注意如下四點:

  1. 把非託管資源全都釋放掉。
  2. 把託管資源全都釋放掉(這也包括再也不訂閱早前關注的那些事件)。
  3. 設定相關的狀態標誌,用以表示該對象已經清理過了。若是對象已經清理過了以後還有人要訪問其中的公有成員,那麼你能夠經過此標誌得知這一情況,從而令這些操做拋出ObjectDisposedException。
  4. 阻止垃圾回收器重複清理該對象。這能夠經過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版)》 比爾·瓦格納

相關文章
相關標籤/搜索