1. 程式人生 > 實用技巧 >C# 值型別和引用型別、堆和棧、裝箱和拆箱

C# 值型別和引用型別、堆和棧、裝箱和拆箱

一、概述 

值型別直接儲存其值,引用型別儲存對值的引用,值型別存在堆疊上,引用型別儲存在託管堆上,值型別轉為引用型別叫做裝箱,引用型別轉為值型別叫拆箱。

二、值型別和引用型別

C#值型別資料直接在他自身分配到的記憶體中儲存資料,而C#引用型別只是包含指向儲存資料位置的指標。C#值型別,我們可以把他歸納成三類:

值型別 基礎資料型別(除string) 浮點型 float 和 double
十進位制型 decimal
布林型 bool
整型 sbyte、byte、char、short、ushort、int、uint、long、ulong
結構型別 struct
列舉型別 enum
引用型別 string、class、interface、delegate、object、string、Array


所有值型別的資料都無法為null的,引用型別才允許為null。值型別是可以表示成可空型別的,可空型別可以為null。

三、棧(Stack)和堆(Heap)

1、堆疊stack:堆疊中儲存值型別。

  堆疊實際上是自上向下填充的,即由高記憶體地址指向低記憶體地址填充。

  堆疊的工作方式是先分配的記憶體變數後釋放(先進後出原則)。堆疊中的變數是從下向上釋放,這樣就保證了堆疊中先進後出的規則不與變數的生命週期起衝突!

  堆疊的效能非常高,但是對於所有的變數來說還不太靈活,而且變數的生命週期必須巢狀。

  通常我們希望使用一種方法分配記憶體來儲存資料,並且方法退出後很長一段時間內資料仍然可以使用。此時就要用到堆(託管堆)

2、C#堆疊的工作方式

  Windwos使用虛擬定址系統,把程式可用的記憶體地址對映到硬體記憶體中的實際地址,其作用是32位處理器上的每個程序都可以使用4GB的記憶體-無論計算機上有多少硬碟空間(在64位處理器上,這個數字更大些)。這4GB記憶體包含了程式的所有部份-可執行程式碼,載入的DLL,所有的變數。這4GB記憶體稱為虛擬記憶體。

  4GB的每個儲存單元都是從0開始往上排的。要訪問記憶體某個空間儲存的值。就需要提供該儲存單元的數字。在高階語言中,編譯器會把我們可以理解的名稱轉換為處理器可以理解的記憶體地址。

  在程序的虛擬記憶體中,有一個區域稱為堆疊,用來儲存值型別。另外在呼叫一個方法時,將使用堆疊複製傳遞給方法的所有引數。

  我們來看一下下面的小例子:

        public void Test()
        {
            int a;
            ///do something
            {
                int b;
                ///do something
            }
        }

聲明瞭a之後,在內部程式碼塊中聲明瞭b,然後內部程式碼塊終止,b就出了作用域,然後a才出作用域。在釋放變數的時候,其順序總是與給它們分配記憶體的順序相反,後進先出,這就是堆疊的工作方式。

堆疊是向下填充的,即從高地址向低地址填充。當資料入棧後,堆疊指標就會隨之調整,指向下一個自由空間。我們來舉個例子說明。

如圖,假如堆疊指標2000,下一個自由空間是1999。下面的程式碼會告訴編譯器需要一些儲存單元來儲存一個整數和一個雙精度浮點數。

            int c = 2;
            double d=3.5;
            ///do something

這兩個都是值型別,自然是儲存在堆疊中。宣告c賦值2後,c進入作用域。int型別需要4個位元組,c就儲存在1996~1999上。此時,堆疊指標就減4,指向新的已用空間的末尾1996,下一個自由空間為1995。下一行宣告d賦值3.5後,double需要佔用8個位元組,所以儲存在1988~1995上,堆疊指標減去8。

  當d出作用域時,計算機就知道這個變數已經不需要了。變數的生存期總是巢狀的,當d在作用域的時候,無論發生什麼事情,都可以保證堆疊指標一直指向儲存d的空間。刪除這個d變數的時候堆疊指標遞增8,現在指向d曾經使用過的空間,此處就是放置閉合花括號的地方。然後c也出作用域,堆疊指標再遞增4。

此時如果放入新的變數,從1999開始的儲存單元就會被覆蓋了。

3、堆(託管堆)heap堆(託管堆)儲存引用型別。

  此堆非彼堆,.NET中的堆由垃圾收集器自動管理。

  與堆疊不同,堆是從下往上分配,所以自由的空間都在已用空間的上面。

4、託管堆的工作方式

  堆疊有很高的效能,但要求變數的生命週期必須巢狀(後進先出)。通常我們希望使用一個方法來分配記憶體,來儲存一些資料,並在方法退出後很長的一段時間內資料仍是可用的。用new運算子來請求空間,就存在這種可能性-例如所有引用型別。這時候就要用到託管堆了。

託管堆是程序可用4GB的另一個區域,我們用一個例子瞭解託管堆的工作原理和為引用資料型別分配記憶體。假設我們有一個Cat類。

    public class Cat
    {
        public string Name { get; set; }
    }

來看下面這個最簡單的方法,當然著兩行程式碼,在第一節中也有提到過http://www.cnblogs.com/aehyok/p/3499822.html

