托管對象本質-第四部分-字段布局




托管對象本質-第四部分-字段布局

原文地址:https://devblogs.microsoft.com/premier-developer/managed-object-internals-part-4-fields-layout/
原文作者:Sergey
譯文作者:傑哥很忙

目錄

托管對象本質1-布局
托管對象本質2-對象頭布局和鎖成本
托管對象本質3-托管數組結構
托管對象本質4-字段布局

在最近的博客文章中,我們討論了CLR中對象布局的不可見部分:

這次我們將重點討論實例本身的布局,特別是實例字段在內存中的布局。

FieldsLayout_Figure1_thumb.gif

目前還沒有關於字段布局的官方文檔,因為CLR作者保留了在將來更改它的權利。但是,如果您有興趣或者正在開發一個需要高性能的應用程序,那么了解布局可能會有幫助。

我們如何檢查布局?我們可以在Visual Studio中查看原始內存或在SOS調試擴展中使用!dumpobj命令。這些方法單調乏味,因此我們將嘗試編寫一個工具,在運行時打印對象布局。

如果您對工具的實現細節不感興趣,可以跳到在運行時檢查值類型布局部分。

在運行時獲取字段偏移量

我們不會使用非托管代碼或分析API,而是使用LdFlda指令的強大功能。此IL指令返回給定類型字段的地址。不幸的是,這條指令沒有在C#語言中公開,所以我們需要編寫一些代碼來解決這個限制。

剖析C#中的new()約束時,我們已經做了類似的工作。我們將使用必要的IL指令生成一個動態方法
該方法應執行以下操作:

  • 創建數組用來存儲所有字段地址。
  • 枚舉對象的每個FieldInfo,通過調用LdFlda指令獲取偏移量。
  • 將LdFlda指令的結果轉換為long並將結果存儲在數組中。
  • 返回數組。
private static Func<object, long[]> GenerateFieldOffsetInspectionFunction(FieldInfo[] fields)
{
    var method = new DynamicMethod(
        name: "GetFieldOffsets",
        returnType: typeof(long[]),
        parameterTypes: new[] { typeof(object) },
        m: typeof(InspectorHelper).Module,
        skipVisibility: true);
 
    ILGenerator ilGen = method.GetILGenerator();
 
    // Declaring local variable of type long[]
    ilGen.DeclareLocal(typeof(long[]));
    // Loading array size onto evaluation stack
    ilGen.Emit(OpCodes.Ldc_I4, fields.Length);
 
    // Creating an array and storing it into the local
    ilGen.Emit(OpCodes.Newarr, typeof(long));
    ilGen.Emit(OpCodes.Stloc_0);
 
    for (int i = 0; i < fields.Length; i++)
    {
        // Loading the local with an array
        ilGen.Emit(OpCodes.Ldloc_0);
 
        // Loading an index of the array where we're going to store the element
        ilGen.Emit(OpCodes.Ldc_I4, i);
 
        // Loading object instance onto evaluation stack
        ilGen.Emit(OpCodes.Ldarg_0);
 
        // Getting the address for a given field
        ilGen.Emit(OpCodes.Ldflda, fields[i]);
 
        // Converting field offset to long
        ilGen.Emit(OpCodes.Conv_I8);
 
        // Storing the offset in the array
        ilGen.Emit(OpCodes.Stelem_I8);
    }
 
    ilGen.Emit(OpCodes.Ldloc_0);
    ilGen.Emit(OpCodes.Ret);
 
    return (Func<object, long[]>)method.CreateDelegate(typeof(Func<object, long[]>));
}

我們可以創建一個幫助函數用來提供給定的每個字段的偏移量。

