一提到.net的類型,首當其衝的就是「引用類型」、「值類型」;咱們在面試中,也會常常被問「來講說值類型和引用類型。。。。」,這時候第一反應就是:「哎呀,這還不簡單,值類型是傳遞的值的copy,值對象存儲在棧中;引用類型傳的是引用,對該引用對象的修改都會影響到本來的內容,引用對象存儲在堆中」,額。。。每每第一時間想到此處,彷佛就「詞窮」了,不知道你有木有這樣的感受。哈哈哈哈!可是真理每每沒那麼簡單 - -!html
引用類型和值類型其實有一個很大的、而且很明顯,但容易被你們忽視的區別 「引用類型的對象是受GC控制的,而值類型的對象則不受GC控制」。linux
其實CLR針對於引用類型對象的建立,會額外有2個字段的開銷,一個是同步塊索引(SBI),另外一個是方法表指針(MT Pointer)。每一個引用類型的對象,在內容中都會額外建立這2個對象git
你們看到這2個名詞的時候,彷佛以爲既熟悉又陌生,不過其實也沒那麼玄奧,讓咱們往下看↓(下面的截圖中的地址,可能先後對不上,由於我本地重啓過)github
讓咱們先建立一個簡單的Person類:面試
public class Person { public int Id { get; set; } }
而後打個斷點:docker
接下來咱們按F5開啓調試,而且打開3個神器窗口windows
在解釋以前呢,我想說一句你們都知道的一句話 「經過C#編寫的cs文件,都是通過csc.exe編譯器,編譯成IL中間語言(.exe, .dll),而後由JIT即時轉化成本機代碼執行」,就目前而言,最終就是彙編代碼了,這也是爲啥要打開這3個窗口來剖析的緣由。囧~~~app
咱們能夠經過內存窗口,看看咱們建立的對象,在內存中究竟是怎麼佈局的。函數
當斷點執行到這個地方的時候,在內存中已經建立了Person對象,能夠經過寄存器指令ECX所對應的值02585DCC(都是16進制的),貼到內存窗口中佈局
回到咱們上面說的,一個引用類型的對象,除了方法表指針,應該還有一個同步塊索引,那SBI在哪裏呢?哈哈,其實就在方法表指針地址的上面,鼠標滑輪滾一滾就到了
而後繼續執行你的程序,給id賦值。
至此,你們經過以上的剖析,知道了引用類型對象在內存中長成什麼樣了
細心的同窗應該發現了,SBI永遠是在MT的上面(偏移負4個字節,x32位系統)
那最後咱們這個對象的內存佈局,從宏觀上看應該是(以32位系統爲例):
看到這裏,小夥伴們不放按照我上面的步驟,動手試一下,會加深本身的理解。
不過我相信,有的小夥伴對上面的MT和SBI並無直觀的印象,這2個傢伙有啥用的,爲啥CLR在建立引用類型的對象的時候會用到它呢,且看下面的代碼
下面是你們常見的,鎖機制的代碼
咱們先快速定位到p1對象的SBI,以下圖
由上能夠得出,CLR中的lock機制,實際上是經過對象的SBI來實現的,上鎖則設置成1(其實這個1是,當前執行線程的id號,你能夠試試上書lock代碼外面包一層Task,你會發現可能不是1了,不要誤解凡是上鎖的地方要麼1,要麼0),lock結束則重置成0,這就是同步塊索引的用處之一,沒錯,是之一,他尚未其餘用途(一些你們日常掛在嘴邊,可是不多去深挖的)。
看到這個案例,不知道你們有沒有想起一個面試題,爲啥lock不能鎖值類型對象,其實本質緣由是:值類型對象是沒有SBI的,從而CLR沒法實現lock(下面說值類型的時候,會作詳細的闡述),在代碼的編譯階段vs就會給你報錯了
且看以下代碼
細心的同窗在內存中,能夠看到,p1和p2的MT指向的是相同的地址,這也是CLR優化的地方,由於二者的對象類型都是Person.
你們也許對lldb陌生,不過應該對windbg不陌生,lldb是和windbg同樣,能夠抓dump,分析內存的一個組件,在core裏面,咱們大部分狀況會把咱們的app部署到linux,或者容器中,這個時候
windbg是無法用的(windows),附上lldb相關連接:
https://docs.microsoft.com/en-us/dotnet/framework/tools/sos-dll-sos-debugging-extension#commands
https://lldb.llvm.org/lldb-gdb.html
根據上述結果,咱們能夠找到Person對象的MT地址,咱們看看具體裏面有些什麼,執行dumpmt -md MT的地址
從圖中咱們得知,一個引用類型的對象的MT指針,所指向的內容包含:EEClass、token、size、以及很重要要的MethodDesc Table(方法表)
細心的同窗會發現,方法表除了包含Person類自己的方法,還有它的基類方法。方法表是程序運行時供CLR選擇對應的方法進行調用的。
上述信息有一列JIT,它有3個狀態,分別解釋下:
PreJIT:該方法被NGEN(Native Image Generator) 編譯了。
JIT:該方法,CLR在runtime的時候被JIT即時編譯了。
NONE:該方法,暫時尚未被編譯。(回到咱們上面的代碼,咱們只對id作了set,並無get,因此get_Id()的JIT狀態是NONE)。
接下來,咱們看下Person類的構造函數所對應的本機代碼,咱們執行下:dumpmd 方法描述地址
繼續執行:u codeaddress
可是當前的插件版本,彷佛不支持u命令,那咱們查下當前sos plugin支持的命令有哪些
u命令不行,那咱們用它的全稱去嘗試下 clru 方法地址:
咱們當前看到的就是,Person類的構造函數,所對應的代碼了。
咱們在看看Person類的構造函數,所對應的IL代碼:
值類型對象分配的地方不是在Managed Heap(引用類型),而是在當前程序所在的執行線程的Stack裏(thread stack)。
咱們先建立一個簡單的結構體:
public struct Line { public int Length { get; set; } }
而後,老樣子,開啓F5調試,打開咱們的3大神器窗口:
ps:細心的同窗有沒有發現,這個截圖裏的地址,是16位長度的16進制來顯示的顯示的,上面講引用類型的時候,截圖是8位長度的16進制來顯示的。
其實上面的環境是x86, 下面的是x64位系統:以64位系統舉例,16進制的F,表示成二進制則是1111,那麼想表達64位的話,16進制的長度就爲64%4=16;同理32位系統,想要表達32位的話,16進制的長度就爲32%4=8。
有的同窗在本身vs行試的時候,也許不是上圖的16長度,由於我爲了使用lldb,方便我剖析問題,我建立了一個.net core 2.0的console項目。在調試的時候,vs會默認啓動你本機安裝的dotnet.exe程序。個人電腦安裝列表以下
我裝的都是64位的,若是你想vs能調試32位的話(像我當前的狀況的話,你以32位環境調試的話,會直接crash的),你須要安裝dotnet的32位版本的sdk才行,爲何須要這樣,詳情戳這裏。
不過。。。。64位調試下,也沒啥影響,咱們繼續
哈哈,會對比的同窗,看到這裏,應該發現了2點不同了:
1. 值類型對象並無MT和SBI
2. 值類型對象的對象存儲是從 高地址位--->低地址位
通過上面的剖析,咱們引出咱們常常遇到的一個問題「裝箱(boxing)」,你們都知道裝箱會帶來性能問題(我的以爲,看狀況,畢竟如今硬件這麼牛逼,某些場景下不必吹毛求疵),可是你們思考下,性能的問題,具體體如今哪裏呢?
帶着問題,咱們再改下咱們的代碼:
經過IL代碼,很容易看出,咱們把obj裝箱了,那咱們看看裝箱後的對象obj長什麼樣:
根據上面的例子,其實已經驗證了,值類型對象通過boxing以後,CLR在內存中,其實建立了一個引用類型對象,而後把值對象的值copy過來,產生MT和SBI。因此裝箱的性能損耗也顯而易見了。
而且,值類型對象一旦boxing以後,新產生的obj,它的釋放,則交由GC來控制。這也給GC間接增長了壓力(仍是以前提到的那句話,如今硬件這麼牛逼了,某些場景下,不要吹毛求疵,囧~~~~)
咱們不妨用lldb再來深刻驗證下,新產生的對象,到底存不存在,咱們稍微調整下咱們的代碼,方便咱們作驗證:
此時,咱們當前的內存裏,應該是沒有obj對象的(被註釋了,固然沒有了。。囧),l1也並無被裝箱,而後咱們經過lldb的dumpheap -stat指令來看下,託管堆裏的對象有哪些。
強調下哈,dumpheap指令看的是託管堆對象、託管堆對象、託管堆對象,重要的事情說三遍,因此值類型的對象,不該該也不可能出如今該指令下,向下看↓↓↓↓↓↓
由上圖咱們看到,其實並無任何和Line相關的對象信息,到當前位置,一切的現象都是十分正確的。
接下來,咱們改下代碼,進行boxing,咱們再來對比下:
上圖能夠獲得,我圈起來,其實就是咱們的obj對象,它是由l1 boxing而來的,類型爲Line,咱們繼續執行下咱們上面執行過的指令,看看這個boxing而來的對象,內部到底長什麼樣!?
至此,對於.net的類型,是否是又多了一層認知,除了知道2者的傳值方式的不一樣、直接繼承類的不一樣、Compare的不一樣,還有他內存分佈的不一樣
相信小夥伴們,之後再回答這類問題的時候,又多了一個關注點。
ps:文章中,有不少步驟並無細說,好比docker中怎麼使用lldb,lldb指令的詳解,3個vs窗口的使用等等,都是一筆帶過,後面有時間再補起來,到時候會在文章中加link方便跳轉。
文章中有些地方,本身也不是理解的很透,好比說彙編(大學沒學好,基本上還給老師了,囧),有不足以及錯誤的地方歡迎你們討論。