一個物件總是對映一塊連續的記憶體序列(不考慮物件之間的參照關係),如果我們知道了參照型別範例的記憶體佈局,以及變數參照指向的確切的地址,我們不僅可以採用純「二進位制」的方式在記憶體「繪製」一個指定參照型別的範例,還能直接通過改變二進位制內容來更新範例的狀態。
一、參照型別範例的記憶體佈局
二、以二進位制的形式建立物件
三、位元組陣列與範例狀態的同一性
四、ObjHeader針對雜湊被同步狀態的快取
從記憶體佈局的角度來看,一個參照型別的範例由如下圖所示的三部分組成:ObjHeader + TypeHandle + Fields。前置的ObjHeader用來快取雜湊值和同步狀態(《如何將一個範例的記憶體二進位制內容讀出來?》具有對此的詳細介紹),TypeHandle部分儲存型別對應方法表(Method Table)的地址,方法表可以視為針對型別的描述。也正是這部分內容的存在,執行時可以確定任何一個範例的真實型別,所以我們才說參照型別的範例是自描述(Self Describing)的。Fields用於儲存範例每個欄位的內容。
對於32位元(x86)的機器來說,ObjHeader 和 TypeHandle的長度都是4位元組。如果是64位元(x64)的機器,用於儲存方法表地址的TypeHandle 需要8個位元組來儲存,但是ObjHeader 依然是4個直接。考慮到記憶體對齊,需要前置4個位元組的Padding。對於一個不為null的應用型別變數來說,它儲存的是範例的記憶體地址。但是這個地址並不是範例所在記憶體的「首地址(ObjHeader)」,而是TypeHandle部分的地址。
既然我們已經知道了參照型別範例的記憶體佈局,也知道了參照指向的確切的地址,我們不僅可以採用純「二進位制」的方式在記憶體「繪製」一個指定參照型別的範例,還可以修改某個變數的「值」指向它。具體的實現體現在如下所示的Create方法中,該方法根據指定的屬性值建立一個Foobar物件。除了用來提供兩個屬性值的foo、bar引數之外,它還通過輸出引數bytes返回整個範例的位元組序列。
var foobar = Create(1, 2, out var bytes); Debug.Assert(foobar.Foo == 1); Debug.Assert(foobar.Bar == 2); static unsafe Foobar Create(int foo, int bar, out byte[] bytes) { Foobar foobar = null!; bytes = new byte[24]; BinaryPrimitives.WriteInt64LittleEndian(bytes.AsSpan(8), typeof(Foobar).TypeHandle.Value.ToInt64()); BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(16), foo); BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(20), bar); Unsafe.Write(Unsafe.AsPointer(ref foobar), new IntPtr(Unsafe.AsPointer(ref bytes[8]))); return foobar; } public class Foobar { public int Foo { get; set; } public int Bar { get; set; } }
根據上述針對記憶體佈局的介紹,我們知道任何一個Foobar範例在x64機器中都對映位一段連續的24位元組記憶體,所以Create方法建立了一個長度位24的位元組陣列。我們保持ObjHeader為空,所以我們從第8(zero based)個位元組開始寫入Foobar型別對應TypeHandle的值(8位元組),然後將指定的資料成員的值(int型別佔據4個位元組)填充到最後8個位元組(由於兩個欄位的型別均為int,所以不需要新增額外的「留白」來確保記憶體對齊)。自此我們將「憑空」在記憶體中「繪製」了一個Foobar物件。由於x86機器採用「小端位元組序」,所以二進位制的寫入最終是通過呼叫BinaryPrimitives的WriteInt32/64LittleEndian方法來完成的。
接下來我們定義一個Foobar型別的變數,並讓它指向這個繪製的Foobar物件。我們在上面說過,它指向的不是範例記憶體的首位元組,而是TypleHandle部分。對於我們的例子來說,它指向的就是我們建立的位元組陣列的第8(zero based)的元素。針對變數內容(目標物件的地址)的改寫是通過呼叫Unsafe的靜態方法Write實現的。我們的演示程式呼叫了Create建立了一個Foo和Bar屬性分別為1和2的Foobar物件,並得到它真正對映在記憶體中的位元組序列。
對於我們定義的Create方法來說,由於通過輸出引數返回的位元組數位就是返回的Foobar物件在記憶體中的對映,所以Foobar的狀態(Foo和Bar屬性)發生改變後,位元組陣列的內容也會發生改變。這一點可以通過如下的程式來驗證。
var foobar = Create(1, 1, out var bytes);
Console.WriteLine(BitConverter.ToString(bytes));
foobar.Foo = 255;
foobar.Bar = 255;
Console.WriteLine(BitConverter.ToString(bytes));
輸出結果
00-00-00-00-00-00-00-00-D8-11-30-17-F9-7F-00-00-01-00-00-00-01-00-00-00 00-00-00-00-00-00-00-00-D8-11-30-17-F9-7F-00-00-FF-00-00-00-FF-00-00-00
既然返回的位元組資料和Foobar物件具有同一性,我們自然也可以按照如下的方式通過修改位元組陣列的內容來到達改變範例狀態的目的。
var foobar = Create(1, 1, out var bytes);
Debug.Assert(foobar.Foo == 1); Debug.Assert(foobar.Bar == 1); BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(16), 255); BinaryPrimitives.WriteInt32LittleEndian(bytes.AsSpan(20), 255); Debug.Assert(foobar.Foo == 255); Debug.Assert(foobar.Bar == 255);
我們可以進一步利用這種方式驗證範例的ObjHeader針對雜湊值和同步狀態的快取。如下面的程式碼片段所示,我們呼叫Create建立了一個Foobar物件並將得到的位元組陣列列印出來。然後我們呼叫其GetHashCode方法觸發雜湊值的計算,並再次列印位元組陣列。接下來我們建立一個新的Foobar物件,分別對它進行加鎖和解鎖狀態列印位元組陣列。
var foobar = Create(1, 2, out var bytes); Console.WriteLine($"{BitConverter.ToString(bytes)}[Original]"); foobar.GetHashCode(); Console.WriteLine($"{BitConverter.ToString(bytes)}[GetHashCode]"); foobar = Create(1, 2, out bytes); lock (foobar) { Console.WriteLine($"{BitConverter.ToString(bytes)}[Enter lock]"); } Console.WriteLine($"{BitConverter.ToString(bytes)}[Exit lock]");
從如下所示的輸出結果可以看出,在GetHashCode方法呼叫和被「鎖住」之後,承載Foobar物件的ObjHeader位元組(4-7位元組)都發生了改變,實際上執行時就是利用它來儲存計算出的雜湊值和同步狀態。至於ObjHeader具體的位元組佈局,我的另一篇文章《如何將一個範例的記憶體二進位制內容讀出來?》提供了系統的說明。
00-00-00-00-00-00-00-00-90-1C-30-17-F9-7F-00-00-01-00-00-00-02-00-00-00[Original] 00-00-00-00-C7-D5-9F-0D-90-1C-30-17-F9-7F-00-00-01-00-00-00-02-00-00-00[GetHashCode] 00-00-00-00-01-00-00-00-90-1C-30-17-F9-7F-00-00-01-00-00-00-02-00-00-00[Enter lock]
00-00-00-00-00-00-00-00-90-1C-30-17-F9-7F-00-00-01-00-00-00-02-00-00-00[Exit lock]