public static (FieldInfo fieldInfo, int offset)[] GetFieldOffsets(Type t)
{
    var fields = t.GetFields(BindingFlags.Public | BindingFlags.Instance | BindingFlags.NonPublic);
 
    Func<object, long[]> fieldOffsetInspector = GenerateFieldOffsetInspectionFunction(fields);
 
    var instance = CreateInstance(t);
    var addresses = fieldOffsetInspector(instance);
 
    if (addresses.Length == 0)
    {
        return Array.Empty<(FieldInfo, int)>();
    }
 
    var baseLine = addresses.Min();
            
    // Converting field addresses to offsets using the first field as a baseline
    return fields
        .Select((field, index) => (field: field, offset: (int)(addresses[index] - baseLine)))
        .OrderBy(tuple => tuple.offset)
        .ToArray();
}

函數非常簡單,有一個警告:LdFlda 指令需要計算堆棧上的對象實例。對於值類型和具有默認構造函數的引用類型,解決方案是不難的:可以直接使用Activator.CreateInstance(Type)。但是,如果想要檢查沒有默認構造函數的類,該怎么辦?

在這種情況下我們可以使用不常使用的通用工廠,調用FormatterServices.GetUninitializedObject(Type)

譯者補充: FormatterServices.GetUninitializedObject方法不會調用默認構造函數,所有字段都保持默認值。

private static object CreateInstance(Type t)
{
    return t.IsValueType ? Activator.CreateInstance(t) : FormatterServices.GetUninitializedObject(t);
}

讓我們來測試一下 GetFieldOffsets 獲取下面類型的布局。

class ByteAndInt
{
    public byte b;
    public int n;
}
 
 Console.WriteLine(
    string.Join("\r\n",
        InspectorHelper.GetFieldOffsets(typeof(ByteAndInt))
            .Select(tpl => $"Field {tpl.fieldInfo.Name}: starts at offset {tpl.offset}"))
    );

輸出是:

Field n: starts at offset 0
Field b: starts at offset 4

有意思,但是做的還不夠。我們可以檢查每個字段的偏移量,但是知道每個字段的大小來理解布局的空間利用率,了解每個實例有多少空閑空間會很有用。

計算類型實例的大小

同樣,沒有"官方"方法來獲取對象實例的大小。sizeof 運算符僅適用於沒有引用類型字段的基元類型和用戶定義結構。Marshal.SizeOf 返回非托管內存中的對象的大小,並不滿足我們的需求。

我們將分別計算值類型和對象的實例大小。為了計算結構的大小,我們將依賴於 CLR 本身。我們會創建一個包含兩個字段的簡單泛型類型:第一個字段是泛型類型字段,第二個字段用於獲取第一個字段的大小。

struct SizeComputer<T>
{
    public T dummyField;
    public int offset;
}
 
public static int GetSizeOfValueTypeInstance(Type type)
{
    Debug.Assert(type.IsValueType);
 
    var generatedType = typeof(SizeComputer<>).MakeGenericType(type);
    // The offset of the second field is the size of the 'type'
    var fieldsOffsets = GetFieldOffsets(generatedType);
    return fieldsOffsets[1].offset;
}

為了得到引用類型實例的大小,我們將使用另一個技巧:我們獲取最大字段偏移量,然后將該字段的大小和該數字四舍五入到指針大小邊界。我們已經知道如何計算值類型的大小,並且我們知道引用類型的每個字段都占用 4 或 8 個字節(具體取決於平台)。因此,我們獲得了所需的一切信息:

public static int GetSizeOfReferenceTypeInstance(Type type)
{
    Debug.Assert(!type.IsValueType);
 
    var fields = GetFieldOffsets(type);
 
    if (fields.Length == 0)
    {
        // Special case: the size of an empty class is 1 Ptr size
        return IntPtr.Size;
    }
 
    // The size of the reference type is computed in the following way:
    // MaxFieldOffset + SizeOfThatField
    // and round that number to closest point size boundary
    var maxValue = fields.MaxBy(tpl => tpl.offset);
    int sizeCandidate = maxValue.offset + GetFieldSize(maxValue.fieldInfo.FieldType);
 
    // Rounding the size to the nearest ptr-size boundary
    int roundTo = IntPtr.Size - 1;
    return (sizeCandidate + roundTo) & (~roundTo);
}
 
 
public static int GetFieldSize(Type t)
{
    if (t.IsValueType)
    {
        return GetSizeOfValueTypeInstance(t);
    }
 
    return IntPtr.Size;
}

我們有足夠的信息在運行時獲取任何類型實例的正確布局信息。

在運行時檢查值類型布局

我們從值類型開始,並檢查以下結構:

public struct NotAlignedStruct
{
    public byte m_byte1;
    public int m_int;
    public byte m_byte2;
    public short m_short;
}

調用TypeLayout.Print<NotAlignedStruct>()結果如下:

Size: 12. Paddings: 4 (%33 of empty space)
|================================|
|     0: Byte m_byte1 (1 byte)   |
|--------------------------------|
|   1-3: padding (3 bytes)       |
|--------------------------------|
|   4-7: Int32 m_int (4 bytes)   |
|--------------------------------|
|     8: Byte m_byte2 (1 byte)   |
|--------------------------------|
|     9: padding (1 byte)        |
|--------------------------------|
| 10-11: Int16 m_short (2 bytes) |
|================================|

默認情況下,用戶定義的結構具有sequential布局,Pack 等於 0。下面是 CLR 遵循的規則

字段必須與自身大小的字段(1、2、4、8 等、字節)或比它小的字段的類型的對齊方式對齊。由於默認的類型對齊方式是以最大元素的大小對齊(大於或等於所有其他字段長度),這通常意味着字段按其大小對齊。例如,即使類型中的最大字段是 64 位(8 字節)整數,或者 Pack 字段設置為 8,byte字段在 1 字節邊界上對齊,Int16 字段在 2 字節邊界上對齊,Int32 字段在 4 字節邊界上對齊。

譯者補充:當較大字段排列在較小字段之后時,會進行對內對齊,以最大基元元素的大小填齊使得內存對齊。

在上面的情況,4個字節對齊會有比較合理的開銷。我們可以將 Pack 更改為 1,但由於未對齊的內存操作,性能可能會下降。相反,我們可以使用LayoutKind.Auto 來允許 CLR 自動尋找最佳布局:

譯者補充:內存對齊的方式主要有2個作用:一是為了跨平台。並不是所有的硬件平台都能訪問任意地址上的任意數據的;某些硬件平台只能在某些地址處取某些特定類型的數據,否則拋出硬件異常。二是內存對齊可以提高性能,原因在於,為了訪問未對齊的內存,處理器需要作兩次內存訪問;而對齊的內存訪問僅需要一次訪問。

[StructLayout(LayoutKind.Auto)]
public struct NotAlignedStructWithAutoLayout
{
    public byte m_byte1;
    public int m_int;
 
    public byte m_byte2;
    public short m_short;
}
Size: 8. Paddings: 0 (%0 of empty space)
|================================|
|   0-3: Int32 m_int (4 bytes)   |
|--------------------------------|
|   4-5: Int16 m_short (2 bytes) |
|--------------------------------|
|     6: Byte m_byte1 (1 byte)   |
|--------------------------------|
|     7: Byte m_byte2 (1 byte)   |
|================================|

記住,只有當類型中沒有"指針"時,才可能同時使用值類型和引用類型的順序布局。如果結構或類至少有一個引用類型的字段,則布局將自動更改為 LayoutKind.Auto

在運行時檢查引用類型布局

引用類型的布局和值類型的布局之間存在兩個主要差異。首先,每個對象實例都有一個對象頭和方法表指針。其次,對象的默認布局是自動的(Auto)的,而不是順序的(sequential)的。與值類型類似,順序布局僅適用於沒有任何引用類型的類。

方法 TypeLayout.PrintLayout<T>(bool recursively = true)采用一個參數,允許打印嵌套類型。

