1. 程式人生 > >再回首:值類型和引用類型

再回首:值類型和引用類型

數據結構 有趣 .cn 容易 val indent -s 繼續 聲明

前言

關於值類型和引用類型,這又是一個十分沈重的話題。

一般人都知道:

1、C#中又兩大數據類型,即:值類型和引用類型。

2、值類型存在在棧(又稱“堆棧”)上,引用類型存儲在堆上。

3、值類型轉換為引用類型會發生“裝箱”,引用類型轉換為值類型會發生“拆箱”;裝箱和拆箱過程會比較耗費資源(.NET後來提供了泛型來優化這一個過程)。

以上這些觀點,首先是有錯誤的,其次是很膚淺的。

這裏的堆和這裏的棧

這裏的堆和棧並不是直接等於數據結構中的堆棧。堆和棧都是內存中的空間,在C#世界中,內存不止這兩個角色:在C#中,內存分成5個區,即:

1、堆

2、棧

3、自由存儲區、全局/靜態存儲區

4、常量存儲區

眾所周知,在32位操作系統中分配給每個進程的最大內存是2G(總共4G,系統隱藏了2G,如果是企業用戶則分配為3G隱藏1G)。而我們從任務管理器中很容易看出我們會有多個進程同時發生,這樣4G*n 常常超出我們內存條容量大小。所以:這裏的內存,並不是指內存條的內存容量,而是虛擬內存(硬盤存儲)

技術分享

堆、棧(堆棧)是做什麽的

棧負責保存我們的代碼執行(或調用)路徑,而堆則負責保存對象(或者說數據,接下來將談到很多關於堆的問題)的路徑。

可以將棧想象成一堆從頂向下堆疊的盒子。當每調用一次方法時,我們將應用程序中所要發生的事情記錄在棧頂的一個盒子中,而我們每次只能夠使用棧頂的那個盒子。當我們棧頂的盒子被使用完之後,或者說方法執行完畢之後,我們將拋開這個盒子然後繼續使用棧頂上的新盒子。堆的工作原理比較相似,但大多數時候堆用作保存信息而非保存執行路徑,因此堆能夠在任意時間被訪問。與棧相比堆沒有任何訪問限制,堆就像床上的舊衣服,我們並沒有花時間去整理,那是因為可以隨時找到一件我們需要的衣服,而棧就像儲物櫃裏堆疊的鞋盒,我們只能從最頂層的盒子開始取,直到發現那只合適的。

技術分享

如何決定放哪兒?

1. 引用類型總是放在堆中。(夠簡單的吧?)

2. 值類型和指針總是放在它們被聲明的地方。(這條稍微復雜點,需要知道棧是如何工作的,然後才能斷定是在哪兒被聲明的。)

就像我們先前提到的,棧是負責保存我們的代碼執行(或調用)時的路徑。當我們的代碼開始調用一個方法時,將放置一段編碼指令(在方法中)到棧上,緊接著放置方法的參數,然後代碼執行到方法中的被“壓棧”至棧頂的變量位置。通過以下例子很容易理解...

下面是一個方法(Method):

public int AddFive(int pValue)

{
int result;
result = pValue + 5;
return result;
}

現在就來看看在棧頂發生了些什麽,記住我們所觀察的棧頂下實際已經壓入了許多別的內容。

首先方法(只包含需要執行的邏輯字節,即執行該方法的指令,而非方法體內的數據)入棧,緊接著是方法的參數入棧。(我們將在後面討論更多的參數傳遞)


技術分享

接著,控制(即執行方法的線程)被傳遞到堆棧中AddFive()的指令上,

技術分享

當方法執行時,我們需要在棧上為“result”變量分配一些內存,

技術分享

方法執行完成,然後方法的結果被返回。

技術分享

通過將棧指針指向AddFive()方法曾使用的可用的內存地址,所有在棧上的該方法所使用內存都被清空,且程序將自動回到棧上最初的方法調用的位置(在本例中不會看到)。

技術分享

在這個例子中,我們的"result"變量是被放置在棧上的,事實上,當值類型數據在方法體中被聲明時,它們都是被放置在棧上的。

值類型數據有時也被放置在堆上。記住這條規則--值類型總是放在它們被聲明的地方。好的,如果一個值類型數據在方法體外被聲明,且存在於一個引用類型中,那麽它將被堆中的引用類型所取代。

