深入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=1object o=i;

 

  那顯然,o所引用的對象在堆上占了4個字節多一些的大小(還有.NET用於識別和管理其類實例的一些信息:一個方法表指針和一個SyncBlockIndex),假設是6個字節。

  如果現在又給o綁定個long類型呢?

o=(long)100000000;

  如果只是把數據填充到原來的內存空間,這6個字節小廟恐怕容不下比8個字節還大的佛把。

  只能重新分配新的空間來保存新的對象了。

  string和object是兩個一旦初始化,就不可變的類型。(參見C#高級編程)。所謂不可變,包括了在內存中的大小不可變。大小一旦固定,修改其內容的方法和運算符實際上都是創建一個新對象,並分配新的內存空間,因為之前的大小可能不合適。究其根本,這是一個‘=’運算符的重載。

 

 


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM