注:下面的示意圖主要是為了輔助理解,不代表內存真實情況。
Introduction
類型基礎是C#的基礎概念,了解類型基礎及背后的工作原理更有助於我們在編碼的時候明白數據在內存中的分配與傳遞。C#提供了值類型和引用類型,值類型如struct, 引用類型如class。 這里主要說明一下它們在內存分配與傳遞上的區別。
一 內存分配
首先要了解一下內存中棧和堆的概念。
1. 棧(Stack)
##棧是一種先進后出的內存結構。
方法的調用追蹤就是在棧上完成的。比如我們有一個main方法(程序入口), 在main方法中會調用一個GetPoint的方法。在線程執行時,會將main方法壓入棧底(包括編譯好的方法指令,參數,和方法內部變量),然后再將GetPoint的方法壓入棧底,GetPoint中沒有調用其它方法,壓棧完畢。出棧順序是先進后出,也就是后進先出,棧頂的方法GetPoint先執行完畢,然后出棧,所占內存清空,接着main方法執行后出棧,所占內存清空。
//示意圖:自己腦補吧...
從上面方法的壓棧出棧中可以看出:
##棧只能在一端對數據進行操作,也就是棧頂端進行操作。’
##棧也是一種內存自我管理的結構,壓棧自動分配內存,出棧自動清空所占內存。
另外值得注意的兩點:
##棧中的內存不能動態請求,只能為大小確定的數據分配內存,靈活性不高,但是棧的執行效率很高。
##棧的可用空間並不大,所以我們在操作分配到棧上的數據時要注意數據的大小帶來的影響。
2.堆(Heap)
##堆與棧有所區別,堆在C#中用於存儲實實例對象,能存儲大量數據,而且堆能夠動態分配存儲空間。
##相比棧只能在一端操作,堆中的數據可以隨意存取。
##但堆的結構使得堆的執行效率不如棧高,而且不能自動回收使用過的對象。對於堆中的內存回收,C++程序員需要進行手動回收,這也是C++編程值得注意的一點,否則很容易造成內存溢出。而對於.NET程序員,平台提供了垃圾回收(GC)機制,可以自動回收堆中過期的對象(實現原理大概就是當發現沒有“引用”指向此對象時,表明此對象可以回收,此文主要討論值類型和引用類型,對於GC,感興趣的可以搜索相關資料)。
3.值類型和引用類型在棧和堆中的分配
這兒有兩個原則:
(1)創建引用類型時,runtime會為其分配兩個空間,一塊空間分配在堆上,存儲引用類型本身的數據,另一個塊空間分配在棧上,存儲對堆上數據的引用(實際上存儲的堆上的內存地址,也就是指針)。
(2)創建值類型時, runtime會為其分配一個空間,這個空間分配在變量創建的地方,如:
##如果值類型是在方法內部創建,則跟隨方法入棧,分配到棧上存儲。
##如果值類型是引用類型的成員變量,則跟隨引用類型,存儲在堆上。
在此我們舉例說明。
定義一個Point類:
public class Point { public double PointX { get; set; } public double PointY { get; set; } }
StartProgram類,有方法Start()和InitialPoint():
class StartProgram { void Start() { double pointX = 100.1; InitialPoint(pointX); } void InitialPoint(double pointX) { var point = new Point(); point.PointX = pointX; } }
示例分析:假設主線程從Start()進入執行,我們從分析一下方法中的變量在內存中的大致分配情況,不深究細節。
首先將Start()方法指令壓入棧底,然后壓入局部變量pointX;緊接着將InitialPoint()方法壓入棧底,形參pointX壓入棧底,在堆上實例化Point對象(包括其成員變量PointX和PointY),並在棧上創建point變量指向堆上的Point對象,最后給成員變量PointX賦值,參考圖如下:
注:注意不要混淆code中的pointx,雖然變量名相同,但是它們是不同的變量。
二 數據傳遞
1.按值傳遞原則
在C#中數據傳遞默認按值傳遞,先看一個示例。
現在有一個結構體PointSturct, 一個類PointClass:
public struct PointStruct { public double PointX { get; set; } public double PointY { get; set; } }
public class PointClass { public double PointX { get; set; } public double PointY { get; set; } }
並在一個方法中執行執行以下代碼:
1 void Excute() 2 { 3 var pointStruct1 = new PointStruct(); 4 var pointClass1 = new PointClass(); 5 var pointStruct2 = pointStruct1; 6 var pointClass2 = pointClass1; 7 }
示例分析:第3,4行代碼分別創建了一個結構體pointStruct1和一個類實例pointClass1, 結合上面的內存分配規則,對於pointSturct1,會在棧上分配內存存儲其數據本身,對於pointClass1,會在堆上分配內存存儲實例,且在棧上存儲指向實例的引用,參考圖如下:
經過執行5,6行代碼后,內存分配應該是怎樣的呢? 對於值類型(pointStruct1),會在棧上開辟一塊新的空間,將數據復制一份新的過去,因此pointStruct2和pointStruct1是互相獨立的,對其中一個的修改不會影響到另一個;對於引用類型(pointClass1),也會在棧上開辟一個新的空間,將棧上的引用復制到新的空間, 但是注意,此處復制的是棧上存儲的引用,也就是說棧上的兩個變量pointClass1和pointClass2雖然是不同的空間,但是它們存儲的引用, 都是指向堆上的同一實例,所以當通過pointClass2對實例的數據進行修改以后,通過pointClass1再訪問實例的數據,將會是修改過的數據,反之亦然。對於復制引用,我們打個比方,假如把堆上的實例比作學校,A同學記錄了學校的地址(引用),現在又來了B同學,復制引用就好比A同學把學校的地址抄了一份給B同學。參考圖如下:
2.參數傳遞
當程序中進行參數傳遞的時候,也是默認按值傳遞,值類型復制數據本身,形成獨立的數據塊,引用類型復制引用,指向同一實例。
我們將之前的StartProgram類中的方法改成如下 :
class StartProgram { void Start() { double pointX1 = 100.1; var point1 = new Point(); point1.PointX = 200.1; InitialPoint(pointX1, point1); Console.WriteLine(string.Format("pointX1:{0}", pointX1)); Console.WriteLine(string.Format("point1.PointX:{0}", point1.PointX)); Console.ReadKey(); } void InitialPoint(double pointX2, Point point2) { pointX2 = 300.1; point2.PointX = pointX2; } } /*Output:pointX1:100.1 point1.PointX:300.1
*/
示例分析:從輸出結果可以看到,pointX1還是原來的值,沒有受到pointX2影響,而point1.PointX的值是point2對PointX更改后的值。在內存中,將值類型pointX1傳遞給pointX2后,在棧上形成兩個獨立的內存塊,因此對pointX2更改后,並不會影響到pointX1;而對於引用類型point1,傳遞給point2后,它們兩塊內存存儲的引用指向同一實例,因此再InitialPoint()方法內對point2.PointX賦值為300.1后,再Start()方法里面取point1取PointX的值,也是300.1。
既然point1和point2指向同一實例,那么如果我們在InitialPoint()方法的最后將point2設置為null,會不會影響到Start()方法里的point1呢?用point.PointX取值的時候,會不會得到實例為null的異常呢?
void InitialPoint(double pointX2, Point point2) { pointX2 = 300.1; point2.PointX = pointX2; point2 = null; } /*Output:pointX1:100.1 point1.PointX:300.1 */
示例分析:還是會得到之前的結果,沒有檢測到null異常。這是因為point2設置為null的含義是,並不是將堆上的實例變為null,而是設置棧上的引用為null,注意,這和上一句代碼point2.PointX = pointX2是有區別的,上一句代碼的含義是,通過point2引用找到堆上的實例,對其屬性PointX進行更新。將point2設置為null后,point1仍然指向堆上的實例,因此可以訪問到更新后的實例屬性值。
我們也用上面的學校作類比,學校表示堆上的實例,A同學和B同學都有學校的地址(引用),將point2設置為null,就相當於銷毀B同學的地址,讓B同學找不到學校了,但是A同學仍然可以去學校,以及可以看到B同學之前在學校完成的作業(point2設置為null之前對實例數據的更新)。
參考圖如下:
3.按引用傳遞(Ref和Out關鍵字)
注:Ref和Out的區別在於Ref在傳遞前需要初始化。
我們知道C#中的Ref和Out關鍵字可以在值類型的傳參上實現跟引用類型一樣的效果,那么在引用類型參數上加入ref和out關鍵字跟默認的引用類型傳參有什么區別呢?很多人覺得應該沒有什么用,其實不然,我們繼續將StartProgram類的方法改為按ref傳遞,看看會有什么不同。
class StartProgram {void Start() { double pointX1 = 100.1; var point1 = new Point(); point1.PointX = 200.1; InitialPoint(ref pointX1, ref point1); Console.WriteLine(string.Format("pointX1:{0}", pointX1)); if (point1 != null) Console.WriteLine(string.Format("point1.PointX:{0}", point1.PointX)); else Console.WriteLine(string.Format("point1 is null")); Console.ReadKey(); } void InitialPoint(ref double pointX2, ref Point point2) { pointX2 = 300.1; point2.PointX = pointX2; point2 = null; } /*Output:
pointX1:300.1 point1 is null */
}
示例分析:從運行結果可以看到,對於值類型, pointX2對值的更改影響到了pointX1;對於引用類型,將point2設置為null后,point1也變成了null,之前我們沒有加ref參數的時候,point2設置為null,並不會影響到point1本身。我們可以看到,通過加入ref和out參數后,在內存中並不是像值傳遞一樣將棧上的數據拷貝一份到新的空間。在這里,我並沒有去研究C#對ref和out參數在內存上的實現原理,有興趣的可以深入研究。
Summary
本文從內存中棧和堆的結構特點出發,分析了C#值類型和引用類型在棧和堆上的分配情況,接着分析了數據傳遞過程,包括按值傳遞(賦值,參數傳遞),按引用傳遞(ref,out關鍵字),僅供參考。