在C#中,變量的類型就屬引用類型,值類型,以及他們之間相互的轉換比較難理解,里面更是涉及到了類型在內存中的存儲結構,本文通過內存,棧,堆,值類型,引用類型的關系,以及相互轉換時產生的裝拆箱操作,來給大家梳理一下其中的過程,撥開各種層層的技術迷霧,探究其真正的本質。如果大家對過程產生疑問或者描述過程有錯誤的地方,歡迎在評論區中多多指正,大家一起學習,一起進步!
內存
內存的物理結構
在講數據結構之前,和大家先一起回顧下內存的物理結構是啥,內存的物理結構比較簡單,大部分人都見過內存條:

抽象出來之后的內存條模型:

內存實際上是一種名為內存IC的電子元件,內存IC中有電源、地址信號、數據信號、控制信號等用於輸入輸出的大量引腳(IC的引腳),通過為其指定地址,來進行數據的讀寫。VCC和GND是電源,A0~A9是地址信號的引腳,D0~D7是數據信號的引腳,RD和WR是控制信號的引腳。將電源連接到VCC和GND后,就可以給其他引腳傳遞比如0或者1這樣的信號。大多數情況下,+ 5V的直流電壓表示1,0V表示0。
上面的內存IC能存儲多少數據呢,數據信號引腳有D0~D7共八個,表示一次可以輸入輸出8位(=1字節)的數據。此外,地址信號引腳有A0~A9共十個,表示可以指定0000000000~1111111111共1024個地址。而地址用來表示數據的存儲場所,因此我們可以得出這個內存IC中可以存儲1024個1字節的數據。因為1024=1K,所以該內存IC的容量就是1KB。
現在大家使用的計算機至少有512M的內存。這就相當於512000個(512MB÷1KB=512000K)1KB的內存IC。當然,一台計算機中不太可能放入如此多的內存IC。通常情況下,計算機使用的內存IC中會有更多的地址信號引腳,這樣就能在一個內存IC中存儲數十兆字節的數據。因此,只用數個內存IC,就可以達到512MB的容量。如上實圖1GB的內存條,引腳比較多。
內存的讀寫
內存的寫入的實現,我們繼續來看剛才所說的1KB的內存IC。首先,我們假設要往該內存IC中寫入1字節的數據。為了實現該目的,可以給VCC接入+5V,給GND接入0V的電源,並使用A0~A9的地址信號來指定數據的存儲場所,然后再把數據的值輸入給D0~D7的數據信號,並把WR(write=寫入的簡寫)信號設定成1。執行完這些操作,就可以在內存IC內部寫入數據(如下圖中的a)了。
內存的讀取的實現,讀出數據時,只需通過A0~A9的地址信號指定數據的存儲場所,然后再將RD(read=讀出的簡寫)信號設成1即可。執行完這些操作,指定地址中存儲的數據就會被輸出到D0~D7的數據信號引腳(下圖中的b)中。
另外,像WR和RD這樣可以讓IC運行的信號稱為控制信號。其中,當WR和RD同時為0時,寫入和讀出的操作都無法進行。
內存IC內部有大量可以存儲8位數據的地方,通過地址指定這些場所,之后即可進行數據的讀寫。
內存的邏輯模型
內存的邏輯模型可以簡單理解為每層都存儲着數據的樓房,在這個樓房中,1層可以存儲1個字節的數據,樓層號表示的就是地址。同時並不需要過多地關注內存IC的電源和控制信號等。當內存為1KB時,表示有1024層的樓房(編程語言的數據類型空間沒有在這里體現):

