匹夫在日常和別人交流的時候,常常會發現一旦討論涉及到“類型”,話題的熱度就會立馬升溫,因為很多似是而非、或者片面的概念常常被人們當做是全面和正確的答案。加之最近在園子看到有人翻譯的《C#堆vs棧》系列,覺得也挺有趣,挺不錯的,所以匹夫今天也想從存儲位置的角度聊聊所謂的值類型,同時也想反駁一下簡單的“值棧類型”理論(自己起的名,指單純的把值類型當成總是在棧上存儲的類型)。
很多看官在想到存儲空間的分配的時候,往往會想到有一個東西叫內存,當然如果知識更牢靠的朋友能進一步知道還有所謂的堆和棧的概念。不錯,堆和棧應該是一談到存儲空間時,我們第一時間想到的。但是還有沒有什麼遺漏呢?的確有遺漏,如果你沒有考慮到寄存器的話。這裡匹夫先把寄存器提出來,是為了下面尾首呼應,關於寄存器的話題先按下不表。那拋開寄存器,又回到了我們看似熟悉的堆和棧的話題上。那就分別聊聊吧。
其實我更喜歡叫它托管堆,不過為了簡便,匹夫還是一律使用堆來代替了(要明白托管堆和堆不是一個東西)。為什麼先聊堆呢?因為下面聊到棧的時候你會發現原來它們有很多相似的地方,不過棧做的更講究。堆的實現細節有很多(比如GC),所以避重就輕,我們就聊聊它的設計思路,而不去考慮它是如何實現具體細節的。
假設,我們有很大一塊內存是為了引用類型的實例准備的。同時,由於可能有的實例還“活著”,換句話說就是還在這塊內存的某個地方,但是有的實例卻死了,換言之之前存放這個實例的內存已經解放了,所以這塊內存上以“是否存放有引用類型的實例”為標准來看,是不連續的,或者說存在很多“洞”。而這些“洞”,才是我們可以用來為新實例分配的空間。
所以一個思路就是造一個鏈表,用來存放這些不連續的“洞”,但是每一次分配空間時,都要去這個鏈表裡面檢查以尋找合適的“洞”,這顯然是一筆額外的開銷(所以pass掉)。
所以,我們顯然更希望存放有類實例的內存在一起,空閒的內存在一起(頂端)。只有在這個前提下,我們才能放心大膽的給新的類實例分配存儲空間,同時內存分配實現起來也十分容易,容易到什麼地步呢?你只需要一個指針的移動就可以實現內存的分配。
為了實現這個目的,下面就引入了我們的常說的GC。(注:當然要具體聊聊GC,可能需要查閱更多的資料和寫更多的篇幅,而且可能更加索然無味,所以這裡匹夫只是簡單的引入,如果有錯誤也歡迎各位指出。)
GC的行為過程可以分為三個階段,各位可能也都十分熟悉:
當然,GC的開銷還是比較大的,所以為了對實例區別對待,以提高效率,GC還有一個“代”的概念。簡單的說,就是按照實例的存活時間,將實例劃歸不同的部分。目的就是針對不同的存活時間,GC有不同的執行頻率。
所以可以看到堆的開銷很大一部分是由於有GC的存在,而GC的存在本身又是為了使堆分配新的空間更加容易。
棧和堆很像,假設你同樣有一塊空間用來存儲數據。那我們需要增加什麼樣的限定,來區分堆和棧呢?
還記得上面介紹堆時候匹夫說過的話嗎?“我們顯然更希望存放有類實例的內存在一起,空閒的內存在一起(頂端)”。而棧之所以是棧,就是因為棧底部存儲的數據總是會比頂部數據活的更長,也就是說,棧中的空間是有序的。頂部的數據總是先於底部的數據先死掉,也正是因為如此,棧中沒有堆中存在的“洞”,存儲空間的連續就意味著我們無需GC來對空間進行壓縮。(圖片來自網絡)
也正是因為我們總是知道棧頂是空的,而棧頂往下都是存活的數據,所以我們在分配新的數據時,只需要移動指針即可。想起了什麼嗎?不錯,棧無需GC就實現了堆所追求的分配新空間時的最佳形式。
還有什麼好處呢?對,我們同樣只需要移動指針就能重新分配棧的空間。由於完全只是指針的移動,所以和使用GC的堆相比(GC的標記,清理,壓縮,以及代的概念的引入),時間更少。
所以,如果只考慮在內存上分配存儲空間,堆和棧其實很相似。不同之處主要體現在GC的開銷上。
顯然,使用棧的效率要高於使用堆。但為什麼不都去使用棧呢?因為匹夫之前說過的,棧之所是棧的原因,就是因為棧底部存儲的數據總是會比頂部數據活的更長,只有能保證這個條件,我們才能使用棧。
那麼誰能夠保證呢?在回答這個問題之前,匹夫先提一個新的問題。
如果匹夫問你,C#中的變量有幾種形式呢?一定逃不掉的是值類型的實例,引用類型的實例。
但你有沒有發現一個問題呢?你真的直接操作過引用類型的實例嗎?
為什麼這麼問呢?
首先要提個問題:
TypeA a = new TypeA();
這裡的a是什麼呢?
首先,它不是值類型的實例。
其次,看著有點像是TypeA的實例啊?
錯,你可以說它指向一個TypeA的實例,但不能說它就是TypeA的實例。
不錯,它就是我們常說但也經常忽視的引用了。我們都是通過實例的引用去操作某個引用類型的實例的。
所以,變量有三種形式:
但是,這裡就有了一個很有趣的問題。我們都知道,引用類型的實例的空間分配在堆上。但是上例中的a的空間該如何分配呢?它是一個引用,而非引用類型的實例。它的值指向一塊分配在堆上的引用類型實例。但是它自己難道不需要存儲空間嗎?
所以我們應該明確,所有的變量都會被分配給相應的存儲空間。而引用的內容,指向另一塊存儲空間。
既然匹夫已經提了一個問題了,那麼就再提一個問題好了。既然上文多處提到了所謂的生命時間或者說生命周期,那麼“空間的生命周期”究竟應該如何定義?
那麼匹夫就先下個一個定義:存儲空間的生命周期指的是這塊空間中的內容的有效期。
生命周期有了,但是顯然還需要一個基准,來作為衡量生命周期長短的標准吧?
我們知道,方法是過程抽象的一種表現形式。所以,我們再定義一個以方法執行時間為標准的稱呼“活動周期”:從該方法開始執行到正常返回或拋出異常所消耗的時間。
而在這個方法的方法體內的變量,顯然要獲取其對應的存儲空間。如果變量要求的空間的生命周期要比該方法的活動周期還要長,那麼就被標記為“長壽”空間,否則就是“短壽”空間。
OK,回答完匹夫上面提到的2個問題,再結合上文匹夫提到過存儲空間類型,我們來看看微軟的處理。
OK,看完了微軟的處理方式之後,匹夫再給各位總結一下,順帶回答一下0x02節標題上的問題。
首先,我們可以看到在空間分配這個問題上,值類型實例和引用(不是引用類型實例哦)並無本質區別。也就是說,它們可以被分配在棧上、寄存器中以及堆上,這和它們是什麼類型無關,只和它們需要的空間的生命周期是“長壽”還是“短壽”有關。
其次,某天在某技術群中有人提問過lamda表達式中的值類型實例應該如何分配。在此匹夫也回答一下這個問題,數組中的元素、引用類型的字段、迭代器塊中的局部變量、匿名函數(lamda)中的局部變量所需要的空間生命周期都要長於方法的活動周期,即便是短於方法的活動周期,但是由於上述第4點,即對運行時來說難以判斷其生命周期的長短,故都按“長壽”空間計。所以都會被分配到堆上。
最後,回答一下本節題目中的問題。究竟誰能使用棧呢?
其實上文都已經回答過了,不過這裡匹夫還是舉個例子作答吧:一般方法中的值類型局部變量或臨時變量。
原因如下:
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
碼字不易。求個推薦