1         public void Test()
2         {
3             Cat cat;
4             cat = new Cat();
5         }

  第三行程式碼聲明瞭一個Cat的引用cat,在堆疊上給這個引用分配儲存空間,但這只是一個引用,而不是實際的Cat物件。cat引用包含了儲存Cat物件的地址-需要4個位元組把0~4GB之間的地址儲存為一個整數-因此cat引用佔4個位元組。

  第四行程式碼首先分配託管堆上的記憶體,用來儲存Cat例項,然後把變數cat的值設定為分配給Cat物件的記憶體地址。

  Cat是一個引用型別,因此是放在記憶體的託管堆中。為了方便討論,假設Cat物件佔用32位元組,包括它的例項欄位和.NET用於識別和管理其類例項的一些資訊。為了在託管堆中找到一個儲存新Cat物件的儲存位置,.NET執行庫會在堆中搜索一塊連續的未使用的32位元組的空間,假定其起始地址是1000。而在堆疊中的記憶體地址的四個位元組為:1996到1999。在例項化cat之前應該是這樣的。

cat例項化,給Cat物件分配空間之後,記憶體變化為 cat在堆疊中使用1996到1999的記憶體地址,然後對Cat物件分配空間之後。

這裡與堆疊不同,堆上的記憶體是向上分配的,所有自由空間都在已用空間的上面。

  以上例子可以看出,建議引用變數的過程比建立值變數的過程複雜的多,且不能避免效能的降低-.NET執行庫需要保持堆的資訊狀態,在堆新增新資料時,這些資訊也需要更新(這個會在堆的垃圾收集機制中提到)。儘管有這麼些效能損失,但還有一種機制,在給變數分配記憶體的時候,不會受到堆疊的限制:

  把一個引用變數e的值賦給另一個相同型別的變數f,這兩個引用變數就都引用同一個物件了。當變數f出作用域的時候,它會被堆疊刪除,但它所引用的物件依然保留在堆上,因為還有一個變數e在引用這個物件。只有該物件的資料不再被任何變數引用時,它才會被刪除。

5、託管堆的垃圾收集

  物件不再被引用時,會刪除堆中已經不再被引用的物件。如果僅僅是這樣,久而久之,堆上的自由空間就會分散開來,給新物件分配記憶體就會很難處理,.NET執行庫必須搜尋整個堆才能找到一塊足夠大的記憶體塊來儲存整個新物件。

  但託管堆的垃圾收集器執行時,只要它釋放了能釋放的物件,就會壓縮其他物件,把他們都推向堆的頂部,形成一個連續的塊。在移動物件的時候,需要更新所有物件引用的地址,會有效能損失。但使用託管堆,就只需要讀取堆指標的值,而不用搜索整個連結地址列表,來查詢一個地方放置新資料。

  因此在.NET下例項化物件要快得多,因為物件都被壓縮到堆的相同記憶體區域,訪問物件時交換的頁面較少。Microsoft相信,儘管垃圾收集器需要做一些工作,修改它移動的所有物件引用,導致效能降低,但這樣效能會得到彌補。

三、裝箱和拆箱

1、概念

裝箱是將值型別轉換為引用型別 ;拆箱是將引用型別轉換為值型別。利用裝箱和拆箱功能,可通過允許值型別的任何值與Object 型別的值相互轉換,將值型別與引用型別連結起來。

程式碼如下:

static void Main(string[] args)
{    
    int val = 100; 
    object obj = val;//裝箱操作
    int num = (int)obj;//拆箱操作
    Console.WriteLine("num = {0}", num); 
    Console.ReadLine();
 }

2、為什麼需要裝箱

呼叫一個含型別為Object的引數的方法,該Object可支援任意為型,以便通用。當你需要將一個值型別(如Int32)傳入時,需要裝箱。

3、裝箱/拆箱的內部操作

裝箱:  
  第一步:新分配託管堆記憶體
  第二步:將值型別的例項欄位拷貝到新分配的記憶體中。
  第三步:返回託管堆中新分配物件的地址。這個地址就是一個指向物件的引用了。

拆箱:

  第一步:在堆疊上開闢記憶體

   第二步:將引用型別的值拷貝到堆疊上

  拆箱必須非常小心,確保該值變數有足夠的空間儲存拆箱後得到的值。C#int只有32位,如果把64位的long值拆箱為int時,會產生一個InvalidCastExecption異常。

總結:從原理上可以看出,裝箱時,生成的是全新的引用物件,這會有時間損耗,也就是造成效率降低。裝箱操作和拆箱操作是要額外耗費cpu和記憶體資源的,所以在c# 2.0之後引入了泛型來減少裝箱操作和拆箱操作消耗。

4、非泛型的裝箱和拆箱以及泛型

(1)非泛型裝箱拆箱

var array = new ArrayList();
array.Add(1);
array.Add(2);
foreach (int value in array)
{
    Console.WriteLine("value is {0}",value);
}

在這個過程中會發生兩次裝箱操作和兩次拆箱操作,在向ArrayList中新增int型別元素時會發生裝箱,在使用foreach列舉ArrayList中的int型別元素時會發生拆箱操作,將object型別轉換成int型別。如果ArrayList的元素個數很多,執行裝箱拆箱的操作會更多。所以儘量明確型別。

(2)泛型裝箱拆箱

var list = new List<int>();
list.Add(1);
list.Add(2);
 
foreach (int value in list)
{
Console.WriteLine("value is {0}", value);
}

程式碼和1中的程式碼的差別在於集合的型別使用了泛型的List,而非ArrayList。上述程式碼因為指定了型別,所以沒有進行裝箱和拆箱操作。可以看出泛型可以避免裝箱拆箱帶來的不必要的效能消耗;當然泛型的好處不止於此,泛型還可以增加程式的可讀性,使程式更容易被複用等等。