來看另一個例子:

假如我們有這樣一個MyInt類(它是引用類型因為它是一個類類型):

public class MyInt
{
publicint MyValue;
}

然後執行下面的方法:

public MyInt AddFive(int pValue)
{
MyInt result = new MyInt();
result.MyValue = pValue + 5;
return result;
}

就像前面提到的,方法及方法的參數被放置到棧上,接下來,控制被傳遞到堆棧中AddFive()的指令上。

技術分享

接著會出現一些有趣的現象...

因為"MyInt"是一個引用類型,它將被放置在堆上,同時在棧上生成一個指向這個堆的指針引用。

技術分享

在AddFive()方法被執行之後,我們將清空...

技術分享

我們將剩下孤獨的MyInt對象在堆中(棧中將不會存在任何指向MyInt對象的指針!)

技術分享

這就是垃圾回收器(後簡稱GC)起作用的地方。當我們的程序達到了一個特定的內存閥值,我們需要更多的堆空間的時候,GC開始起作用。GC將停止所有正在運行的線程,找出在堆中存在的所有不再被主程序訪問的對象,並刪除它們。然後GC會重新組織堆中所有剩下的對象來節省空間,並調整棧和堆中所有與這些對象相關的指針。你肯定會想到這個過程非常耗費性能,所以這時你就會知道為什麽我們需要如此重視棧和堆裏有些什麽,特別是在需要編寫高性能的代碼時。

內存中是如何存放的?

我們還是從這下面這一張圖片開始說起吧:

技術分享

高、底地址,我們姑且認為沒用實際意義,就像給內存空間編號(內存中按一字節即8位位一單元,依次編號)

下面舉兩個例子來說明:加入我們的搞地址開始位置編號是100000,低地址開始位置編號是100

1、

當前的堆棧指針為100000,這表明它的下一個自由存儲空間從99999開始,當我們在C#中聲明一個int類型的變量A,因為int類型是四個字節,所以它將分配在99996到99999這個存儲單元中。如果我們接著聲明double變量B(8字節),該變量將分配在99988到99995這個存儲單元。 如果代碼運行到他們的作用域之外,這時候A和B兩個變量都將被刪除,此時的順序正好相反,先刪除變量B,同時堆棧指針會遞增8,也就是重新指向到99996這個位置;接下來刪除變量A,堆棧指針重新指向10000。如果兩個變量是同時聲明的。如int A,B,此時我們並不知道A和B的分配順序,但是編譯器會確保他們的刪除順序正好和分配順序相反。

2、

了解堆棧上的分配方式之後,很明顯,它的性能相當高,同時我們也發現了它的一個缺點:變量的生存期必須嵌套。這對於某些情況來說是無法接受的,有時候我們需要存儲一些數據並且在方法退出後仍然能保證這部分數據是可以使用的。為此,虛擬內存另外分配了一部分區域,我們稱之為托管堆。托管堆和傳統的堆很大的一個不同點在於,托管堆在垃圾收集器的控制下進行工作。引用類型就分配在托管堆上,下面我們來看看引用類型的分配過程。  

假設我們需要聲明一個Person類並對它進行實例化。 Person p = new Person(); 首先, 系統會在堆棧上給p這個變量在堆棧上分配存儲空間,當然它只是一個引用而已,用來存放Person實例在托管堆上的位置,並沒有存放真正的Person實例。因為它僅僅是存放一個地址(一個整數值),所以它將在堆棧上占據4個字節的空間。接下來Person實例將會被存放在托管堆上。和堆棧不同,托管堆是由下往上分配的,假設這個實例需要占據10個字節,假設托管堆上的地址為100,那麽它將分配在100到109這個存儲單元。 需要註意的是,這個分配和實例的大小有關,如果實例小於85000字節,它會被分配在托管堆。如果超過了85000字節,它將被分配在LOH上 。

由此可見,這個分配過程比值類型的分配方式更為復雜,因此也就不可避免的有性能方面的損耗。這也是為什麽對於小數據量的數據結構我們更願意使用結構而不是類

一生二,二生萬物

“一”是object,“二”是值類型和引用類型,“萬物”就是C#程序員的各種代碼和程序。

再回首:值類型和引用類型