前面我們圖解了.NET里各種對象的內存布局,我們再來從調試器和clr源碼的角度來看一下對象的內存布局。我寫了一個測試程序來加深對.net對象內存布局的了解:
using System; using System.Runtime.InteropServices; // 實際上是一個C語言里的聯合體 [StructLayout(LayoutKind.Explicit)] public struct InnerStruct { [FieldOffset(0)] public float FloatValue; [FieldOffset(0)] public double DoubleValue; } public struct TestStruct { public int IntValue; public string StringValue; public object ObjectValue; public InnerStruct InnerStructValue; } public class ObjectLayout { private int _IntValue = 456; public int IntValue { get { return _IntValue; } set { _IntValue = value; } } public static void Main() { Object o = new Object(); lock (o) { Console.WriteLine("Object實例: {0}", o.ToString()); } int i = 123; Console.WriteLine("int值: {0}", i); string s = "This is a string"; Console.WriteLine("字符串:{0}", s); ObjectLayout[] olArr = new ObjectLayout[10]; olArr[0] = new ObjectLayout(); olArr[0].IntValue = 2222; Console.WriteLine("數組的長度:{0}", olArr.Length); object[] objArr = new object[2]; objArr[0] = o; Console.WriteLine("數組的長度:{0}", objArr.Length); string[] strArr = new string[2]; strArr[0] = s; strArr[1] = s + "!"; Console.WriteLine("數組的長度:{0}", strArr.Length); TestStruct ts = new TestStruct(); ts.IntValue = 100; ts.StringValue = s + "!"; ts.ObjectValue = o; ts.InnerStructValue.FloatValue = 789.0f; int[] intArr = new int[10]; for (int j = 0; j < intArr.Length; ++j) { intArr[j] = j; } Console.WriteLine("int數組的長度:{0}", intArr.Length); TestStruct[] tsArr = new TestStruct[2]; tsArr[0] = ts; Console.WriteLine("TestStruct數組的長度:{0}", tsArr.Length); } }
使用命令編譯一個調試版本的objectlayout.exe程序:
csc /debug objectlayout.cs
用sos瀏覽對象內存布局
我們用sos這個工具加深對.net對象的理解,sos可以在Visual Studio里使用:
- 啟動VS,依次點擊菜單里的“文件(File)” -> “打開(Open)” -> “工程或解決方案(Project/Solution)”,然后選擇剛剛編譯的objectlayout.exe程序,開始調試這個程序;
- 對於托管程序,VS支持多種調試模式,如果要使用sos插件的話,需要采用“混合(Mixed)”調試模式調試程序,具體做法是在“解決方案管理器(Solution Explorer)”里右鍵單擊objectlayout.exe程序,然后點擊“屬性(Properties)”打開屬性窗口,將里面的“調試器類型(Debugger Type)”改成“混合(Mixed)”模式;
- 在VS里打開程序的源碼objectlayout.cs,並在Main函數的最后一行設置斷點;
- 在VS里,打開“立即”窗口,菜單命令是:“調試(Debug)” -> “窗口(Windows)” -> “立即(Immediate)”;
- 在“立即”窗口里,執行命令將sos加載到VS中:!load C:\WINDOWS\Microsoft.NET\Framework\v2.0.50727\sos.dll
這個時候就可以在vs里使用sos插件里面的命令了,如下圖所示:
這里對sos命令不做過多的解釋,有興趣的網友可以參看我的《Windows調試技術》視頻來了解sos的用法,下面我用類似bash注釋的方式解釋查看過程:
# #使用 !clrstack 命令查看當前被調試進程的堆棧,而 -l 參數則告訴sos同時 # 顯示堆棧上每個函數的局部變量。 # !clrstack -l OS Thread Id: 0xba4 (2980) ESP EIP 0012f408 00e10341 ObjectLayout.Main() # # 下面打印了Main函數里的所有局部變量,如果執行過GC,有些變量可能不可見 # 局部變量左邊是局部變量的內存地址,而右邊則是局部變量的值,因為大部分 # 局部變量都是引用類型,所有值大部分都是指針,除了少數幾個,如第二個變量 # 就是一個值類型,因此直接保存了它的值:0x0000007b # LOCALS: 0x0012f444 = 0x012b1bd8 0x0012f440 = 0x0000007b <CLR reg> = 0x012b1af4 0x0012f438 = 0x012c9520 0x0012f434 = 0x012c966c 0x0012f430 = 0x012c9788 0x0012f41c = 0x012c98d8 0x0012f418 = 0x012c990c 0x0012f414 = 0x0000000a 0x0012f410 = 0x012c9a5c 0x0012f40c = 0x00000000 0012f69c 79e88f63 [GCFrame: 0012f69c]
接下來,我們一個個分析這些對象的內存布局,首先是第一個對象 - object類型的o:
!do 0x012b1bd8 Name: System.Object #指明了類型,這個類型由保存在對象里的MethodTable獲取 MethodTable: 790f9c18 # MethodTable地址,直接保存在對象里 EEClass: 790f9bb4 #通過MethodTable解析到 Size: 12(0xc) bytes (C:\WINDOWS\assembly\GAC_32\mscorlib\2.0.0.0__b77a5c561934e089\mscorlib.dll) Object Fields: None
打開VS的“內存(Memory)”窗口,或者“命令(Command)”窗口,查看0x012b1bd8地址處的內存,這里為了寫文章方便,我用的是“命令”窗口,在VS里依次點擊菜單“視圖(View)” -> “其他窗口(Other Windows)” -> “命令窗口(Command Window)”。在命令窗口里執行下面的命令(熟悉windbg的同學應該知道這是windbg里的命令):
# # 你可以直接給出變量名,vs會自動將變量名解析為內存地址 # 可以看到,對象的第一個指針就是說明自己類型的MethodTable # 指針 -> 790f9c18, # >dd o 0x012B1BD8 790f9c18 00000000 00000000 00000000 # # 當然也可以直接給dd命令內存地址 # >dd 0x012b1bd8 0x012B1BD8 790f9c18 00000000 00000000 00000000
前文我們已經提到clr將對象的指針做了一些處理,對托管代碼隱藏了objheader信息,這個信息其中一個作用就是處理線程同步信息,要看看syncvalue是怎么工作的話,可以重新啟動被調試程序,並將程序中斷在代碼的第39行即lock語句那里 - 在其執行之前中斷程序,如下圖所示:
然后我們在“命令窗口”查看對象的內存布局:
# #我用的是虛擬機上安裝的32位xp系統,因此我們將地址提前 #一個指針,看看當前objheader的synvalue的值,目前因為 #沒有線程需要同步訪問這個對象,所以其值為0 # >dd 0x012b1bd4 0x012B1BD4 00000000 790f9c18 00000000 00000000 # #單步執行lock語句一次,以便開始線程同步,再看相同地址的值 #現在會發現lockvalue已經更新了,lockvalue的作用在后文說明 #這里就不詳細說明它了。 # >dd 0x012b1bd4 0x012B1BD4 00000001 790f9c18 00000000 00000000
我們再看第三個局部變量,string類型的s,使用sos命令查看的結果如下:
!do 0x012b1af4 Name: System.String MethodTable: 790fa3e0 EEClass: 790fa340 Size: 50(0x32) bytes (C:\WINDOWS\assembly\GAC_32\mscorlib\2.0.0.0__b77a5c561934e089\mscorlib.dll) # #對於字符串對象,!do命令足夠聰明,可以直接將字符串的內容打印出來 # String: This is a string # #顯示該對象實例的每一個成員變量的值 # Fields: MT Field Offset Type VT Attr Value Name 790fed1c 4000096 4 System.Int32 0 instance 17 m_arrayLength 790fed1c 4000097 8 System.Int32 0 instance 16 m_stringLength 790fbefc 4000098 c System.Char 0 instance 54 m_firstChar 790fa3e0 4000099 10 System.String 0 shared static Empty >> Domain:Value 0014c558:790d6584 << 79124670 400009a 14 System.Char[] 0 shared static WhitespaceChars >> Domain:Value 0014c558:012b1670 <<
我們再在命令行里用dd查看它的內存布局,因為是字符串,所以這里我們用dc命令,除了用16進制顯示內存以外,還盡量使用字符的形式打印內存的每個指針:
# #第一個指針保存的仍然是對象的類型信息 - MethodTable指針, #接下來第二個指針就是如果將字符串當作數組看待的話,它的長度, #這個長度會包括最后的’\0’,這里它的值是 0x11,也就是17。 #第三個指針就是字符串的長度,即 0x10,也就是16個字符。 #在字符串長度后面就是實際的字符串WCHAR數組了。 # >dc s 0x012B1AE8 790fa3e0 00000011 00000010 00680054 à£.y........T.h. 0x012B1AF8 00730069 00690020 00200073 00200061 i.s. .i.s. .a. . 0x012B1B08 00740073 00690072 0067006e 00000000 s.t.r.i.n.g..... 0x012B1B18 00000000 790fa3e0 00000008 00000007 ....à£.y........
接下來我們再來看引用類型的數組對象在內存里的布局,下面是 ObjectLayout[] 類型的對象olArr的結果,可以看到在clr里,對象的類型實際上 System.Object[],而不是 ObjectLayout[]。
!do 0x012c9520 Name: System.Object[] # #MethodTable的值是 79124228,跟后面 object[] 類型對象的 objArr 的 #MethodTable是一樣的 # MethodTable: 79124228 EEClass: 7912479c Size: 56(0x38) bytes # #對於數組對象,sos會打印出數組的維度和大小信心 # Array: Rank 1, Number of elements 10, Type CLASS # #數組元素的類型 # Element Type: ObjectLayout Fields: None # #使用 da 命令可以打印出數組的詳細內容 # !da 0x012c9520 Name: ObjectLayout[] MethodTable: 79124228 EEClass: 7912479c Size: 56(0x38) bytes Array: Rank 1, Number of elements 10, Type CLASS Element Methodtable: 00933018 [0] 012c9558 [1] null [2] null [3] null [4] null [5] null [6] null [7] null [8] null [9] null
把olArr對象的內存打印出來,可以看到:
- 第一個指針跟其他對象一樣,是MethodTable,也就是對象類型的指針;
- 第二個指針是數組的大小0xa,也就是10;
- 第三個指針是數組里元素的類型指針;
- 第四個指針開始則是各個對象的引用。
>dd olArr 0x012C9438 79124228 0000000a 00933018 012c9558 0x012C9448 00000000 00000000 00000000 00000000 # #打印 object[] 類型的 objArr 對象的信息 # !do 0x012c966c Name: System.Object[] # #MethodTable指針與前面的ObjectLayout[]對象的MethodTable完全一樣 # MethodTable: 79124228 EEClass: 7912479c Size: 24(0x18) bytes Array: Rank 1, Number of elements 2, Type CLASS Element Type: System.Object Fields: None !da 0x012c966c Name: System.Object[] MethodTable: 79124228 EEClass: 7912479c Size: 24(0x18) bytes Array: Rank 1, Number of elements 2, Type CLASS Element Methodtable: 790f9c18 [0] 012b1c3c [1] null # #打印 string[] 類型的 strArr 對象的信息 # !do 0x012c9788 Name: System.Object[] # #MethodTable指針與前面的ObjectLayout[]和object[]對象的MethodTable完全一樣 # MethodTable: 79124228 EEClass: 7912479c Size: 24(0x18) bytes Array: Rank 1, Number of elements 2, Type CLASS Element Type: System.String Fields: None !da 0x012c9788 Name: System.String[] MethodTable: 79124228 EEClass: 7912479c Size: 24(0x18) bytes Array: Rank 1, Number of elements 2, Type CLASS Element Methodtable: 790fa3e0 [0] 012b1af4 [1] 012c97a0 # #打印 int[] 類型的 intArr 對象的信息 # !da 0x012c990c Name: System.Int32[] # #注意:MethodTable 也就是類型指針跟前面引用類型的MethodTable不同 # MethodTable: 791240f0 EEClass: 791241a8 Size: 52(0x34) bytes Array: Rank 1, Number of elements 10, Type Int32 Element Methodtable: 790fed1c [0] null [1] 00000001 [2] 00000002 [3] 00000003 [4] 00000004 [5] 00000005 [6] 00000006 [7] 00000007 [8] 00000008 [9] 00000009
查看intArr的內存布局,可以看到,與前面的引用類型數組不同,第三個指針就是數組的第一個元素(值為0)了,而引用類型數組的第三個指針是元素的類型指針。
>dd intArr 0x012C97B0 791240f0 0000000a 00000000 00000001 0x012C97C0 00000002 00000003 00000004 00000005 0x012C97D0 00000006 00000007 00000008 00000009 # #打印自定義結構體 TestStruct[] 類型的 tsArr對象的信息 # !da 0x012c9a5c Name: TestStruct[] # #注意:MethodTable 指針不僅跟前面引用類型的MethodTable不同, #而且跟intArr的類型也不一樣 # MethodTable: 00933200 EEClass: 00933180 Size: 52(0x34) bytes Array: Rank 1, Number of elements 2, Type VALUETYPE Element Methodtable: 0093313c [0] 012c9a64 [1] 012c9a78
查看tsArr的內存布局,也可以看到,clr直接將結構體的所有內容保存在數組元素的內存空間里,與 int[] 類型的對象一樣,結構體數組也不保存元素的類型信息。
>dd tsArr 0x012C98B8 00933200 00000002 012c977c 012b1bd8 0x012C98C8 00000064 44454000 00000000 00000000 0x012C98D8 00000000 00000000 00000000 00000000 0x012C98E8 00000000 00000000 00000000 00000000 # #使用df命令,打印出內存里的浮點數,可以看到在結構體的最后一個指針 #就是浮點數的值 # >df tsArr 0x012C98B8 1.3517755e-038 2.803e-045#DEN 3.1700095e-038 3.1427717e-038 0x012C98C8 1.401e-043#DEN 789.00000 0.00000000 0.00000000 0x012C98D8 0.00000000 0.00000000 0.00000000 0.00000000 0x012C98E8 0.00000000 0.00000000 0.00000000 0.00000000 # #使用dc命令查看數組第一個元素的第一個指針,是結構體的字符串成員變量 #通過這個例子也可以看到,在實際的內存布局里,如果不顯示指定成員變量的 #內存布局,clr里對象的成員變量的布局順序跟源碼的順序有可能是不一樣的 # >dc 0x012c977c 0x012C977C 790fa3e0 00000012 00000011 00680054 à£.y........T.h. 0x012C978C 00730069 00690020 00200073 00200061 i.s. .i.s. .a. . 0x012C979C 00740073 00690072 0067006e 00000021 s.t.r.i.n.g.!... 0x012C97AC 00000000 791240f0 0000000a 00000000 ….ð@.y........
通過前面的分析,可以看到,實際上所有的引用類型數組的類型都是一樣的,即 object[] 類型,但是值類型數組的類型卻各不相同,這個差異在jit的時候就已經決定了,也就是說,雖然在 IL 代碼里,創建數組的指令都是 newarr 指令,但是在jit編譯生成代碼后,傳給 newarr 指令的類型參數就已經不一樣了。我們在后面解讀 jit 源碼的時候會繼續提到這一點。