值類型和引用類型的內存分配:
值類型變量與引用類型變量的內存分配模型不一樣。
爲了理解清楚這個問題,讀者首先必須區分兩種不同類型的內存區域:線程堆棧(Thread Stack)和託管堆(Managed Heap)。
每個正在運行的程序都對應着一個進程(process),在一個進程內部,可以有一個或多線程(thread),每個線程都擁有塊“自留地”,稱爲“線程堆棧”,大小爲1M,用於保存自身的一些數據,比如函數中定義的局部變量、函數調用時傳送的參數值等,這部分內存區域的分配與回收不需要程序員干涉。
所有值類型的變量都是在線程堆棧中分配的。另一塊內存區域稱爲“堆(heap)”,在.NET 這種託管環境下,堆由CLR 進行管理,所以又稱爲“託管堆(managed heap)”。用new 關鍵字創建的類的對象時,分配給對象的內存單元就位於託管堆中。
在程序中我們可以隨意地使用new 關鍵字創建多個對象,因此,託管堆中的內存資源是可以動態申請並使用的,當然用完了必須歸還。打個比方更易理解:託管堆相當於一個旅館,其中的房間相當於託管堆中所擁有的內存單元。
當程序員用new 方法創建對象時,相當於遊客向旅館預訂房間,旅館管理員會先看一下有沒有合適的空房間,有的話,就可以將此房間提供給遊客住宿。當遊客旅途結束,要辦理退房手續,房間又可以爲其他旅客提供服務了。
引用類型共有四種:類類型、接口類型、數組類型和委託類型。
所有引用類型變量所引用的對象,其內存都是在託管堆中分配的。
嚴格地說,我們常說的“對象變量”其實是類類型的引用變量。但在實際中人們經常將
引用類型的變量簡稱爲“對象變量”,用它來指代所有四種類型的引用變量。
堆棧和託管堆:
內存格局通常分爲四個區
全局數據區:存放全局變量,靜態數據,常量
代碼區:存放所有的程序代碼
棧區:存放爲運行而分配的局部變量,參數,返回數據,返回地址等,
堆區:即自由存儲區
堆棧和堆(託管堆)都在進程的虛擬內存中。(在32位處理器上每個進程的虛擬內存爲4GB)
堆棧stack
堆棧中存儲值類型。
堆棧實際上是向下填充,即由高內存地址指向低內存地址填充。
堆棧的工作方式是先分配內存的變量後釋放(先進後出原則)。
堆棧中的變量是從下向上釋放,這樣就保證了堆棧中先進後出的規則不與變量的生命週期起衝突!
堆棧的性能非常高,但是對於所有的變量來說還不太靈活,而且變量的生命週期必須嵌套。
通常我們希望使用一種方法分配內存來存儲數據,並且方法退出後很長一段時間內數據仍然可以使用。此時就要用到堆(託管堆)!
堆(託管堆)heap
堆(託管堆)存儲引用類型。
此堆非彼堆,.NET中的堆由垃圾收集器自動管理。
與堆棧不同,堆是從下往上分配,所以自由的空間都在已用空間的上面。
比如創建一個對象:
<span style="font-family:Arial Black;">Student stu;
stu = new Student();</span>
申明一個Student的引用cus,在堆棧上給這個引用分配存儲空間。這僅僅只是一個引用,不是實際的Student對象!
stu佔4個字節的空間,包含了存儲Student的引用地址。接着分配堆上的內存以存儲Student對象的實例,假定Student對象的實例是32字節,爲了在堆上找到一個存儲Student對象的存儲位置。
.NET運行庫在堆中搜索第一個從未使用的,32字節的連續塊存儲Student對象的實例!
然後把分配給Student對象實例的地址賦給stu變量!
從這個例子中可以看出,建立對象引用的過程比建立值變量的過程複雜,且不能避免性能的降低!
實際上就是.NET運行庫保存對的狀態信息,在堆中添加新數據時,堆棧中的引用變量也要更新。性能上損失很多!
有種機制在分配變量內存的時候,不會受到堆棧的限制:把一個引用變量的值賦給一個相同類型的變量,那麼這兩個變量就引用同一個堆中的對象。
當一個應用變量出作用域時,它會從堆棧中刪除。但引用對象的數據仍然保留在堆中,一直到程序結束 或者 該數據不被任何變量應用時,垃圾收集器會刪除它。
棧是內存中完全用於存儲局部變量或成員字段(值類型數據)的高效的區域,但其大小有限制。
託管堆所佔內存比棧大得多,當訪問速度較慢。託管堆只用於分配內存,一般由CLR(Common Language Runtime)來處理內存釋放問題。
當創建值類型數據時,在棧上分配內存;
當創建引用型數據時,在託管堆上分配內存並返回對象的引用。注意這個對象的引用,像其他局部變量一樣也是保存在棧中的。該引用指向的值則位於託管堆中。
如果創建了一個包含值類型的引用類型,比如數組,其元素的值也是存放在託管堆中而非棧中的。當從數組中檢索數據時,獲得本地使用的元素值的副本,而該副本這時候就是存放在棧中的了。所以,不能籠統的說“值類型保存在棧中,引用類型保存在託管堆中”。
值類型和引用類型的區別:引用類型存儲在託管堆的唯一位置中,其存在於託管堆中某個地方,由使用該實體的變量引用;而值類型存儲在使用它們的地方,有幾處在使用,就有幾個副本存在。
對於引用類型,如果在聲明變量的時候沒有使用new運算符,運行時就不會給它分配託管堆上的內存空間,而是在棧上給它分配一個包含null值的引用。對於值類型,運行時會給它分配棧上的空間,並調用默認的構造函數,來初始化對象的狀態。
託管堆優化:
看上去似乎很簡單,但是垃圾收集器實際採用的步驟和堆管理系統的其他部分並非微不足道,其中常常涉及爲提高性能而作的優化設計。舉例來說,垃圾收集遍歷整個內存池具有很高的開銷。然而,研究表明大部分在託管堆上分配的對象只有很短的生存期,因此堆被分成三個段,稱作generations。新分配的對象被放在generation 0中。這個generation是最先被回收的——在這個generation中最有可能找到不再使用的內存,由於它的尺寸很小(小到足以放進處理器的L2
cache中),因此在它裏面的回收將是最快和最高效的。
託管堆的另外一種優化操作與locality of reference規則有關。該規則表明,一起分配的對象經常被一起使用。如果對象們在堆中位置很緊湊的話,高速緩存的性能將會得到提高。由於託管堆的天性,對象們總是被分配在連續的地址上,託管堆總是保持緊湊,結果使得對象們始終彼此靠近,永遠不會分得很遠。這一點與標準堆提供的非託管代碼形成了鮮明的對比,在標準堆中,堆很容易變成碎片,而且一起分配的對象經常分得很遠。
還有一種優化是與大對象有關的。通常,大對象具有很長的生存期。當一個大對象在.NET託管堆中產生時,它被分配在堆的一個特殊部分中,這部分堆永遠不會被整理。因爲移動大對象所帶來的開銷超過了整理這部分堆所能提高的性能。
關於外部資源(External Resources)的問題
垃圾收集器能夠有效地管理從託管堆中釋放的資源,但是資源回收操作只有在內存緊張而觸發一個回收動作時才執行。那麼,類是怎樣來管理像數據庫連接或者窗口句柄這樣有限的資源的呢?等待,直到垃圾回收被觸發之後再清理數據庫連接或者文件句柄並不是一個好方法,這會嚴重降低系統的性能。
所有擁有外部資源的類,在這些資源已經不再用到的時候,都應當執行Close或者Dispose方法。從Beta2(譯註:本文中所有的Beta2均是指.NET Framework Beta2,不再特別註明)開始,Dispose模式通過IDisposable接口來實現。這將在本文的後續部分討論。
需要清理外部資源的類還應當實現一個終止操作(finalizer)。在C#中,創建終止操作的首選方式是在析構函數中實現,而在Framework層,終止操作的實現則是通過重載System.Object.Finalize 方法。以下兩種實現終止操作的方法是等效的:
- <span style=“font-family:Arial Black;”>~OverdueBookLocator()
- {
- Dispose(false);
- }
- 和:
- public void Finalize()
- {
- base.Finalize();
- Dispose(false);
- }</span>
<span style="font-family:Arial Black;">~OverdueBookLocator()
{
Dispose(false);
}
和:
public void Finalize()
{
base.Finalize();
Dispose(false);
}</span>
在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方法。這個方法被用來釋放外部資源並抑制終止操作,就象下面這個程序片斷所演示的那樣:
- <span style=“font-family:Arial Black;”> 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
- }
- } </span>
<span style="font-family:Arial Black;"> 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
}
} </span>
這些都是.NET中CLR的概念,和C#沒多大關係。
使用基於CLR的語言編譯器開發的代碼稱爲託管代碼。
託管堆是CLR中自動內存管理的基礎。初始化新進程時,運行時會爲進程保留一個連續的地址空間區域。這個保留的地址空間被稱爲託管堆。託管堆維護着一個指針,用它指向將在堆中分配的下一個對象的地址。最初,該指針設置爲指向託管堆的基址。
認真看MSDN Library,就會搞清楚這些概念。
以下代碼說明的很形象:
- <span style=“font-family:Arial Black;”><span style=“font-family:Arial Black;”>//引用類型(‘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
- } </span></span>
<span style="font-family:Arial Black;"><span style="font-family:Arial Black;">//引用類型('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
} </span></span>
垃圾回收機制 :
首先聲明一點所謂垃圾回收,回收的是分配在託管堆上的內存,對於託管堆外的內存,它無能爲力。
討論垃圾回收機制就不得不提內存的分配,在C運行時堆(C-runtime heap)中,堆是不連續的,我們new一個新的對象時,系統會檢查內存,找一塊足夠大的內存然後初始化對象,對象被銷燬後,這塊空間會用於初始化新的對象。這樣做有什麼弊端?隨着程序運行一直有對象生成釋放,內存會變得碎片化,這樣有新的大的對象要生成時就必須擴展堆的長度,碎片內存無法得到充分利用,還有一個弊端是每次創建一個對象時都要檢查堆內存,效率不高。而C#託管堆採取連續內存存儲,新創建對象時只要考慮剩下的堆內存是否足夠大就成,但一直生成對象而不析構會使託管堆無限增大,怎麼維護這樣一塊連續內存呢?這也就引出了垃圾回收機制。託管堆的大小是特定的,垃圾收集器GC負責當內存不夠的時候釋放掉垃圾對象,copy仍在使用的對象成一塊連續內存。而這就帶來了性能問題,當對象很大的時候,頻繁的copy移動對象會降低性能,所以C#的垃圾收集引入了世代和大對象堆小對象堆的概念。
所謂大對象堆小對象堆從字面意義就能看出其作用,大對象堆主要負責分配大的對象,小對象堆分配小的。但對象大小怎麼確定呢?在.NET Framework中規定,如果對象大於或等於 85,000 字節,將被視爲大型對象。當對象分配請求傳入後,如果符合該大小閾值,便會將此對象分配給大型對象堆。這個85000字節是根據性能優化的結果確定。值得注意的是垃圾回收對大對象堆和小對象堆都起作用。那麼分大對象和小對象堆作用是什麼呢?還是性能,對於大對象和小對象區別對待採取不同靈活的垃圾回收策略必定比一棍子打死死板的採用同一種策略要好。下面我們討論一下在SOH(大型對象堆)和LOH(小型對象堆)不同的垃圾收集策略:
先說一下世代,之所以分世代,是因爲在第0代就能清除大部分對象。請注意,世代是個邏輯上的概念,物理上並沒有世代這個數據結構。以小對象堆垃圾回收爲例:當一個對象被創建的時候,它被定義爲第0代對象,而經歷一次垃圾收集後還存餘的對象就被歸入了第1代對象,同理經過兩次或者兩次以上仍然存在的對象就可以看成第2代對象。雖然世代只是邏輯概念,但它卻是有大小的,對於SOH對象來說,由於每次垃圾回收都會壓縮移動對象,所以世代數越大越在堆底。經歷一次垃圾回收,對象都會被移入下一個世代的內存空間中(下圖以小對象堆上垃圾回收爲例。對象W至少經過兩次垃圾回收而不死,所以放入世代2,X經歷了一次垃圾回收)。而每次一個世代內存達到其闕值,都會引發垃圾收集器回收一次。這麼做的好處就是每次垃圾回收器只是回收一個世代的內存,從整體上來看,減少了對象複製移動的次數。
以上討論都是針對於SOH,對於SOH對象來說複製移動較之LOH成本性能要小一點,那麼對於LOH呢?複製一個LOH的對象並且清除原來內存位置的字節,成本相當大,怎麼確保它的垃圾回收呢?首先從物理上來說,LOH在託管堆的堆底,SOH在其上,邏輯上講LOH對象都分配在第二世代,也就是說前邊第0代和第1代的垃圾收集回收的都是第OH對象,這也就解釋了爲什麼前兩個世代垃圾回收中允許複製移動對象。但對於第二世代垃圾回收呢?第二代垃圾回收之後的對象仍是第二世代,其回收時並不移動仍在使用的對象,壓縮空間,而只是清除垃圾對象。當一個新LOH對象創建時,它會從堆底遍歷尋找LOH中能夠滿足要求的內存,如果沒有接着向堆頂創建(這個過程和C運行時工作原理一樣,所以也存在相同的弊端,LOH堆內存有可能存在碎片)。此時如果堆頂已經超出闕值,引發垃圾回收器回收內存空間。
從上邊討論中我們可以總結一下:我們new出一對象時它要麼小對象會被放入第0代,大對象會被分在LOH中,只有垃圾回收器才能夠在第1代和第2代中“分配”對象,這裏所說分配對象是指移動複製對象。