C#的記憶體管理:堆、棧、託管堆與指標

來源:互聯網
上載者:User

在32位的Windows作業系統中,每個進程都可以使用4GB的記憶體,這得益於虛擬定址技術,在這4GB的記憶體中儲存著可執行代碼、代碼載入的DLL和程式啟動並執行所有變數,在C#中,虛擬記憶體中有個兩個儲存變數的地區,一個稱為堆棧,一個稱為託管堆,託管堆的出現是.net不同於其他語言的地方,堆棧儲存實值型別資料,而託管堆儲存參考型別如類、對象,並受垃圾收集器的控制和管理。在堆棧中,一旦變數超出使用範圍,其使用的記憶體空間會被其他變數重新使用,這時其空間中儲存的值將被其他變數覆蓋而不複存在,但有時候我們希望這些值仍然存在,這就需要託管堆來實現。我們用幾段代碼來說明其工作原理,假設已經定義了一個類class1:

class1 object1;

object1=new class1();

第一句定義了一個class1的引用,實質上只是在堆棧中分配了一個4個位元組的空間,它將用來存府後來執行個體化對象在託管堆中的地址,在windows中這需要4個位元組來表示記憶體位址。第二句執行個體化object1對象,實際上是在託管堆中開僻了一個記憶體空間來儲存類class1的一個具體對象,假設這個對象需要36個位元組,那麼object1指向的實際上是在託管堆一個大小為36個位元組的連續記憶體空間開始的地址。由此也可以看出在C#編譯器中為什麼不允許使用未執行個體化的對象,因為這個對象在託管堆中還不存在。當對象不再使用時,這個被儲存在堆棧中的引用變數將被刪除,但是從上述機制可以看出,在託管堆中這個引用指向的對象仍然存在,其空間何時被釋放取決垃圾收集器而不是引用變數失去範圍時。

在使用電腦的過程中大家可能都有過這種經驗:電腦用久了以後程式運行會變得越來越慢,其中一個重要原因就是系統中存在大量記憶體片段,就是因為程式反覆在堆棧中建立和釋入變數,久而久之可用變數在記憶體中將不再是連續的記憶體空間,為了定址這些變數也會增加系統開銷。在.net中這種情形將得到很大改善,這是因為有了垃圾收集器的工作,垃圾收集器將會壓縮託管堆的記憶體空間,保證可用變數在一個連續的記憶體空間內,同時將堆棧中引用變數中的地址改為新的地址,這將會帶來額外的系統開銷,但是,其帶來的好處將會抵消這種影響,而另外一個好處是,程式員將不再花上大量的心思在內在泄露問題上。

當然,以C#程式中不僅僅只有參考型別的變數,仍然也存在實值型別和其他託管堆不能管理的對象,如果檔案名稱柄、網路連接和資料庫連接,這些變數的釋放仍需要程式員通過解構函式或IDispose介面來做。

另一方面,在某些時候C#程式也需要追求速度,比如對一個含用大量成員的數組的操作,如果仍使用傳統的類來操作,將不會得到很好的效能,因為數組在C#中實際是System.Array的執行個體,會儲存在託管堆中,這將會對運算造成大量的額外的操作,因為除了垃圾收集器除了會壓縮託管堆、更新引用地址、還會維護託管堆的資訊列表。所幸的是C#中同樣能夠通過不安全的程式碼使用C++程式員通常喜歡的方式來編碼,在標記為unsafe的代碼塊使用指標,這和在C++中使用指標沒有什麼不同,變數也是存府在堆棧中,在這種情況下聲明一個數組可以使用stackalloc文法,比如聲明一個儲存有50個double類型的數組:

double* pDouble=stackalloc double[50]

stackalloc會給pDouble數組在堆棧中分配50個double類型大小的記憶體空間,可以使用pDouble[0]、*(pDouble+1)這種方式運算元組,與在C++中一樣,使用指標時必須知道自己在做什麼,確保訪問的正確的記憶體空間,否則將會出現無法預料的錯誤。

掌握託管堆、堆棧、垃圾收集器和不安全的程式碼的工作原理和方式,將有助於你成為真正的優秀C#程式員。

進程中每個線程都有自己的堆棧,這是一段線程建立時保留下的地址地區。我們的“棧記憶體”即在此。至於“堆”記憶體,我個人認為在未用new定義時,堆應該就是未“保留”未“提交”的自由空間,new的功能是在這些自由空間中保留(並提交?)出一個位址範圍

