.NET中的陣列在記憶體中如何佈局?

2023-10-30 09:00:15

總的來說,.NET的值型別和參照型別都對映一段連續的記憶體片段。不過對於值型別物件來說,這段記憶體只需要儲存其欄位成員,而對應參照型別物件,還需要儲存額外的內容。就記憶體佈局來說,參照型別有兩個獨特的存在,一個是字串,另一個就是陣列。我在《你知道.NET的字串在記憶體中是如何儲存的嗎?》一文中對其記憶體佈局作了詳細介紹,今天我們來聊聊陣列型別的記憶體佈局。

一、參照型別佈局
二、陣列型別佈局
三、值型別陣列
四、參照型別陣列

一、參照型別佈局

但是對於參照型別物件,除了儲存其所有欄位成員外,還需要儲存一個Object Header和TypeHandle,前者可以用來儲存Hash值,也可以用來儲存同步狀態;後者儲存的是目標型別方法表的地址(詳細介紹可以參考我的文章《如何計算一個範例佔用多少記憶體?》、《如何將一個範例的記憶體二進位制內容讀出來?》。

如下圖所示,對於32位元(x86)系統,Object Header和TypeHandle各佔據4個位元組;但是對於64位元(x64)來說,儲存方法表指標的TypeHandle自然擴充套件到8個位元組,但是Object Header依然是4個位元組,為了確保TypeHandle基於8位元組的記憶體對齊,所以會前置4個位元組的「留白(Padding)」。

image_thumb2

順便說一下,即使沒有定義任何的欄位成員,執行時依然會使用一個「指標寬度(IntPtr.Size)」的儲存空間(上圖中的Payload),所以x86/x64系統中一個參照型別物件至少佔據12/24位元組的記憶體。除此之外,所謂物件的參照並不是指向這段記憶體的起始位置,而是指向TypeHandle的地址。

二、陣列型別佈局

既然陣列是參照型別,它自然按照上面的方式進行記憶體佈局。它依然擁有4位元組的Object Header,TypeHandle部分儲存的是陣列型別自身的方法表地址。其荷載內容(Payload)採用如下的佈局:前置4個位元組以UInt32的形式儲存陣列的長度,後面依次儲存每個陣列元素的內容。對於64位元(x64)來說,為了確保陣列元素的記憶體對齊,兩者之間具有4個位元組的Padding。

image_thumb5

三、值型別陣列

對於值型別的陣列,Payload部分直接儲存元素自身的值。如下程式演示瞭如何將一個陣列(Int32)物件在記憶體中的位元組序列讀出來。如程式碼片段所示,GetArray方法根據上述的記憶體佈局計算出一個陣列物件佔據的位元組數,並建立出對應的位元組資料來儲存陣列物件的位元組內容。我們在上面說過,一個陣列變數指向的是目標物件TypeHandle部分的地址,所以我們需要前移一個指標寬度才能得到記憶體的起始位置。我們最終利用起始位置和位元組數,將承載陣列自身物件的位元組讀出來存放到預先建立的位元組陣列中。

var array = new byte[] { byte.MaxValue, byte.MaxValue, byte.MaxValue };
Console.WriteLine($"Array: {BitConverter.ToString(GetArray(array))}");
Console.WriteLine($"TypeHandle of Byte[]: {BitConverter.ToString(GetTypeHandle<byte[]>())}");

unsafe static byte[] GetArray<T>(T[] array)
{
    var size = IntPtr.Size // Object header + Padding
     + IntPtr.Size // TypeHandle
     + IntPtr.Size // Length + Padding
     + Unsafe.SizeOf<T>() * array.Length // Elements
        ;
    var bytes = new byte[size];

    var pointer = Unsafe.AsPointer(ref array);
    var head = *(IntPtr*)pointer - IntPtr.Size;
    Marshal.Copy(head, bytes, 0, size);
    return bytes;
}

unsafe static byte[] GetTypeHandle<T>() => BitConverter.GetBytes(typeof(T).TypeHandle.Value);

為了進一步驗證陣列物件每個部分的內容,我們還定義了GetTypeHandle<T>方法讀取目標型別TypeHandle的值(方法表地址)。在演示程式中,我們建立了一個長度位3的位元組陣列,並將三個陣列元素的值設定位byte.MaxValue。我們將承載這個陣列的位元組序列和位元組陣列型別的TypeHandle的值列印出來。

Array: [00-00-00-00-00-00-00-00]-[E0-6A-0D-01-FF-7F-00-00]-[03-00-00-00]-00-00-00-00-[FF-FF-FF]
TypeHandle of Byte[]: E0-6A-0D-01-FF-7F-00-00

如上所示的輸出結果驗證了陣列物件的記憶體佈局。由於演示機器為64位元系統,所以前8個位元組表示Object Header(4位元組)和Padding(位元組)。中間高亮的8個位元組正好與位元組陣列型別的TypeHandle的值一致。後面4個位元組(03-00-00-00)表示位元組的長度(3),緊隨其後的4個位元組位Padding。最後的內容正好是三個陣列元素的值(FF-FF-FF)。

四、參照型別陣列

對於參照型別的陣列,其每個陣列元素儲存是元素物件的地址,下面的程式驗證了這一點。如程式碼片段所示,我們定義了GetAddress<T>方法得到指定變數指向的目標地址,並將其轉換成返回的位元組陣列。演示程式建立了一個包含三個元素的字串陣列,我們將承載陣列物件的位元組序列和作為陣列元素的三個字串物件的地址列印出來。

var s1 = "foo";
var s2 = "bar";
var s3 = "baz";
var array = new string[] { s1, s2, s3 };

Console.WriteLine($"Array: {BitConverter.ToString(GetArray(array))}");
Console.WriteLine($"element 1: {BitConverter.ToString(GetAddress(ref s1))}");
Console.WriteLine($"element 2: {BitConverter.ToString(GetAddress(ref s2))}");
Console.WriteLine($"element 3: {BitConverter.ToString(GetAddress(ref s3))}");

unsafe static byte[] GetAddress<T>(ref T value)
{
    var address = *(IntPtr*)Unsafe.AsPointer(ref value);
    return  BitConverter.GetBytes(address);
}

從如下的程式碼片段可以看出,在承載陣列物件的位元組序列中,最後的24位元組正好是三個字串的地址。

Array: 00-00-00-00-00-00-00-00-48-E9-5E-03-FF-7F-00-00-03-00-00-00-00-00-00-00-E0-EF-40-73-72-02-00-00-00-F0-40-73-72-02-00-00-20-F0-40-73-72-02-00-00
element 1: E0-EF-40-73-72-02-00-00
element 2: 00-F0-40-73-72-02-00-00
element 3: 20-F0-40-73-72-02-00-00