在程序中,可以指定數據類型的占用空間的大小(占用的樓層數),程序定義三個變量,內存實際存放的空間如下:
// 定義變量
byte a=123; short b=123; int c=123;
其中,字節是二進制數據的單位,常用的字節是8位的字節,即包含8位的二進制數,因此,4字節就是32位,一個字節有符號代表2^7=128 ,無符號代表2^8=256。
內存地址的解析
地址,是用來標志存儲資源位置的,在計算機中用一串二進制數據表示,其中包含:
-
物理地址:加載到內存地址寄存器中的地址,內存單元的真正地址。在前端總線上傳輸的內存地址都是物理內存地址,編號從0開始一直到可用物理內存的最高端。這些數字被北橋(Nortbridge chip)映射到實際的內存條上。物理地址是明確的、最終用在總線上的編號,不必轉換,不必分頁,也沒有特權級檢查。
-
邏輯地址:CPU所生成的地址。邏輯地址是內部和編程使用的、並不唯一,邏輯地址的生成依賴與編譯器(源代碼編譯為機器碼)。
下圖是CPU和計算機的基本架構,我們以此圖來說明物理/邏輯地址在CPU和計算機中如何被解析處理的:
-
CPU中的算數邏輯單元看到的都是邏輯地址
-
當CPU需要把數據寫入內存或從內存中讀取時,MMU會把邏輯地址轉換成對應的物理地址
-
控制邏輯把數據、操作請求和物理地址發送到總線,分為讀請求和寫請求(寫請求,則把數據寫入內存,讀請求,則把數據從內存中讀取發送給CPU)
-
MMU負責邏輯地址和物理地址之間的轉換
-
操作系統負責建立邏輯地址和物理地址之間的映射關系
Windows使用了虛擬尋址系統技術,把程序的可用內存地址映射到硬件內存中的實際地址中,這些任務完全由Windows后台管理,實際結果是32位處理器上的每個進程都可以使用4GB的內存,有2G的用戶模式內存(無論計算機上有多少硬盤空間(在64位處理器上這個數值會更大))。這個4GB內存,2G用戶模式內存也叫虛擬地址空間,或叫虛擬內存
當然,這里也涉及到了許多內存管理相關的知識,比如連續的內存分配,非連續的內存分配的方式(虛擬內存)進行內存的相關管理和優化。有興趣的同學可以再深入探索。
程序在內存的分配
當一個exe程序(內容為再分配信息,變量組和函數組)被點擊時,此時程序會被加載到虛擬內存中,並且從虛擬內存地址轉換成實際的內存地址。虛擬內存會為程序額外生成2個組,那就是棧和堆。
棧是內存數組,是一個后進先出的數據結構(先進先出的稱為隊列),棧也成為堆棧,線程堆棧,每個正在運行的程序都對應着一個進程(或幾個,但是一個進程只能對應一個應用程序)在一個進程內部,可以有一個或多個線程(thread),每個線程都擁有一塊“自留地”,稱為“線程堆棧”,大小為1M,棧存儲下列的幾種類型數據:
- 某些類型變量的值(值類型)
- 程序當前的執行環境
- 傳遞給方法的參數
這部分的內存區域分配和釋放不需要程序員管理
堆是內存的一塊區域,在堆里可以分配大塊的內存用於存儲某類型的數據對象,C#中稱為托管堆,由CLR進行管理,與棧不同,堆里面的內存能夠以任務的順序存入和移除。同時因為這個特點,也會造成堆存儲的空間不連續,需要GC進行相應的處理。
exe文件中並不存在棧和堆的組。棧和堆需要的內存空間是在exe文件加載到內存后開始運行時得到分配的。因而,內存中的程序,就是由用於變量(全局變量,靜態變量,常量)的內存空間,用於函數的內存空間,用於棧的內存空間,用於堆的內存空間這4部分構成的。當然在內存中,加載windowds等操作系統的內存空間又是另外一回事了。
如下圖所示:
棧及堆的相似之處在於,他們的內存空間都是在程序運行時得到申請分配的。不過,在內存的使用方法上,二者存在些許不同。棧中對數據進行存儲和舍棄(清理處理)的代碼,是由編譯器自動生成的,因此不需要程序員的參與。使用棧的數據的內存空間,每當函數被調用時都會得到申請分配,並在函數處理完畢后自動釋放。與此相對,堆的內存空間,則要根據程序員編寫的程序,來明確進行申請分配或釋放。根據編程語言的不同,對堆用的內存空間進行申請分配和釋放的程序的編寫方法也是多種多樣的。
比如C語言需要程序員調用對應的方法函數來手動申請分配和釋放,C++需要用運算符來申請和釋放,當然C和C++不好操作的地方在於,如果沒有在程序中明確釋放堆的內存空間,那么即使在處理完畢后,該內存空間仍會一直殘留。這個現象稱為內存泄露。而C#和Java,都是使用GC自動垃圾回收機制來處理相關的問題,使得程序員不需要關心堆在內存中的管理問題。
關於GC如何工作的,請移步:
https://docs.microsoft.com/zh-cn/dotnet/standard/garbage-collection/
下面我們來具體看下數據在棧和堆中是怎么分配和存儲的
棧和堆
為啥內存中既然有了棧,為啥還用堆這種內存空間。因為棧的工作的方式是先分配內存的變量后面才釋放(先進后出),是從上往下填充(高內存地址到低內存地址)。但是很多變量不是單獨存在的,可能和其他的變量嵌套,這樣就和變量的生命周期起了沖突,為了解決這個問題,堆的設計就是從下往上分配,保證了棧中先進后出的規則不與變量的生命周期起沖突。為啥堆能解決沖突,還要設計棧這個結構呢,因為全部變量保存在堆中,會使得應用程序性能下降。
在介紹數據在棧和堆的存放原理時,需要介紹下C#中的數據類型
C#數據層次結構
數據類型如下如圖所示:

數據類型分:值類型和引用類型。值類型存放在棧(堆棧)中,引用類型先在棧中存放對應的引用地址,然后在堆(托管堆)中分配空間存放數據。
//創建一個對象
Student st; //聲明一個student的引用對象
st=new Student();
聲明st的對象引用的時候,會在棧中存放對應的引用地址(占用4個字節的空間,地址此時是空的信息,因為還沒創建對應的實例對象),這里僅僅是一個引用地址的信息存放,不是對應Student對象,接着第二行代碼,堆中的內存會給Student對象分配內存空間,假定Student對象的實例是32個字節,CLR需要搜索一個未使用且連續的內存空間來存儲對象的實例(大小為32*8位字節,同時需要提領指針,把分配給Student對象的實例地址賦值給st變量),如果沒有,這個時候,會涉及到GC強制的一次垃圾回收,如果回收后空間還是不夠,會拋出內存不足異常。
上面的例子告訴我們,建立對象引用的過程比建立值變量的過程復雜,且不能避免性能的降低,為了提升簡單和常用的類型的性能,CLR提供了名為“值類型”的輕量級類型。值類型實例變量不包含指向實例的指針。相反,變量中包含了實例本身的字段,由於變量已包含了實例的字段,所以操作實例中的字段不需要提領指針(Int32 a=new Int(); 所以值類型也是有實例對象)。值類型的實例不受垃圾回收器的控制。因此,值類型的使用緩解了托管堆的壓力,並減少了應用程序生存期內的垃圾回收的次數,提升了性能。
下面通過例子來演示引用類型和值類型的區別:
// 引用類型-類類型
class StudentRef{
public int age;
}
// 值類型-結構體類型
struct StudentVal{
public int age;
}
static void ValueTypeDemo(){
StudentRef r1=new StudentRef(); //在堆上分配
StudentVal v1=new StudentVal(); //在棧上分配
r1.age=18; //提領指針
v1.age=18; //在棧上修改
Console.WriteLine(r1.age); // 顯示"18"
Console.WriteLine(v1.age); // 同樣顯示"18"
// ****** 分割符 ******//
StudentRef r2=r1; // 只復制引用
StudentVal v2=v1; // 在棧上分配並復制成員
r1.age =20; // r1.age 和 r2.age 都會更改為20
v1.age =21; // v1.age 會更改為 21,v2.age 的值不會更改,為18
}
如圖:(對象指針的作用是用來關聯對象,同步索引的作用是用來完成同步(比如線程同步))

下面來看更加復雜的對象的存儲:
//定義一個學生的基類
class Student{
public void Study(){
Console.WriteLine("學習!");
}
public virtual int Credit(int x, int y){
Console.WriteLine($"總學分:{x+y},必修:{x},選修:{y}");
return x+y;
}
public static void Play(string s){
Console.WriteLine("玩耍:"+s);
}
}
//大一學生
class Freshman: Student{
//重寫學分的方法
public override int Credit(int x, int y){
Console.WriteLine($"大一學生總學分:{x+y}");
return x+y;
}
}
//實例化小明對象:
public void XiaoMing(){
int score; //1.總學分
//小明是大一學生
Student xm=new Freshman(); //2.實例化對象
score=xm.Credit(30,5); //3.調用虛方法,實際調用子類重寫之后的方法
xm.Study(); //4.調用實例方法
Student.Play("游戲"); //5.調用靜態方法,內存中,不管有多少個實例對象,靜態成員只有一份
// 第2個實例對象:小花
// Student xh=new Freshman(); //體現在圖中的Freshman實例2
}
如圖:

說明:
-
進程啟動,CLR加載到其中,托管堆初始化,創建線程(1M的棧空間),線程已經執行一些代碼,后調用XiaoMing()方法時,JIT編譯器將方法中的IL代碼轉換為本機CPU指令,加載方法中定義的類型涉及到的程序集,CLR提取相關數據到內存數據區,並初始化/創建一些數據結構來表示類型本身
-
在執行XiaoMing()方法之前,參數類型,變量類型已經創建完成(常用類型先加載)
-
類型對象指針和同步塊索引是每個對象都有的成員,每個類型對象都包含一個方法表,在方法表中,類型定義的每個方法都有對應的記錄項
Student類型有3個方法記錄項,Freshman類型只有一個,因為繼承關系,Freshman類型有專門的字段來引用基類型(其他類型同樣有),可以讓JIT編譯器追溯類層次結構(一直追溯到Object)
裝箱和拆箱
裝箱
值類型比引用類型“輕”,原因是他們不作為對象在托管堆中分配,不被GC回收,不用通過指針 進行引用。但是許多時候,都需要獲取對值類型實例的引用。比如:
//值類型
struct Point{
public Int32 x,y;
}
pulic sealed class Program{
public static void Main(){
ArrayList a =new ArrayList();
Point p; //分配一個Point(不在堆中分配空間)
for(Int32 i=0;i<10;i++){
p.x=p.y=i; //初始化值類型的成員
a.Add(p); // 將值類型裝箱,將引用添加到Arratlist中
}
}
}
每次循環迭代都初始化一個Point的值類字段,並將該Point存儲到ArrarList中。ArrayList的add方法:
public virtual Int32 Add(Object value);
Add方法獲取的是一個Object參數,也就是說,Add獲取對托管堆上的一個對象的引用(或指針)來作為參數。但是之前的代碼傳遞的是p,也就是Point,是值類型。為了使代碼正確工作,Point值類型必須轉換成真正的,在堆中托管的對象,而且必須獲取對該對象的引用。
將值類型轉換為引用類型,需要使用裝箱機制,該機制所發生的事情如下總結:
- 在托管堆中分配內存。分配的內存量是值類型各字段所需的內存量,還要加上托管堆所有對象都有兩個額外成員(類型對象指針和同步塊索引)所需的內存量。
- 值類型的字段復制到新分配的堆內存。
- 返回對象地址。現在該地址是對象引用;值類型成了引用類型。
注意:因為ArrayList會把所有插入其中的數據當作為object類型來處理,在我們使用ArrayList處理數據時,很可能會報類型不匹配的錯誤,也就是ArrayList不是類型安全的。可以用C#泛型來指定類型安全。
拆箱
講完裝箱,講講拆箱,如下代碼
Point p=(Point) a[0];
獲取ArrayList的元素0包含的引用(或指針),試圖將其放到Point值類型的實例p中。為此,已裝箱Point對象中的所有字段都必須復制到值類型變量p中,后者在線程棧上,CLR分2步完成復制:
- 獲取已裝箱Point對象中的各個Point字段地址。這個過程稱為拆箱
- 將字段包含的值從堆復制到基於棧的值類型實例當中
拆箱不是直接將裝箱過程倒過來。拆箱的代價要比裝箱低的多。拆箱其實就是獲取指針的過程,該指針指向包含在一個對象中的原始值類型(數據字段)。其實,指針指向的是已裝箱實例中的未裝箱部分。所以和裝箱不同,拆箱不要求在內存中復制任何字節。
裝箱和拆箱顯然會對應用程序的速度和內存消耗產生不利影響,所以應留意編譯器在什么時候生成代碼來自動進行這些操作。並嘗試手動編寫代碼,盡量減少這種情況的發生。
