匹夫細說C#:不是“棧類型”的值類型,從生命周期聊存儲位置


0x00 前言:

匹夫在日常和別人交流的時候,常常會發現一旦討論涉及到“類型”,話題的熱度就會立馬升溫,因為很多似是而非、或者片面的概念常常被人們當做是全面和正確的答案。加之最近在園子看到有人翻譯的《C#堆vs棧》系列,覺得也挺有趣,挺不錯的,所以匹夫今天也想從存儲位置的角度聊聊所謂的值類型,同時也想反駁一下單純的把值類型當成總是存儲在棧上的觀點。

0x01 堆vs棧?

很多看官在想到存儲空間的分配的時候,往往會想到有一個東西叫內存,當然如果知識更牢靠的朋友能進一步知道還有所謂的堆和棧的概念。不錯,堆和棧應該是一談到存儲空間時,我們第一時間想到的。但是還有沒有什么遺漏呢?的確有遺漏,如果你沒有考慮到寄存器的話。這里匹夫先把寄存器提出來,是為了下面尾首呼應,關於寄存器的話題先按下不表。那拋開寄存器,又回到了我們看似熟悉的堆和棧的話題上。那就分別聊聊吧。

其實我更喜歡叫它托管堆,不過為了簡便,匹夫還是一律使用堆來代替了(要明白托管堆和堆不是一個東西)。為什么先聊堆呢?因為下面聊到棧的時候你會發現原來它們有很多相似的地方,不過棧做的更講究。堆的實現細節有很多(比如GC),所以避重就輕,我們就聊聊它的設計思路,而不去考慮它是如何實現具體細節的。

假設,我們有很大一塊內存是為了引用類型的實例准備的。同時,由於可能有的實例還“活着”,換句話說就是還在這塊內存的某個地方,但是有的實例卻死了,換言之之前存放這個實例的內存已經解放了,所以這塊內存上以“是否存放有引用類型的實例”為標准來看,是不連續的,或者說存在很多“洞”。而這些“洞”,才是我們可以用來為新實例分配的空間。

所以一個思路就是造一個鏈表,用來存放這些不連續的“洞”,但是每一次分配空間時,都要去這個鏈表里面檢查以尋找合適的“洞”,這顯然是一筆額外的開銷(所以pass掉)。

所以,我們顯然更希望存放有類實例的內存在一起,空閑的內存在一起(頂端)。只有在這個前提下,我們才能放心大膽的給新的類實例分配存儲空間,同時內存分配實現起來也十分容易,容易到什么地步呢?你只需要一個指針的移動就可以實現內存的分配。

為了實現這個目的,下面就引入了我們的常說的GC。(注:當然要具體聊聊GC,可能需要查閱更多的資料和寫更多的篇幅,而且可能更加索然無味,所以這里匹夫只是簡單的引入,如果有錯誤也歡迎各位指出。)

GC的行為過程可以分為三個階段,各位可能也都十分熟悉:

  1. 標記階段:首先堆上所有的實例在默認狀態下都假設是“死的”,但是CLR顯然知道哪些實例是活的,這樣在GC開始的時候,會將這些活着的實例標記為活着。
  2. 清理階段:沒有被標記的實例釋放空間
  3. 壓縮階段:堆重新組織,使存放活着的類實例的空間連在一起,已經釋放掉的空閑的空間連在一起。

當然,GC的開銷還是比較大的,所以為了對實例區別對待,以提高效率,GC還有一個“代”的概念。簡單的說,就是按照實例的存活時間,將實例划歸不同的部分。目的就是針對不同的存活時間,GC有不同的執行頻率。

所以可以看到堆的開銷很大一部分是由於有GC的存在,而GC的存在本身又是為了使堆分配新的空間更加容易。

 棧

棧和堆很像,假設你同樣有一塊空間用來存儲數據。那我們需要增加什么樣的限定,來區分堆和棧呢?

還記得上面介紹堆時候匹夫說過的話嗎?“我們顯然更希望存放有類實例的內存在一起,空閑的內存在一起(頂端)”。而棧之所以是棧,就是因為棧底部存儲的數據總是會比頂部數據活的更長,也就是說,棧中的空間是有序的。頂部的數據總是先於底部的數據先死掉,也正是因為如此,棧中沒有堆中存在的“洞”,存儲空間的連續就意味着我們無需GC來對空間進行壓縮。(圖片來自網絡)

 

也正是因為我們總是知道棧頂是空的,而棧頂往下都是存活的數據,所以我們在分配新的數據時,只需要移動指針即可。想起了什么嗎?不錯,棧無需GC就實現了堆所追求的分配新空間時的最佳形式。

還有什么好處呢?對,我們同樣只需要移動指針就能重新分配棧的空間。由於完全只是指針的移動,所以和使用GC的堆相比(GC的標記,清理,壓縮,以及代的概念的引入),時間更少。

所以,如果只考慮在內存上分配存儲空間,堆和棧其實很相似。不同之處主要體現在GC的開銷上。

0x02 誰“能”使用棧?

顯然,使用棧的效率要高於使用堆。但為什么不都去使用棧呢?因為匹夫之前說過的,棧之所是棧的原因,就是因為棧底部存儲的數據總是會比頂部數據活的更長,只有能保證這個條件,我們才能使用棧。

那么誰能夠保證呢?在回答這個問題之前,匹夫先提一個新的問題。

值(value)的第三種形式

如果匹夫問你,C#中的值有幾種形式呢?一定逃不掉的是值類型的實例,引用類型的實例。

但你有沒有發現一個問題呢?你真的直接操作過引用類型的實例嗎?

為什么這么問呢?

首先要提個問題:

TypeA a = new TypeA();

這里的a是什么呢?

首先,它不是值類型的實例。

其次,看着有點像是TypeA的實例啊?

錯,你可以說它指向一個TypeA的實例,但不能說它就是TypeA的實例。

不錯,a既不是值類型也不是引用類型的實例,而是我們常說但也經常忽視的“引用”(reference)了。我們都是通過“引用”去操作某個引用類型的實例的。

所以,值有三種形式:

  1. 值類型的實例
  2. 引用類型的實例
  3. 引用

但是,這里就有了一個很有趣的問題。我們都知道,引用類型的實例的空間分配在堆上。但是上例中a的值的空間該如何分配呢?它是一個引用,而非引用類型的實例。它的值指向一塊分配在堆上的引用類型實例。但是這個值自己難道不需要存儲空間嗎?

所以我們應該明確,所有的值都會被分配給相應的存儲空間。而以“引用”這種形式出現的值,關聯着另外一塊存儲空間。

空間的生命周期

既然匹夫已經提了一個問題了,那么就再提一個問題好了。既然上文多處提到了所謂的生命時間或者說生命周期,那么“空間的生命周期”究竟應該如何定義?

那么匹夫就先下個一個定義:存儲空間的生命周期指的是這塊空間中的內容的有效期。

生命周期有了,但是顯然還需要一個基准,來作為衡量生命周期長短的標准吧?

我們知道,方法是過程抽象的一種表現形式。所以,我們再定義一個以方法執行時間為標准的稱呼“活動周期”:從該方法開始執行到正常返回或拋出異常所消耗的時間。

而在這個方法的方法體內的變量,顯然要獲取其對應的存儲空間。如果變量要求的空間的生命周期要比該方法的活動周期還要長,那么就被標記為“長壽”空間,否則就是“短壽”空間

M$的空間分配的策略

OK,回答完匹夫上面提到的2個問題,再結合上文匹夫提到過存儲空間類型,我們來看看微軟的處理。

  1. 三種存儲類型:棧,堆,寄存器
  2. “長壽”空間永遠是堆空間。
  3. “短壽”空間永遠是棧空間或寄存器。
  4. 如果運行時很難判斷所需的存儲空間究竟是“長壽”的還是“短壽”的,為了避免錯誤,一律當做“長壽”空間處理。例如,引用類型的實例(不是引用本身哦)需要的空間永遠被當做“長壽”的。所以引用類型實例分配在堆上。

0x03 結論

OK,看完了微軟的處理方式之后,匹夫再給各位總結一下,順帶回答一下0x02節標題上的問題。

首先,我們可以看到在空間分配這個問題上,值類型實例和引用(不是引用類型實例哦)並無本質區別。也就是說,它們可以被分配在棧上、寄存器中以及堆上,這和它們是什么類型無關,只和它們需要的空間的生命周期是“長壽”還是“短壽”有關。

其次,某天在某技術群中有人提問過lamda表達式中的值類型實例應該如何分配。在此匹夫也回答一下這個問題,數組中的元素、引用類型的字段、迭代器塊中的局部變量、閉包情況下匿名函數(lamda)中的局部變量所需要的空間生命周期都要長於方法的活動周期,即便是短於方法的活動周期,但是由於上述第4點,即對運行時來說難以判斷其生命周期的長短,故都按“長壽”空間計。所以都會被分配到堆上

最后,回答一下本節題目中的問題。究竟誰能使用棧呢?

其實上文都已經回答過了,不過這里匹夫還是舉個例子作答吧:一般方法中的值類型局部變量或臨時變量。

原因如下:

  1. 生命周期符合棧底部存儲的數據總是會比頂部數據活的更長
  2. 值類型實例的值就是它自己,所以它們的存儲位置就是它們所在的位置。不會有引用指向它們。
  3. 同2,由於值類型的實例的值就是它自己,所以它不引用別人,不必關系引用的實例的生命周期。
  4. 說到底,還是和它的空間生命周期是長壽還是短壽有關

所以,單純的把值類型當成總是存儲在棧上是不准確的。而值類型之所叫“值類型”,其實和它的語義(semantic)有關,也就是說基於值類型的變量直接包含值(將一個值類型變量賦給另一個值類型變量時,將復制其包含的值。這與引用類型變量的賦值不同,引用類型變量的賦值只復制對對象的引用,不復制對象本身)。而和它的存儲空間分配策略無關,否則,為什么不叫“棧類型”和“堆類型”這樣的名稱呢?

0x04 后記補充

當然,從園友的回復來看,對迭代器塊中的局部變量、閉包情況下匿名函數中的局部變量也分配在堆上比較有異議。所以匹夫就寫個小例程,同時從更底層的CIL代碼的角度來看看這個問題。

using System;                                                                                                                                
using System.Collections.Generic;
class Program
{
    static void Main()
    {   
    }   
   //測試1
    static IEnumerable<int> Test1() {
        int i = 0;
        yield return i;
    }   
   //測試2
    static void Test2() {
        int i = 0;
        Action act = delegate {Console.WriteLine(i);};
    }   
}

之后,我們將這個小例子的源代碼編譯成CIL的形式,再來看看Test1和Test2的CIL實現。

Test1:

//迭代器部分Test1
.field  assembly  int32 '<i>__0' //聲明
.....
IL_0022:  ldc.i4.0  //取常數0壓棧
IL_0023:  stfld int32 Program/'<Foo>c__Iterator0'::'<i>__0' //stfld給字段'<i>__0' 賦值
...
IL_002a:  ldfld int32 Program/'<Foo>c__Iterator0'::'<i>__0'//從字段中'<i>__0'取值壓棧
IL_002f:  stfld int32 Program/'<Foo>c__Iterator0'::$current//賦值給$current

Test2:

//匿名函數部分Test2
.field  assembly  int32 i //聲明字段
....
IL_0007:  ldc.i4.0  //常數0壓棧
IL_0008:  stfld int32 Program/'<Test2>c__AnonStorey1'::i  //賦值給字段i
....
IL_0001:  ldfld int32 Program/'<Test2>c__AnonStorey1'::i //字段i中值壓棧
IL_0006:  call void class [mscorlib]System.Console::WriteLine(int32) //調用輸出

到此,不明真相的群眾可能又要說了。匹夫你的注釋里面寫的不都是棧棧棧棧嗎?那你還說是在堆上?你又騙人?

當然沒騙你,因為CIL的指令的確是運行在棧上的,匹夫之前的CIL系列也說過這一點。但是,可不要搞混指令和數據啊。

所以,可以看到閉包情況下的匿名函數和迭代器塊將它們的局部變量做成了類的字段,從而存儲在了堆上。

~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~

碼字不易。求個推薦


免責聲明!

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



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