棧(Stack)是作業系統在建立某個進程時或者線程(在支援多線程的作業系統中是線程)為這個線程建立的儲存地區,該地區具有FIFO的特性,在編譯的時候可以指定需要的Stack的大小。在編程中,例如C/C++中,所有的局部變數都是從棧中分配記憶體空間,實際上也不是什麼分配,只是從棧頂向上用就行,在退出函數的時候,只是修改棧指標就可以把棧中的內容銷毀,所以速度最快。  
  堆(Heap)是應用程式在啟動並執行時候請求作業系統分配給自己記憶體,一般是申請/給予的過程,C/C++分別用malloc/New請求分配Heap,用free/delete銷毀記憶體。由於從作業系統管理的記憶體配置所以在分配和銷毀時都要佔用時間,所以用堆的效率低的多!但是堆的好處是可以做的很大,C/C++對分配的Heap是不初始化的。  
  在Java中除了簡單類型(int,char等)都是在堆中分配記憶體,這也是程式慢的一個主要原因。但是跟C/C++不同,Java中分配Heap記憶體是自動初始化的。在Java中所有的對象(包括int的wrapper   Integer)都是在堆中分配的,但是這個對象的引用卻是在Stack中分配。也就是說在建立一個對象時從兩個地方都分配記憶體,在Heap中分配的記憶體實際建立這個對象,而在Stack中分配的記憶體只是一個指向這個堆對象的指標(引用)而已。

