深入C#記憶體管理來分析 值型別、引用型別、裝箱、拆箱、堆疊幾個概念組合
-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#高階程式設計)。所謂不可變,包括了在記憶體中的大小不可變。大小一旦固定,修改其內容的方法和運算子實際上都是建立一個新物件,並分配新的記憶體空間,因為之前的大小可能不合適。究其根本,這是一個‘=’運算子的過載。