public class ClassWithNestedCustomStruct
{
    public byte b;
    public NotAlignedStruct sp1;
}
Size: 40. Paddings: 11 (%27 of empty space)
|========================================|
| Object Header (8 bytes)                |
|----------------------------------------|
| Method Table Ptr (8 bytes)             |
|========================================|
|     0: Byte b (1 byte)                 |
|----------------------------------------|
|   1-7: padding (7 bytes)               |
|----------------------------------------|
|  8-19: NotAlignedStruct sp1 (12 bytes) |
| |================================|     |
| |     0: Byte m_byte1 (1 byte)   |     |
| |--------------------------------|     |
| |   1-3: padding (3 bytes)       |     |
| |--------------------------------|     |
| |   4-7: Int32 m_int (4 bytes)   |     |
| |--------------------------------|     |
| |     8: Byte m_byte2 (1 byte)   |     |
| |--------------------------------|     |
| |     9: padding (1 byte)        |     |
| |--------------------------------|     |
| | 10-11: Int16 m_short (2 bytes) |     |
| |================================|     |
|----------------------------------------|
| 20-23: padding (4 bytes)               |
|========================================|

結構包裝的成本

盡管類型布局非常簡單,但我發現了一個有趣的特性。

我最近正在調查項目中的一個內存問題,我注意到一些奇怪的現象:托管對象的所有字段的總和都高於實例的大小。我大致知道 CLR 如何布置字段的規則,所以我感到困惑。我已經開始研究這個工具來理解這個問題。

我已經將問題縮小到以下情況:

internal struct ByteWrapper
{
    public byte b;
}
 
internal class ClassWithByteWrappers
{
    public ByteWrapper bw1;
    public ByteWrapper bw2;
    public ByteWrapper bw3;
}
     --- Automatic Layout ---              --- Sequential Layout ---     
Size: 24 bytes. Paddings: 21 bytes    Size: 8 bytes. Paddings: 5 bytes 
(%87 of empty space)                  (%62 of empty space)
|=================================|   |=================================|
| Object Header (8 bytes)         |   | Object Header (8 bytes)         |
|---------------------------------|   |---------------------------------|
| Method Table Ptr (8 bytes)      |   | Method Table Ptr (8 bytes)      |
|=================================|   |=================================|
|     0: ByteWrapper bw1 (1 byte) |   |     0: ByteWrapper bw1 (1 byte) |
|---------------------------------|   |---------------------------------|
|   1-7: padding (7 bytes)        |   |     1: ByteWrapper bw2 (1 byte) |
|---------------------------------|   |---------------------------------|
|     8: ByteWrapper bw2 (1 byte) |   |     2: ByteWrapper bw3 (1 byte) |
|---------------------------------|   |---------------------------------|
|  9-15: padding (7 bytes)        |   |   3-7: padding (5 bytes)        |
|---------------------------------|   |=================================|
|    16: ByteWrapper bw3 (1 byte) |
|---------------------------------|
| 17-23: padding (7 bytes)        |
|=================================|

即使 ByteWrapper 的大小為 1 字節,CLR 在指針邊界上對齊每個字段! 如果類型布局是LayoutKind.Auto CLR 將填充每個自定義值類型字段! 這意味着,如果你有多個結構,僅包裝一個 int 或 byte類型,而且它們廣泛用於數百萬個對象,那么由於填充的現象,可能會有明顯的內存開銷。

默認包大小為4或8,根據平台而定。

參考文檔

  1. StructLayout特性
  2. Compiling C# Code Into Memory and Executing It with Roslyn
  3. StructLayoutAttribute.Pack
  4. 有助於在運行時查看 CLR 類型的內部結構的工具

20191127212134.png
微信掃一掃二維碼關注訂閱號傑哥技術分享
出處:https://www.cnblogs.com/Jack-Blog/p/12259258.html
作者:傑哥很忙
本文使用「CC BY 4.0」創作共享協議。歡迎轉載,請在明顯位置給出出處及鏈接。


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM