託管物件本質-第四部分-欄位佈局
目錄
- 託管物件本質-第四部分-欄位佈局
- 目錄
- 在執行時獲取欄位偏移量
- 計算型別例項的大小
- 在執行時檢查值型別佈局
- 在執行時檢查引用型別佈局
- 結構包裝的成本
- 參考文件
託管物件本質-第四部分-欄位佈局
原文地址:https://devblogs.microsoft.com/premier-developer/managed-object-internals-part-4-fields-layout/
原文作者:Sergey
譯文作者:傑哥很忙
目錄
託管物件本質1-佈局
託管物件本質2-物件頭佈局和鎖成本
託管物件本質3-託管陣列結構
託管物件本質4-欄位佈局
在最近的部落格文章中,我們討論了CLR中物件佈局的不可見部分:
這次我們將重點討論例項本身的佈局,特別是例項欄位在記憶體中的佈局。
目前還沒有關於欄位佈局的官方文件,因為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,根據平臺而定。
參考文件
- StructLayout特性
- Compiling C# Code Into Memory and Executing It with Roslyn
- StructLayoutAttribute.Pack
微信掃一掃二維碼關注訂閱號傑哥技術分享
出處:https://www.cnblogs.com/Jack-Blog/p/12259258.html
作者:傑哥很忙
本文使用「CC BY 4.0」創作共享協議。歡迎轉載,請在明顯位置給出出處及連結。