在.NET的所有技術中,最具爭議的恐怕是垃圾收集(Garbage Collection,GC)了。作為.NET架構中一個重要的部分,託管堆和垃圾收集機制對我們中的大部分人來說是陌生的概念。在這篇文章中將要討論託管堆,和你將從中得到怎樣的好處。
  為什麼要託管堆?
  .NET架構套件含一個託管堆,所有的.NET語言在分配參考型別對象時都要使用它。像實值型別這樣的輕量級對象始終分配在棧中,但是所有的類執行個體和數組都被產生在一個記憶體池中,這個記憶體池就是託管堆。
  垃圾收集器的基本演算法很簡單:
  ● 將所有的託管記憶體標記為垃圾
  ● 尋找正被使用的記憶體塊,並將他們標記為有效
  ● 釋放所有沒有被使用的記憶體塊
  ● 整理堆以減少片段
  託管堆最佳化
  看上去似乎很簡單,但是垃圾收集器實際採用的步驟和堆管理系統的其他部分並非微不足道,其中常常涉及為提高效能而作的最佳化設計。舉例來說,垃圾收集遍曆整個記憶體池具有很高的開銷。然而,研究表明大部分在託管堆上分配的對象只有很短的生存期,因此堆被分成三個段,稱作generations。新分配的對象被放在generation 0中。這個generation是最先被回收的——在這個generation中最有可能找到不再使用的記憶體,由於它的尺寸很小(小到足以放進處理器的L2 cache中),因此在它裡面的回收將是最快和最高效的。
  託管堆的另外一種最佳化操作與locality of reference規則有關。該規則表明,一起分配的對象經常被一起使用。如果對象們在堆中位置很緊湊的話,快取的效能將會得到提高。由於託管堆的天性,對象們總是被分配在連續的地址上,託管堆總是保持緊湊,結果使得對象們始終彼此靠近,永遠不會分得很遠。這一點與標準堆提供的Unmanaged 程式碼形成了鮮明的對比,在標準堆中,堆很容易變成片段,而且一起分配的對象經常分得很遠。
  還有一種最佳化是與大對象有關的。通常,大對象具有很長的生存期。當一個大對象在.NET託管堆中產生時,它被分配在堆的一個特殊部分中,這部分堆永遠不會被整理。因為移動大對象所帶來的開銷超過了整理這部分堆所能提高的效能。
  關於外部資源(External Resources)的問題
  垃圾收集器能夠有效地管理從託管堆中釋放的資源,但是資源回收操作只有在記憶體緊張而觸發一個回收動作時才執行。那麼,類是怎樣來管理像資料庫連接或者視窗控制代碼這樣有限的資源的呢?等待,直到記憶體回收被觸發之後再清理資料庫連接或者檔案控制代碼並不是一個好方法,這會嚴重降低系統的效能。
  所有擁有外部資源的類,在這些資源已經不再用到的時候,都應當執行Close或者Dispose方法。從Beta2(譯註:本文中所有的Beta2均是指.NET Framework Beta2,不再特別註明)開始,Dispose模式通過IDisposable介面來實現。這將在本文的後續部分討論。
  需要清理外部資源的類還應當實現一個終止操作(finalizer)。在C#中,建立終止操作的首選方式是在解構函式中實現,而在Framework層,終止操作的實現則是通過重載System.Object.Finalize 方法。以下兩種實現終止操作的方法是等效的:
  ~OverdueBookLocator()
  {
   Dispose(false);
  }
  和:
  public void Finalize()
  {
   base.Finalize();
   Dispose(false);
  }
  在C#中,同時在Finalize方法和解構函式實現終止操作將會導致錯誤的產生。
  除非你有足夠的理由,否則你不應該建立解構函式或者Finalize方法。終止操作會降低系統的效能,並且增加執行期的記憶體開銷。同時,由於終止操作被執行的方式,你並不能保證何時一個終止操作會被執行。
  記憶體配置和記憶體回收的細節
  對GC有了一個總體印象之後,讓我們來討論關於託管堆中的分配與回收工作的細節。託管堆看起來與我們已經熟悉的C++編程中的傳統的堆一點都不像。在傳統的堆中,資料結構習慣於使用大塊的空閑記憶體。在其中尋找特定大小的記憶體塊是一件很耗時的工作,尤其是當記憶體中充滿片段的時候。與此不同,在託管堆中,記憶體被組製成連續的數組,指標總是巡著已經被使用的記憶體和未被使用的記憶體之間的邊界移動。當記憶體被分配的時候,指標只是簡單地遞增——由此而來的一個好處是,分配操作的效率得到了很大的提升。
  當對象被分配的時候,它們一開始被放在generation 0中。當generation 0的大小快要達到它的上限的時候,一個只在generation 0中執行的回收操作被觸發。由於generation 0的大小很小,因此這將是一個非常快的GC過程。這個GC過程的結果是將generation 0徹底的重新整理了一遍。不再使用的對象被釋放,確實正被使用的對象被整理並移入generation 1中。
  當generation 1的大小隨著從generation 0中移入的對象數量的增加而接近它的上限的時候,一個回收動作被觸發來在generation 0和generation 1中執行GC過程。如同在generation 0中一樣,不再使用的對象被釋放,正在被使用的對象被整理並移入下一個generation中。大部分GC過程的主要目標是generation 0,因為在generation 0中最有可能存在大量的已不再使用的臨時對象。對generation 2的回收過程具有很高的開銷,並且此過程只有在generation 0和generation 1的GC過程不能釋放足夠的記憶體時才會被觸發。如果對generation 2的GC過程仍然不能釋放足夠的記憶體,那麼系統就會拋出OutOfMemoryException異常
  帶有終止操作的對象的垃圾收集過程要稍微複雜一些。當一個帶有終止操作的對象被標記為垃圾時,它並不會被立即釋放。相反,它會被放置在一個終止隊列(finalization queue)中,此隊列為這個對象建立一個引用,來避免這個對象被回收。後台線程為隊列中的每個對象執行它們各自的終止操作,並且將已經執行過終止操作的對象從終止隊列中刪除。只有那些已經執行過終止操作的對象才會在下一次記憶體回收過程中被從記憶體中刪除。這樣做的一個後果是,等待被終止的對象有可能在它被清除之前,被移入更高一級的generation中,從而增加它被清除的延遲時間。
  需要執行終止操作的對象應當實現IDisposable介面,以便客戶程式通過此介面快速執行終止動作。IDisposable介面包含一個方法——Dispose。這個被Beta2引入的介面,採用一種在Beta2之前就已經被廣泛使用的模式實現。從本質上講,一個需要終止操作的對象暴露出Dispose方法。這個方法被用來釋放外部資源並抑制終止操作,就象下面這個程式片斷所示範的那樣:
  public class OverdueBookLocator: IDisposable
  {
   ~OverdueBookLocator()
   {
   InternalDispose(false);
   }
   public void Dispose()
   {
   InternalDispose(true);
   }
   protected void InternalDispose(bool disposing)
   {
   if(disposing)
   {
   GC.SuppressFinalize(this);
   // Dispose of managed objects if disposing.
   }
   // free external resources here
   }
  }
在.NET的所有技術中,最具爭議的恐怕是垃圾收集(Garbage Collection,GC)了。作為.NET架構中一個重要的部分,託管堆和垃圾收集機制對我們中的大部分人來說是陌生的概念。在這篇文章中將要討論託管堆,和你將從中得到怎樣的好處。 為什麼要託管堆? .NET架構套件含一個託管堆,所有的.NET語言在分配參考型別對象時都要使用它。像實值型別這樣的輕量級對象始終分配在棧中,但是所有的類執行個體和數組都被產生在一個記憶體池中,這個記憶體池就是託管堆。 垃圾收集器的基本演算法很簡單: ● 將所有的託管記憶體標記為垃圾 ● 尋找正被使用的記憶體塊,並將他們標記為有效 ● 釋放所有沒有被使用的記憶體塊 ● 整理堆以減少片段 託管堆最佳化 看上去似乎很簡單,但是垃圾收集器實際採用的步驟和堆管理系統的其他部分並非微不足道,其中常常涉及為提高效能而作的最佳化設計。舉例來說,垃圾收集遍曆整個記憶體池具有很高的開銷。然而,研究表明大部分在託管堆上分配的對象只有很短的生存期,因此堆被分成三個段,稱作generations。新分配的對象被放在generation 0中。這個generation是最先被回收的——在這個generation中最有可能找到不再使用的記憶體,由於它的尺寸很小(小到足以放進處理器的L2 cache中),因此在它裡面的回收將是最快和最高效的。 託管堆的另外一種最佳化操作與locality of reference規則有關。該規則表明,一起分配的對象經常被一起使用。如果對象們在堆中位置很緊湊的話,快取的效能將會得到提高。由於託管堆的天性,對象們總是被分配在連續的地址上,託管堆總是保持緊湊,結果使得對象們始終彼此靠近,永遠不會分得很遠。這一點與標準堆提供的Unmanaged 程式碼形成了鮮明的對比,在標準堆中,堆很容易變成片段,而且一起分配的對象經常分得很遠。 還有一種最佳化是與大對象有關的。通常,大對象具有很長的生存期。當一個大對象在.NET託管堆中產生時,它被分配在堆的一個特殊部分中,這部分堆永遠不會被整理。因為移動大對象所帶來的開銷超過了整理這部分堆所能提高的效能。 關於外部資源(External Resources)的問題 垃圾收集器能夠有效地管理從託管堆中釋放的資源,但是資源回收操作只有在記憶體緊張而觸發一個回收動作時才執行。那麼,類是怎樣來管理像資料庫連接或者視窗控制代碼這樣有限的資源的呢?等待,直到記憶體回收被觸發之後再清理資料庫連接或者檔案控制代碼並不是一個好方法,這會嚴重降低系統的效能。 所有擁有外部資源的類,在這些資源已經不再用到的時候,都應當執行Close或者Dispose方法。從Beta2(譯註:本文中所有的Beta2均是指.NET Framework Beta2,不再特別註明)開始,Dispose模式通過IDisposable介面來實現。這將在本文的後續部分討論。 需要清理外部資源的類還應當實現一個終止操作(finalizer)。在C#中,建立終止操作的首選方式是在解構函式中實現,而在Framework層,終止操作的實現則是通過重載System.Object.Finalize 方法。以下兩種實現終止操作的方法是等效的: ~OverdueBookLocator() { Dispose(false); } 和: public void Finalize() { base.Finalize(); Dispose(false); } 在C#中,同時在Finalize方法和解構函式實現終止操作將會導致錯誤的產生。 除非你有足夠的理由,否則你不應該建立解構函式或者Finalize方法。終止操作會降低系統的效能,並且增加執行期的記憶體開銷。同時,由於終止操作被執行的方式,你並不能保證何時一個終止操作會被執行。 記憶體配置和記憶體回收的細節 對GC有了一個總體印象之後,讓我們來討論關於託管堆中的分配與回收工作的細節。託管堆看起來與我們已經熟悉的C++編程中的傳統的堆一點都不像。在傳統的堆中,資料結構習慣於使用大塊的空閑記憶體。在其中尋找特定大小的記憶體塊是一件很耗時的工作,尤其是當記憶體中充滿片段的時候。與此不同,在託管堆中,記憶體被組製成連續的數組,指標總是巡著已經被使用的記憶體和未被使用的記憶體之間的邊界移動。當記憶體被分配的時候,指標只是簡單地遞增——由此而來的一個好處是,分配操作的效率得到了很大的提升。 當對象被分配的時候,它們一開始被放在generation 0中。當generation 0的大小快要達到它的上限的時候,一個只在generation 0中執行的回收操作被觸發。由於generation 0的大小很小,因此這將是一個非常快的GC過程。這個GC過程的結果是將generation 0徹底的重新整理了一遍。不再使用的對象被釋放,確實正被使用的對象被整理並移入generation 1中。 當generation 1的大小隨著從generation 0中移入的對象數量的增加而接近它的上限的時候,一個回收動作被觸發來在generation 0和generation 1中執行GC過程。如同在generation 0中一樣,不再使用的對象被釋放,正在被使用的對象被整理並移入下一個generation中。大部分GC過程的主要目標是generation 0,因為在generation 0中最有可能存在大量的已不再使用的臨時對象。對generation 2的回收過程具有很高的開銷,並且此過程只有在generation 0和generation 1的GC過程不能釋放足夠的記憶體時才會被觸發。如果對generation 2的GC過程仍然不能釋放足夠的記憶體,那麼系統就會拋出OutOfMemoryException異常 帶有終止操作的對象的垃圾收集過程要稍微複雜一些。當一個帶有終止操作的對象被標記為垃圾時,它並不會被立即釋放。相反,它會被放置在一個終止隊列(finalization queue)中,此隊列為這個對象建立一個引用,來避免這個對象被回收。後台線程為隊列中的每個對象執行它們各自的終止操作,並且將已經執行過終止操作的對象從終止隊列中刪除。只有那些已經執行過終止操作的對象才會在下一次記憶體回收過程中被從記憶體中刪除。這樣做的一個後果是,等待被終止的對象有可能在它被清除之前,被移入更高一級的generation中,從而增加它被清除的延遲時間。 需要執行終止操作的對象應當實現IDisposable介面,以便客戶程式通過此介面快速執行終止動作。IDisposable介面包含一個方法——Dispose。這個被Beta2引入的介面,採用一種在Beta2之前就已經被廣泛使用的模式實現。從本質上講,一個需要終止操作的對象暴露出Dispose方法。這個方法被用來釋放外部資源並抑制終止操作,就象下面這個程式片斷所示範的那樣: public class OverdueBookLocator: IDisposable { ~OverdueBookLocator() { InternalDispose(false); } public void Dispose() { InternalDispose(true); } protected void InternalDispose(bool disposing) { if(disposing) { GC.SuppressFinalize(this); // Dispose of managed objects if disposing. } // free external resources here } }

這些都是.NET中CLR的概念,和C#沒多大關係。
使用基於CLR的語言編譯器開發的代碼稱為Managed 程式碼。
託管堆是CLR中自動記憶體管理的基礎。初始化新進程時,運行時會為進程保留一個連續的地址空間地區。這個保留的地址空間被稱為託管堆。託管堆維護著一個指標,用它指向將在堆中分配的下一個對象的地址。最初,該指標設定為指向託管堆的基址。
認真看MSDN Library,就會搞清楚這些概念。

以下代碼說明的很形象:

//參考型別('class' 類類型)
class SomeRef { public int32 x;}
 
//實值型別('struct')
struct SomeVal(pulic Int32 x;}

static void ValueTypeDemo()
{
  SomeRef r1=new SomeRef();//分配在託管堆
  SomeVal v1=new SomeVal();//堆棧上
  r1.x=5;//解析指標
  v1.x=5;//在堆棧上修改

  SomeRef r2=r1;//僅拷貝引用(指標)
  SomeVal v2=v1;//先在堆棧上分配,然後拷貝成員

  r1.x=8;//改變了r1,r2的值
  v1.x=9;//改變了v1,沒有改變v2
}

相關文章

聯繫我們

該頁面正文內容均來源於網絡整理,並不代表阿里雲官方的觀點,該頁面所提到的產品和服務也與阿里云無關,如果該頁面內容對您造成了困擾,歡迎寫郵件給我們,收到郵件我們將在5個工作日內處理。

如果您發現本社區中有涉嫌抄襲的內容,歡迎發送郵件至: info-contact@alibabacloud.com 進行舉報並提供相關證據,工作人員會在 5 個工作天內聯絡您,一經查實,本站將立刻刪除涉嫌侵權內容。

A Free Trial That Lets You Build Big!

Start building with 50+ products and up to 12 months usage for Elastic Compute Service

  • Sales Support

    1 on 1 presale consultation

  • After-Sales Support

    24/7 Technical Support 6 Free Tickets per Quarter Faster Response

  • Alibaba Cloud offers highly flexible support services tailored to meet your exact needs.