原文地址:https://devblogs.microsoft.com/premier-developer/managed-object-internals-part-4-fields-layout/
原文做者:Sergey
譯文做者:傑哥很忙git
託管對象本質1-佈局
託管對象本質2-對象頭佈局和鎖成本
託管對象本質3-託管數組結構
託管對象本質4-字段佈局github
在最近的博客文章中,咱們討論了CLR中對象佈局的不可見部分:c#
此次咱們將重點討論實例自己的佈局,特別是實例字段在內存中的佈局。api
目前尚未關於字段佈局的官方文檔,由於CLR做者保留了在未來更改它的權利。可是,若是您有興趣或者正在開發一個須要高性能的應用程序,那麼瞭解佈局可能會有幫助。數組
咱們如何檢查佈局?咱們能夠在Visual Studio中查看原始內存或在SOS調試擴展中使用!dumpobj
命令。這些方法單調乏味,所以咱們將嘗試編寫一個工具,在運行時打印對象佈局。微信
若是您對工具的實現細節不感興趣,能夠跳到在運行時檢查值類型佈局部分。app
咱們不會使用非託管代碼或分析API,而是使用LdFlda
指令的強大功能。此IL指令返回給定類型字段的地址。不幸的是,這條指令沒有在C#語言中公開,因此咱們須要編寫一些代碼來解決這個限制。dom
在剖析C#中的new()約束時,咱們已經作了相似的工做。咱們將使用必要的IL指令生成一個動態方法。
該方法應執行如下操做:函數
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 遵循的規則:
字段必須與自身大小的字段(一、二、四、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,根據平臺而定。
微信掃一掃二維碼關注訂閱號傑哥技術分享
出處:http://www.javashuo.com/article/p-repvuqsw-dw.html 做者:傑哥很忙 本文使用「CC BY 4.0」創做共享協議。歡迎轉載,請在明顯位置給出出處及連接。