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

0x00 前言:

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

0x01 堆vs棧?

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

其實我更喜歡叫它託管堆,不過爲了簡便,匹夫仍是一概使用堆來代替了(要明白託管堆和堆不是一個東西)。爲何先聊堆呢?由於下面聊到棧的時候你會發現原來它們有不少類似的地方,不過棧作的更講究。堆的實現細節有不少(好比GC),因此拈輕怕重,咱們就聊聊它的設計思路,而不去考慮它是如何實現具體細節的。網絡

假設,咱們有很大一塊內存是爲了引用類型的實例準備的。同時,因爲可能有的實例還「活着」,換句話說就是還在這塊內存的某個地方,可是有的實例卻死了,換言之以前存放這個實例的內存已經解放了,因此這塊內存上以「是否存放有引用類型的實例」爲標準來看,是不連續的,或者說存在不少「洞」。而這些「洞」,纔是咱們能夠用來爲新實例分配的空間。閉包

因此一個思路就是造一個鏈表,用來存放這些不連續的「洞」,可是每一次分配空間時,都要去這個鏈表裏面檢查以尋找合適的「洞」,這顯然是一筆額外的開銷(因此pass掉)。函數

因此,咱們顯然更但願存放有類實例的內存在一塊兒,空閒的內存在一塊兒(頂端)。只有在這個前提下,咱們才能放心大膽的給新的類實例分配存儲空間,同時內存分配實現起來也十分容易,容易到什麼地步呢?你只須要一個指針的移動就能夠實現內存的分配。測試

爲了實現這個目的,下面就引入了咱們的常說的GC。(注:固然要具體聊聊GC,可能須要查閱更多的資料和寫更多的篇幅,並且可能更加索然無味,因此這裏匹夫只是簡單的引入,若是有錯誤也歡迎各位指出。)ui

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

  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系列也說過這一點。可是,可不要搞混指令和數據啊。

因此,能夠看到閉包狀況下的匿名函數和迭代器塊將它們的局部變量作成了類的字段,從而存儲在了堆上。

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

碼字不易。求個推薦

相關文章
相關標籤/搜索