C#內存管理


1、值數據類型

在進程的虛擬內存中,有一個區域稱為堆棧。堆棧存儲不是對象成員的值數據類型。另外,在調用一個方法時,也使用堆棧存儲傳遞給方法的所有參數的復本。為了理解堆棧的工作原理,需要注意在C#中變量的作用域。如果變量a在變量b之前進入作用域,b就會先出作用域。下面的代碼:

   {

      int a;

      // do something

      {

         int b;

         // do something else

      }

   }

 

首先聲明a。在內部的代碼塊中聲明了b。然后內部的代碼塊終止,b就出作用域,最后a出作用域。所以b的生存期會完全包含在a的生存期中。在釋放變量時,其順序總是與給它們分配內存的順序相反,這就是堆棧的工作方式。

我們不知道堆棧在地址空間的什么地方,這些信息在進行C#開發是不需要知道的。堆棧指針(操作系統維護的一個變量) 表示堆棧中下一個自由空間的地址。程序第一次運行時,堆棧指針指向為堆棧保留的內存塊末尾。堆棧實際上是向下填充的,即從高內存地址向低內存地址填充。當數據入棧后,堆棧指針就會隨之調整,以始終指向下一個自由空間。這種情況如圖下所示。在該圖中,顯示了堆棧指針800000(十六進制的0xC3500),下一個自由空間是地址799999。

 

下面的代碼會告訴編譯器,需要一些存儲單元以存儲一個整數和一個雙精度浮點數,這些存儲單元會分別分配給nRacingCars和engineSize,聲明每個變量的代碼表示開始請求訪問這個變量,閉合花括號表示這兩個變量出作用域的地方。

   {

      int nRacingCars = 10;

      double engineSize = 3000.0;

      // do calculations;

   }

假定使用如上圖所示的堆棧。變量nRacingCars進入作用域,賦值為10,這個值放在存儲單元799996~799999上,這4個字節就在堆棧指針所指空間的下面。有4個字節是因為存儲int要使用4個字節。為了容納該int,應從堆棧指針中減去4,所以它現在指向位置799996,即下一個自由空間 (799995)。

下一行代碼聲明變量engineSize(這是一個double),把它初始化為3000.0。double要占用8個字節,所以值3000.0占據棧上的存儲單元799988~799995上,堆棧指針減去8,再次指向堆棧上的下一個自由空間。

當engineSize出作用域時,計算機就知道不再需要這個變量了。因為變量的生存期總是嵌套的,當engineSize在作用域中時,無論發生什么情況,都可以保證堆棧指針總是會指向存儲engineSize的空間。為了從內存中刪除這個變量,應給堆棧指針遞增8,現在指向engineSize使用過的空間。此處就是放置閉合花括號的地方。當nRacingCars也出作用域時,堆棧指針就再次遞增4,此時如果內存中又放入另一個變量,從799999開始的存儲單元就會被覆蓋,這些空間以前是存儲nRacingCars的。

如果編譯器遇到像int i、j這樣的代碼,則這兩個變量進入作用域的順序就是不確定的:兩個變量是同時聲明的,也是同時出作用域的。此時,變量以什么順序從內存中刪除就不重要了。編譯器在內部會確保先放在內存中的那個變量后刪除,這樣就能保證該規則不會與變量的生存期沖突。

 

2  引用數據類型

堆棧有非常高的性能,但對於所有的變量來說還是不太靈活。變量的生存期必須嵌套,在許多情況下,這種要求都過於苛刻。通常我們希望使用一個方法分配內存,來存儲一些數據,並在方法退出后的很長一段時間內數據仍是可以使用的。只要是用new運算符來請求存儲空間,就存在這種可能性——例如所有的引用類型。此時就要使用托管堆。

如果以前編寫過需要管理低級內存的C++代碼,就會很熟悉堆(heap)。托管堆和C++使用的堆不同,它在垃圾收集器的控制下工作,與傳統的堆相比有很顯著的性能優勢。

托管堆(簡稱為堆)是進程的可用4GB中的另一個內存區域。要了解堆的工作原理和如何為引用數據類型分配內存,看看下面的代碼:

  void DoWork()

   {

      Customer arabel;

      arabel = new Customer();

      Customer otherCustomer2 = new EnhancedCustomer();

   }

這段代碼中,假定存在兩個類Customer 和 EnhancedCustomer。EnhancedCustomer類擴展了Customer類。

首先,聲明一個Customer引用arabel,在堆棧上給這個引用分配存儲空間,但這僅是一個引用,而不是實際的Customer對象。arabel引用占用4個字節的空間,包含了存儲Customer對象的地址(需要4個字節把內存地址表示為0到4GB之間的一個整數值)。

然后看下一行代碼:

      arabel = new Customer();

這行代碼完成了以下操作:首先,分配堆上的內存,以存儲Customer實例(一個真正的實例,不只是一個地址)。然后把變量arabel的值設置為分配給新Customer對象的內存地址(它還調用合適的Customer()構造函數初始化類實例中的字段,但我們不必擔心這部分)。

Customer實例沒有放在堆棧中,而是放在內存的堆中。在這個例子中,現在還不知道一個Customer對象占用多少字節,但為了討論方便,假定是32字節。這32字節包含了Customer實例字段,和.NET用於識別和管理其類實例的一些信息。

為了在堆上找到一個存儲新Customer對象的存儲位置,.NET運行庫在堆中搜索,選取第一個未使用的、32字節的連續塊。為了討論方便,假定其地址是200000,arabel引用占用堆棧中的799996~799999位置。這表示在實例化arabel對象前,內存的內容應如下圖所示。

 

 

給Customer對象分配空間后,內存內容應如下圖所示。注意,與堆棧不同,堆上的內存是向上分配的,所以自由空間在已用空間的上面。

 

 

下一行代碼聲明了一個Customer引用,並實例化一個Customer對象。在這個例子中,需要在堆棧上為mrJones引用分配空間,同時,也需要在堆上為它分配空間:

      Customer otherCustomer2 = new EnhancedCustomer();

該行把堆棧上的4字節分配給otherCustomer2引用,它存儲在799992~799995位置上,而otherCustomer2對象在堆上從200032開始向上分配空間。

從這個例子可以看出,建立引用變量的過程要比建立值變量的過程更復雜,且不能避免性能的降低。實際上,我們對這個過程進行了過分的簡化,因為.NET運行庫需要保存堆的狀態信息,在堆中添加新數據時,這些信息也需要更新。盡管有這些性能損失,但仍有一種機制,在給變量分配內存時,不會受到堆棧的限制。把一個引用變量的值賦予另一個相同類型的變量,就有兩個引用內存中同一對象的變量了。當一個引用變量出作用域時,它會從堆棧中刪除,如上一節所述,但引用對象的數據仍保留在堆中,一直到程序停止,或垃圾收集器刪除它為止,而只有在該數據不再被任何變量引用時,才會被刪除。

這就是引用數據類型的強大之處,在C#代碼中廣泛使用了這個特性。這說明,我們可以對數據的生存期進行非常強大的控制,因為只要有對數據的引用,該數據就肯定存在於堆上。

3  垃圾收集

 

由上面的討論和圖可以看出,托管堆的工作方式非常類似於堆棧,在某種程度上,對象會在內存中一個挨一個地放置,這樣就很容易使用指向下一個空閑存儲單元的堆指針,來確定下一個對象的位置。在堆上添加更多的對象時,也容易調整。但這比較復雜,因為基於堆的對象的生存期與引用它們的基於堆棧的變量的作用域不匹配。

在垃圾收集器運行時,會在堆中刪除不再引用的所有對象。在完成刪除動作后,堆會立即把對象分散開來,與已經釋放的內存混合在一起,如下圖所示。

 

 

如果托管的堆也是這樣,在其上給新對象分配內存就成為一個很難處理的過程,運行庫必須搜索整個堆,才能找到足夠大的內存塊來存儲每個新對象。但是,垃圾收集器不會讓堆處於這種狀態。只要它釋放了能釋放的所有對象,就會壓縮其他對象,把它們都移動回堆的端部,再次形成一個連續的塊。因此,堆可以繼續像堆棧那樣確定在什么地方存儲新對象。當然,在移動對象時,這些對象的所有引用都需要用正確的新地址來更新,但垃圾收集器也會處理更新問題。

垃圾收集器的這個壓縮操作是托管的堆與舊未托管的堆的區別所在。使用托管的堆,就只需要讀取堆指針的值即可,而不是搜索鏈接地址列表,來查找一個地方來放置新數據。因此,在.NET下實例化對象要快得多。有趣的是,訪問它們也比較快,因為對象會壓縮到堆上相同的內存區域,這樣需要交換的頁面較少。Microsoft相信,盡管垃圾收集器需要做一些工作,壓縮堆,修改它移動的所有對象引用,致使性能降低,但這些性能會得到彌補。

注意:

一般情況下,垃圾收集器在.NET運行庫認為需要時運行。可以通過調用System. GC.Collect(),強迫垃圾收集器在代碼的某個地方運行,System.GC是一個表示垃圾收集器的.NET基類, Collect()方法則調用垃圾收集器。但是,這種方式適用的場合很少,例如,代碼中有大量的對象剛剛停止引用,就適合調用垃圾收集器。但是,垃圾收集器的邏輯不能保證在一次垃圾收集過程中,所有未引用的對象都從堆中刪除。

4、釋放未托管的資源

垃圾收集器的出現意味着,通常不需要擔心不再需要的對象,只要讓這些對象的所有引用都超出作用域,並允許垃圾收集器在需要時釋放資源即可。但是,垃圾收集器不知道如何釋放未托管的資源(例如文件句柄、網絡連接和數據庫連接)。托管類在封裝對未托管資源的直接或間接引用時,需要制定專門的規則,確保未托管的資源在回收類的一個實例時釋放。

在定義一個類時,可以使用兩種機制來自動釋放未托管的資源。這些機制常常放在一起實現,因為每個機制都為問題提供了略為不同的解決方法。這兩個機制是:

  • ●       聲明一個析構函數(或終結器),作為類的一個成員
  • ●       在類中執行System.IDisposable接口

下面依次討論這兩個機制,然后介紹如何同時實現它們,以獲得最佳的效果。

11.2.1  析構函數

前面介紹了構造函數可以指定必須在創建類的實例時進行的某些操作,在垃圾收集器刪除對象之前,也可以調用析構函數。由於執行這個操作,所以析構函數初看起來似乎是放置釋放未托管資源、執行一般清理操作的代碼的最佳地方。但是,事情並不是如此簡單。

注意:

在討論C#中的析構函數時,在底層的.NET結構中,這些函數稱為終結器(finalizer)。在C#中定義析構函數時,編譯器發送給程序集的實際上是Finalize()方法。這不會影響源代碼,但如果需要查看程序集的內容,就應知道這個事實。

C++開發人員應很熟悉析構函數的語法,它看起來類似於一個方法,與包含類同名,但前面加上了一個發音符號(~)。它沒有返回類型,不帶參數,沒有訪問修飾符。下面是一個例子:

class MyClass

{

   ~MyClass() 

   {

      // destructor implementation

   }

}

 

C#編譯器在編譯析構函數時,會隱式地把析構函數的代碼編譯為Finalize()方法的對應代碼,確保執行父類的Finalize()方法。下面列出了編譯器為~MyClass()析構函數生成的IL的對應C#代碼:

protected override void Finalize()

{

   try

   {

      // destructor implementation

   }

   finally

   {

      base. Finalize();  

}

}

 

如上所示,在~MyClass()析構函數中執行的代碼封裝在Finalize()方法的一個try塊中。對父類Finalize()方法的調用放在finally塊中,確保該調用的執行。第13章會討論try塊和finally塊。

有經驗的C++開發人員大量使用了析構函數,有時不僅用於清理資源,還提供調試信息或執行其他任務。C#析構函數的使用要比在C++中少得多,與C++析構函數相比,C#析構函數的問題是它們的不確定性。在刪除C++對象時,其析構函數會立即運行。但由於垃圾收集器的工作方式,無法確定C#對象的析構函數何時執行。所以,不能在析構函數中放置需要在某一時刻運行的代碼,也不應使用能以任意順序對不同類實例調用的析構函數。如果對象占用了寶貴而重要的資源,應盡快釋放這些資源,此時就不能等待垃圾收集器來釋放了。

另一個問題是C#析構函數的執行會延遲對象最終從內存中刪除的時間。沒有析構函數的對象會在垃圾收集器的一次處理中從內存中刪除,但有析構函數的對象需要兩次處理才能刪除:第一次調用析構函數時,沒有刪除對象,第二次調用才真正刪除對象。另外,運行庫使用一個線程來執行所有對象的Finalize()方法。如果頻繁使用析構函數,而且使用它們執行長時間的清理任務,對性能的影響就會非常顯著。

11.2.2  IDisposable接口

在C#中,推薦使用System.IDisposable接口替代析構函數。IDisposable接口定義了一個模式(具有語言級的支持),為釋放未托管的資源提供了確定的機制,並避免產生析構函數固有的與垃圾函數器相關的問題。IDisposable接口聲明了一個方法Dispose(),它不帶參數,返回void,Myclass的方法Dispose()的執行代碼如下:

class Myclass : IDisposable

{

   public void Dispose()

   {

      // implementation

   }

}

 

Dispose()的執行代碼顯式釋放由對象直接使用的所有未托管資源,並在所有實現IDisposable接口的封裝對象上調用Dispose()。這樣,Dispose()方法在釋放未托管資源的時間方面提供了精確的控制。

假定有一個類ResourceGobbler,它使用某些外部資源,且執行IDisposable接口。如果要實例化這個類的實例,使用它,然后釋放它,就可以使用下面的代碼:

ResourceGobbler theInstance = new ResourceGobbler();

 

   // do your processing

 

theInstance.Dispose();

 

如果在處理過程中出現異常,這段代碼就沒有釋放theInstance使用的資源,所以應使用try塊(詳見第13章),編寫下面的代碼:

ResourceGobbler theInstance = null;

