1. 程式人生 > 其它 >【基礎概念】匹夫細說C#:不是“棧型別”的值型別,從生命週期聊儲存位置

【基礎概念】匹夫細說C#:不是“棧型別”的值型別,從生命週期聊儲存位置

轉載地址 https://www.cnblogs.com/murongxiaopifu/p/4419040.html

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

所以,可以看到閉包情況下的匿名函式和迭代器塊將它們的區域性變數做成了類的欄位,從而儲存在了堆上。

程式設計是個人愛好