-C#初學者經常被問的幾道辨析題,實值型別與參考型別,裝箱與拆箱,堆棧,這幾個概念組合之間區別,看完此篇應該可以解惑。
俗話說,用思想編程的是文藝程式猿,用經驗編程的是普通程式猿,用複製粘貼編程的是2B程式猿,開個玩笑^_^。
相信有過C#面試經曆的人,對下面這句話一定不陌生:
實值型別直接儲存其值,參考型別儲存對值的引用,實值型別存在堆棧上,參考型別儲存在託管堆上,實值型別轉為參考型別叫做裝箱,參考型別轉為實值型別叫拆箱。
但僅僅背過這句話是不夠的。
C#程式員不必手工管理記憶體,但要編寫高效的代碼,就仍需理解後台發生的事情。
在學校的時候老師們最常說的一句話是:概念不清。最簡單的例子,我熟記了所有的微積分公式,遇到題就套公式,但一樣會有套不上解不出的,因為我根本不清楚公式是怎麼推匯出來的,基本的原理沒弄清楚。
(有人死了,是為了讓我們好好的活著;有人死了,也不讓人好好活:牛頓和萊布尼茨=。=)。
有點扯遠了。下面大家來跟我一起探討下C#堆棧與託管堆的工作方式,深入到記憶體中來瞭解C#的以上幾個基本概念。
一,stack與heap在不同領域的概念
在C/C++中:
Stack叫做棧區,由編譯器自動分配釋放,存放函數的參數值,局部變數的值等。
Heap則稱之為堆區,由程式員分配釋放, 若程式員不釋放,程式結束時可能由OS回收。
而在C#中:
Stack是指堆棧,Heap是指託管堆,不同語言叫法不同,概念稍有差別。(此處若有錯誤,請指正)。
這裡最需要搞清楚的是在語言中stack與heap指的是記憶體中的某一個地區,區別於資料結構中的棧(後進先出的線性表),堆(經過某種排序的二叉樹)。
講一個概念之前,首先要說明它所處的背景。
若無特別說明,這篇文章講的堆棧指的就是Stack,託管堆指的就是Heap。
二,C#堆棧的工作方式
Windwos使用虛擬定址系統,把程式可用的記憶體位址映射到硬體記憶體中的實際地址,其作用是32位處理器上的每個進程都可以使用4GB的記憶體-無論電腦上有多少硬碟空間(在64位處理器上,這個數字更大些)。這4GB記憶體包含了程式的所有部份-可執行代碼,載入的DLL,所有的變數。這4GB記憶體稱為虛擬記憶體。
4GB的每個儲存單元都是從0開始往上排的。要訪問記憶體某個空間儲存的值。就需要提供該儲存單元的數字。在進階語言中,編譯器會把我們可以理解的名稱轉換為處理器可以理解的記憶體位址。
在進程的虛擬記憶體中,有一個地區稱為堆棧,用來儲存實值型別。另外在調用一個方法時,將使用堆棧複製傳遞給方法的所有參數。
我們注意一下C#中變數的範圍,如果變數a在變數b之前進入範圍,b就會先出範圍。看下面的例子:
{ int a; //do something { int b; //do something }}
聲明了a之後,在內部代碼塊中聲明了b,然後內部代碼塊終止,b就出了範圍,然後a才出範圍。在釋放變數的時候,其順序總是與給它們分配記憶體的順序相反,後進先出,是不是讓你想到了資料結構中的棧(LIFO--Last IN First Out)。這就是堆棧的工作方式。
我們不知道堆棧在地址空間的什麼地方,其實C#開發是不需要知道這些的。
堆棧指標,一個由作業系統維護的變數,指向堆棧中下一個自由空間的地址。程式第一次運行時,堆棧指標就指向為堆棧保留的記憶體塊的末尾。
堆棧是向下填充的,即從高地址向低地址填充。當資料入棧後,堆棧指標就會隨之調整,指向下一個自由空間。我們來舉個例子說明。
,堆棧指標800000,下一個自由空間是799999。下面的代碼會告訴編譯器需要一些儲存單元來儲存一個整數和一個雙精確度浮點數。
{ int a=1; double b = 1.1; //do something}
這兩個都是實值型別,自然是儲存在堆棧中。聲明a賦值1後,a進入範圍。int類型需要4個位元組,a就儲存在799996~799999上。此時,堆棧指標就減4,指向新的已用空間的末尾799996,下一個自由空間為799995。下一行聲明b賦值1.1後,double需要佔用8個位元組,所以儲存在799988~799995上,堆棧指標減去8。
當b出範圍時,電腦就知道這個變數已經不需要了。變數的生存期總是嵌套的,當b在範圍的時候,無論發生什麼事情,都可以保證堆棧指標一直指向儲存b的空間。
刪除這個b變數的時候堆棧指標遞增8,現在指向b曾經使用過的空間,此處就是放置閉合花括弧的地方。然後a也出範圍,堆棧指標再遞增4。
此時如果放入新的變數,從799999開始的儲存單元就會被覆蓋了。
二,託管堆的工作方式
堆棧有灰常高的效能,但要求變數的生命週期必須嵌套(後進先出決定的),在很多情況下,這種要求很過分。。。通常我們希望使用一個方法來分配記憶體,來儲存一些資料,並在方法退出後很長的一段時間內資料仍是可用的。用new運算子來請求空間,就存在這種可能性-例如所有參考型別。這時候就要用到託管堆了。
如果看官們編寫過需要管理低級記憶體的C++代碼,就會很熟悉堆(heap),託管堆與C++使用的堆不同,它在垃圾收集器的控制下工作,與傳統的堆相比有很顯著的效能優勢。
託管堆是進程可用4GB的另一個地區,我們用一個例子瞭解託管堆的工作原理和為引用資料類型分配記憶體。假設我們有一個Customer類。
1 void DoSomething()2 {3 Customer john;4 john = new Customer();
5 }
第三行代碼聲明了一個Customer的引用john,在堆棧上給這個引用分配儲存空間,但這隻是一個引用,而不是實際的Customer對象。john引用包含了儲存Customer對象的地址-需要4個位元組把0~4GB之間的地址儲存為一個整數-因此john引用佔4個位元組。
第四行代碼首先分配託管堆上的記憶體,用來儲存Customer執行個體,然後把變數john的值設定為分配給Customer對象的記憶體位址。
Customer是一個參考型別,因此是放在記憶體的託管堆中。為了方便討論,假設Customer對象佔用32位元組,包括它的執行個體欄位和.NET用於識別和管理其類執行個體的一些資訊。為了在託管堆中找到一個儲存新Customer對象的儲存位置,.NET運行庫會在堆中搜尋一塊連續的未使用的32位元組的空間,假定其起始地址是200000。
john引用占堆棧的799996~799999位置。執行個體化john對象前記憶體應該是這樣,。
給Customer對象分配空間後,記憶體內容。這裡與堆棧不同,堆上的記憶體是向上分配的,所有自由空間都在已用空間的上面。
以上例子可以看出,建議引用變數的過程比建立值變數的過程複雜的多,且不能避免效能的降低-.NET運行庫需要保持堆的資訊狀態,在堆添加新資料時,這些資訊也需要更新(這個會在堆的垃圾收集機制中提到)。儘管有這麼些效能損失,但還有一種機制,在給變數分配記憶體的時候,不會受到堆棧的限制:
把一個引用變數a的值賦給另一個相同類型的變數b,這兩個引用變數就都引用同一個對象了。當變數b出範圍的時候,它會被堆棧刪除,但它所引用的對象依然保留在堆上,因為還有一個變數a在引用這個對象。只有該對象的資料不再被任何變數引用時,它才會被刪除。
這就是引用資料類型的強大之處,我們可以對資料的生存周期進行自主的控制,只要有對資料的引用,該資料就肯定存於堆上。
三,託管堆的垃圾收集
對象不再被引用時,會刪除堆中已經不再被引用的對象。如果僅僅是這樣,久而久之,堆上的自由空間就會分散開來,給新對象分配記憶體就會很難處理,.NET運行庫必須搜尋整個堆才能找到一塊足夠大的記憶體塊來儲存整個新對象。
但託管堆的垃圾收集器運行時,只要它釋放了能釋放的對象,就會壓縮其他對象,把他們都推向堆的頂部,形成一個連續的塊。在移動對象的時候,需要更新所有對象引用的地址,會有效能損失。但使用託管堆,就只需要讀取堆積指標的值,而不用搜尋整個連結地址清單,來尋找一個地方放置新資料。
因此在.NET下執行個體化對象要快得多,因為對象都被壓縮到堆的相同記憶體地區,訪問對象時交換的頁面較少。Microsoft相信,儘管垃圾收集器需要做一些工作,修改它移動的所有對象引用,導致效能降低,但這樣效能會得到彌補。
四,裝箱與拆箱
有了上面的知識做鋪墊,看下面一段代碼
int i = 1; object o = i;//裝箱 int j = (int)o;//拆箱
int i=1;在堆棧中分配了一個4個位元組的空間來儲存變數 i 。
object o=i;
裝箱的過程: 首先在堆棧中分配一個4個位元組的空間來儲存引用變數 o,
然後在託管堆中分配了一定的空間來儲存 i 的拷貝,這個空間會比 i 所佔的空間稍大些,多了一個方法表指標和一個SyncBlockIndex,並返回該記憶體位址。
最後把這個地址賦值給變數o,o就是指向對象的引用了。o的值不論怎麼變化,i 的值也不會變,相反你 i 的值變化,o也不會變,因為它們儲存在不同的地方。
int j=int(o);
拆箱的過程:在堆棧分配4位元組的空間儲存變數J,拷貝o執行個體的值到j的記憶體,即賦值給j。
注意,只有裝箱的對象才能拆箱,當o不是裝箱後的int型時,如果執行上述代碼,會拋出一個異常。
這裡有一個警告,拆箱必須非常小心,確保該值變數有足夠的空間儲存拆箱後得到的值。
long a = 999999999; object b = a; int c = (int)b;
C#int只有32位,如果把64位的long值拆箱為int時,會產生一個InvalidCastExecption異常。
---------------------------------------------------------------我是分割線--------------------------------------------------------------
上述為個人理解,如果有任何問題,歡迎指正。希望這對各位看官理解一些基礎概念有協助。
根據_龍貓同學的提示,發現一個有趣的現象。我看來看下面一段代碼,假設我們有個Member 類,欄位有Name和Num:
Member member1 = new Member { Name = "Marry", Num = "001" };Member member2 = member1;member1.Name = "John";Console.WriteLine("member1.Name={0} member2.Name={1}",member1.Name,member2.Name);int i = 1;object o = i;object o2 = o;o = 2;Console.WriteLine("o={0} o2={1}", o, o2);string str1 = "Hello";string str2 = str1;str1 = "Hello,World!";Console.WriteLine("str1={0} str2={1}", str1, str2);Console.ReadKey();
按照我們之前的理論,member1和member2 引用的是堆裡面的同一個對象,修改了其中一個,另一個必然也會改變。
所以首先輸出應該是member1.Name=John member2.Name=John 這是毋庸置疑的。
那object和string是C#預定義的僅有的兩個參考型別,結果會如何呢?
按推理來說,預期的結果會是o=2 o2=2 以及str1=Hello,World! str2=Hello,World!。運行一下,OMG,錯咯。
結果是o=2 o2=1 以及str1=Hello,World! str2=Hello 。
這種現象的解釋是,(正如_龍貓給出的連結中的解釋)string類型比較特殊,因為一個string變數被建立之初,它在堆中所佔的空間大小就已經確定了。
修改一個string變數,如str1 = "Hello,World!",就必須重新分配合適空間來儲存更大的資料(較小時也會如此),即建立了新的對象,並更新str1儲存的地址,指向新的對象。
所以str2依然指向之前的對象。str1指向的是新建立的對象,兩者已是不同對象的引用。
至於object為什麼會如此,我弄懂再說。。。可能因為身為兩大預設參考型別,都是一個德行^_^
感謝_龍貓同學。不然我也也不會注意到這一點。
!回來了,其實哈,object和string果然是一個德行。object身為基類,它可以綁定所有的類型。比如先給他來個
int i=1;object o=i;
那顯然,o所引用的對象在堆上佔了4個位元組多一些的大小(還有.NET用於識別和管理其類執行個體的一些資訊:一個方法表指標和一個SyncBlockIndex),假設是6個位元組。
如果現在又給o綁定個long類型呢?
o=(long)100000000;
如果只是把資料填充到原來的記憶體空間,這6個位元組小廟恐怕容不下比8個位元組還大的佛把。
只能重新分配新的空間來儲存新的對象了。
string和object是兩個一旦初始化,就不可變的類型。(參見C#進階編程)。所謂不可變,包括了在記憶體中的大小不可變。大小一旦固定,修改其內容的方法和運算子實際上都是建立一個新對象,並分配新的記憶體空間,因為之前的大小可能不合適。究其根本,這是一個‘=’運算子的重載。