try

{

   theInstance = new ResourceGobbler();

// do your processing

}

finally 

{

   if (theInstance != null) 

{

theInstance.Dispose();

}

}

 

即使在處理過程中出現了異常,這個版本也可以確保總是在theInstance上調用Dispose(),總是釋放由theInstance使用的資源。但是,如果總是要重復這樣的結構,代碼就很容易被混淆。C#提供了一種語法,可以確保在執行IDisposable接口的對象的引用超出作用域時,在該對象上自動調用Dispose()。該語法使用了using關鍵字來完成這一工作—— 但目前,在完全不同的環境下,它與命名空間沒有關系。下面的代碼生成與try塊相對應的IL代碼:

using (ResourceGobbler theInstance = new ResourceGobbler())

{

   // do your processing

}

 

using語句的后面是一對圓括號,其中是引用變量的聲明和實例化,該語句使變量放在隨附的語句塊中。另外,在變量超出作用域時,即使出現異常,也會自動調用其Dispose()方法。如果已經使用try塊來捕獲其他異常,就會比較清晰,如果避免使用using語句,僅在已有的try塊的finally子句中調用Dispose(),還可以避免進行額外的縮進。

注意:

對於某些類來說,使用Close()方法要比Dispose()更富有邏輯性,例如,在處理文件或數據庫連接時就是這樣。在這些情況下,常常實現IDisposable接口,再執行一個獨立的Close()方法,來調用Dispose()。這種方法在類的使用上比較清晰,還支持C#提供的using語句。

11.2.3  實現IDisposable接口和析構函數

前面的章節討論了類所使用的釋放未托管資源的兩種方式:

  • ●       利用運行庫強制執行的析構函數,但析構函數的執行是不確定的,而且,由於垃圾收集器的工作方式,它會給運行庫增加不可接受的系統開銷。
  • ●       IDisposable接口提供了一種機制,允許類的用戶控制釋放資源的時間,但需要確保執行Dispose()。

一般情況下,最好的方法是執行這兩種機制,獲得這兩種機制的優點,克服其缺點。假定大多數程序員都能正確調用Dispose(),同時把執行析構函數作為一種安全的機制,以防沒有調用Dispose()。下面是一個雙重實現的例子:

public class ResourceHolder : IDisposable

{

private bool isDispose = false;

public void Dispose()

    {

        Dispose(true);

        GC.SuppressFinalize(this);

    }

 

   protected virtual void Dispose(bool disposing)

   {

      if (!isDisposed)

      {

          if (disposing)

{

              // Cleanup managed objects by calling their

// Dispose() methods.

          }

          // Cleanup unmanaged objects

       }

       isDisposed=true;

   }

 

   ~ResourceHolder()

   {

      Dispose (false);

   }

 

   public void SomeMethod()

   {

      // Ensure object not already disposed before execution of any method

      if(isDisposed)

      {

         throw new ObjectDisposedException("ResourceHolder");

      }

 

      // method implementation…

   }

}

 

可以看出,Dispose()有第二個protected重載方法,它帶一個bool參數,這是真正完成清理工作的方法。Dispose(bool)由析構函數和IDisposable.Dispose()調用。這個方式的重點是確保所有的清理代碼都放在一個地方。

傳遞給Dispose(bool)的參數表示Dispose(bool)是由析構函數調用,還是由IDisposable. Dispose()調用—— Dispose(bool)不應從代碼的其他地方調用,其原因是:

  • ●       如果客戶調用IDisposable.Dispose(),該客戶就指定應清理所有與該對象相關的資源,包括托管和非托管的資源。
  • ●       如果調用了析構函數,原則上所有的資源仍需要清理。但是在這種情況下,析構函數必須由垃圾收集器調用,而且不應訪問其他托管的對象,因為我們不再能確定它們的狀態了。在這種情況下,最好清理已知的未托管資源,希望引用的托管對象還有析構函數,執行自己的清理過程。

isDisposed成員變量表示對象是否已被刪除,並允許確保不多次刪除成員變量。它還允許在執行實例方法之前測試對象是否已釋放,如SomeMethod()所示。這個簡單的方法不是線程安全的,需要調用者確保在同一時刻只有一個線程調用方法。要求客戶進行同步是一個合理的假定,在整個.NET類庫中反復使用了這個假定(例如在集合類中)。第18章將討論線程和同步。

最后,IDisposable.Dispose()包含一個對System.GC.SuppressFinalize()方法的調用。GC 表示垃圾收集器,SuppressFinalize()方法則告訴垃圾收集器有一個類不再需要調用其析構函數了。因為Dispose()已經完成了所有需要的清理工作,所以析構函數不需要做任何工作。調用SuppressFinalize()就意味着垃圾收集器認為這個對象根本沒有析構函數。

 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM