如何計算一個範例佔用多少記憶體?

2023-06-05 12:01:23

我們都知道CPU和記憶體是程式最為重要的兩類指標,那麼有多少人真正想過這個問題:一個型別(值型別或者參照型別)的範例在記憶體中究竟佔多少位元組?我們很多人都回答不上來。其實C#提供了一些用於計算大小的操作符和API,但是它們都不能完全解決我剛才提出的問題。本文提供了一種計算值型別和參照型別範例所佔記憶體位元組數量的方法。原始碼從這裡下載。

一、sizeof操作符
二、Marshal.SizeOf方法
三、Unsafe.SizeOf方法>
四、可以根據欄位成員的型別來計算嗎?
五、值型別和應用型別的佈局
六、Ldflda指令
七、計算值型別的位元組數
八、計算參照型別位元組數
九、完整的計算

一、sizeof操作符

sizeof操作用來確定某個型別對應範例所佔用的位元組數,但是它只能應用在Unmanaged型別上。所謂的Unmanaged型別僅限於:

  • 原生型別(Primitive Type:Boolean, Byte, SByte, Int16, UInt16, Int32, UInt32, Int64, UInt64, IntPtr, UIntPtr, Char, Double, 和Single)
  • Decimal型別
  • 列舉型別
  • 指標型別
  • 只包含Unmanaged型別資料成員的結構體

顧名思義,一個Unmanaged型別是一個值型別,對應的範例不能包含任何一個針對託管物件的參照。如果我們定義如下這樣一個泛型方法來呼叫sizeof操作符,泛型引數T必須新增unmananged約束,而且方法上還得新增unsafe標記。

public static unsafe int SizeOf<T>() where T : unmanaged => sizeof(T);

只有原生型別和列舉型別可以直接使用sizeof操作符,如果將它應用在其他型別(指標和自定義結構體),必須新增/unsafe編譯標記,還需要放在unsafe上下文中。

Debug.Assert(sizeof(byte) == 1);
Debug.Assert(sizeof(sbyte) == 1);
Debug.Assert(sizeof(short) == 2);
Debug.Assert(sizeof(ushort) == 2);
Debug.Assert(sizeof(int) == 4);
Debug.Assert(sizeof(uint) == 4);
Debug.Assert(sizeof(long) == 8);
Debug.Assert(sizeof(ulong) == 8);
Debug.Assert(sizeof(char) == 2);
Debug.Assert(sizeof(float) == 4);
Debug.Assert(sizeof(double) == 8);
Debug.Assert(sizeof(bool) == 1);
Debug.Assert(sizeof(decimal) == 16);
Debug.Assert(sizeof(DateTimeKind) == 4);

unsafe

{

    Debug.Assert(sizeof(int*) == 8);
    Debug.Assert(sizeof(DateTime) == 8);
    Debug.Assert(sizeof(DateTimeOffset) == 16);
    Debug.Assert(sizeof(Guid) == 16);
    Debug.Assert(sizeof(Point) == 8);

}

由於如下這個結構體Foobar並不是一個Unmanaged型別,所以程式會出現編譯錯誤。

unsafe
{
    Debug.Assert(sizeof(Foobar) == 16);
}
public struct Foobar
{
    public string Foo;
    public int Bar;
}

二、Marshal.SizeOf方法

靜態型別Marshal定義了一系列API用來幫助我們完成非託管記憶體的分配與拷貝、託管型別和非託管型別之間的轉換,以及其他一系列非託管記憶體的操作(Marshal在計算科學中表示為了資料儲存或者傳輸而將記憶體物件轉換成相應的格式的操作)。靜態其中就包括如下4個SizeOf方法過載來確定指定型別或者物件的位元組數。

public static class Marshal
{
    public static int SizeOf(object structure);
    public static int SizeOf<T>(T structure);
    public static int SizeOf(Type t);
    public static int SizeOf<T>()
}
Marshal.SizeOf方法雖然對指定的型別沒有針對Unmanaged型別的限制,但是依然要求指定一個值型別。如果傳入的是一個物件,該物件也必須是對一個值型別的裝箱。
object  value = default(Foobar);
Debug.Assert(Marshal.SizeOf<Foobar>() == 16);
Debug.Assert(Marshal.SizeOf(value) == 16);
Debug.Assert(Marshal.SizeOf(typeof(Foobar)) == 16);
Debug.Assert(Marshal.SizeOf(typeof(Foobar)) == 16);

public struct Foobar
{
    public object Foo;
    public object Bar;
}

由於如下這個Foobar被定義成了,所以針對兩個SizeOf方法的呼叫都會丟擲ArgumentException異常,並提示:Type 'Foobar' cannot be marshaled as an unmanaged structure; no meaningful size or offset can be computed.

Marshal.SizeOf<Foobar>();
Marshal.SizeOf(new Foobar());

public class Foobar
{
    public object Foo;
    public object Bar;
}

Marshal.SizeOf方法不支援泛型,還對結構體的佈局有要求,它支援支SequentialExplicit佈局模式。由於如下所示的Foobar結構體採用Auto佈局模式(由於非託管環境具有更加嚴格的記憶體佈局要求,所以不支援Auto這種根據欄位成員對記憶體佈局進行「動態規劃」的方式),所以針對SizeOf方法的呼叫還是會丟擲和上面一樣的ArgumentException異常。

Marshal.SizeOf<Foobar>();

[StructLayout(LayoutKind.Auto)]
public struct Foobar
{
    public int Foo;
    public int Bar;
}

三、Unsafe.SizeOf方法>

靜態Unsafe提供了針對非託管記憶體更加底層的操作,類似的SizeIOf方法同樣定義在該型別中。該方法對指定的型別沒有任何限制,但是如果你指定的是參照型別,它會返回「指標位元組數」(IntPtr.Size)。

public static class Unsafe
{
    public static int SizeOf<T>();
}
Debug.Assert( Unsafe.SizeOf<FoobarStructure>() == 16);
Debug.Assert( Unsafe.SizeOf<FoobarClass>() == 8);

public struct FoobarStructure
{
    public long Foo;
    public long Bar;
}

public class FoobarClass
{
    public long Foo;
    public long Bar;
}

四、可以根據欄位成員的型別來計算嗎?

我們知道不論是值型別還是參照型別,對應的範例都對映為一段連續的片段(或者直接儲存在暫存器)。型別的目的就在於規定了物件的記憶體佈局,具有相同型別的範例具有相同的佈局,位元組數量自然相同(對於參照型別的欄位,它在這段位元組序列中只儲存參照的地址)。既然位元組長度由型別來決定,如果我們能夠確定每個欄位成員的型別,那麼我們不就能夠將該型別對應的位元組數計算出來嗎?實際上是不行的。

Debug.Assert(Unsafe.SizeOf<ValueTuple<byte, byte>>() == 2);
Debug.Assert(Unsafe.SizeOf<ValueTuple<byte, short>>() == 4);
Debug.Assert(Unsafe.SizeOf<ValueTuple<byte, int>>() == 8);
Debug.Assert(Unsafe.SizeOf<ValueTuple<byte, long>>() == 16);

一上面的程式為例,我們知道byte、short、int和long的位元組數分別是1、2、4和8,所以一個針對byte的二元組的位元組數為2,但是對於一個針對型別組合分別為byte + short,byte + int,byte + long的二元組來說,對應的位元組並不是3、5和9,而是3、8和16。因為這涉及記憶體對齊(memory alignment)的問題。

五、值型別和參照型別的佈局

對於完全相同的資料成員,參照型別和子型別的範例所佔的位元組數也是不同的。如下圖所示,值型別範例的位元組序列全部用來儲存它的欄位成員。對於參照型別的範例來說,在欄位位元組序列前面還儲存了型別對應方法表(Method Table)的地址。方法表幾乎提供了描述型別的所有後設資料,我們正是利用這個參照來確定範例屬於何種型別。在最前面,還具有額外的位元組,我們將其稱為Object Header,它不僅僅用來儲存物件的鎖定狀態,雜湊值也可以快取在這裡。當我們建立了一個參照型別變數時,這個變數並不是指向範例所佔記憶體的首位元組,而是存放方法表地址的地方

image

六、Ldflda指令

上面我們介紹sizeof操作符和靜態型別Marshal/Unsafe提供的SizeOf方法均不能真正解決範例佔用位元組長度的計算。就我目前的瞭解,這個問題在單純的C#領域都無法解決,但IL層面提供的Ldflda指令可以幫助我們解決這個問題。顧名思義,Ldflda表示Load Field Address,它可以幫助我們得到範例某個欄位的地址。由於這個IL指令在C#中沒有對應的API,所以我們只有採用如下的形式採用IL Emit的來使用它。

public class SizeCalculator
{
    private static Func<object?, long[]> GenerateFieldAddressAccessor(FieldInfo[] fields)
    {
        var method = new DynamicMethod(
            name: "GetFieldAddresses",
            returnType: typeof(long[]),
            parameterTypes: new[] { typeof(object) },
            m: typeof(SizeCalculator).Module,
            skipVisibility: true);
        var ilGen = method.GetILGenerator();

        // var addresses = new long[fields.Length + 1];
        ilGen.DeclareLocal(typeof(long[]));
        ilGen.Emit(OpCodes.Ldc_I4, fields.Length + 1);
        ilGen.Emit(OpCodes.Newarr, typeof(long));
        ilGen.Emit(OpCodes.Stloc_0);

        // addresses[0] = address of instace;
        ilGen.Emit(OpCodes.Ldloc_0);
        ilGen.Emit(OpCodes.Ldc_I4, 0);
        ilGen.Emit(OpCodes.Ldarg_0);
        ilGen.Emit(OpCodes.Conv_I8);
        ilGen.Emit(OpCodes.Stelem_I8);

        // addresses[index] = address of field[index + 1];
        for (int index = 0; index < fields.Length; index++)
        {
            ilGen.Emit(OpCodes.Ldloc_0);
            ilGen.Emit(OpCodes.Ldc_I4, index + 1);
            ilGen.Emit(OpCodes.Ldarg_0);
            ilGen.Emit(OpCodes.Ldflda, fields[index]);
            ilGen.Emit(OpCodes.Conv_I8);
            ilGen.Emit(OpCodes.Stelem_I8);
        }

        ilGen.Emit(OpCodes.Ldloc_0);
        ilGen.Emit(OpCodes.Ret);

        return (Func<object?, long[]>)method.CreateDelegate(typeof(Func<object, long[]>));
    }
    ...
}

如上面的程式碼片段所示,我們在SizeCalculator型別中定了一個GenerateFieldAddressAccessor方法,它會根據指定型別的欄位列表生成一個Func<object?, long[]> 型別的委託,該委託幫助我們返回指定物件及其所有欄位的記憶體地址。有了物件自身的地址和每個欄位的地址,我們自然就可以得到每個欄位的偏移量,進而很容易地計算出整個範例所佔記憶體的位元組數。

七、計算值型別的位元組數

由於值型別和參照型別在記憶體中採用不同的佈局,我們也需要採用不同的計算方式。由於結構體在記憶體中位元組就是所有欄位的內容,所有我們採用一種討巧的計算方法。假設我們需要結算型別為T的結構體的位元組數,那麼我們建立一個ValueTuple<T,T>元組,它的第二個欄位Item2的偏移量就是結構體T的位元組數。具體的計算方式體現在如下這個CalculateValueTypeInstance方法中。

public class SizeCalculator
{
    private static readonly MethodInfo _getDefaultMethod = typeof(SizeCalculator).GetMethod(nameof(GetDefault), BindingFlags.Static | BindingFlags.NonPublic)!;

    public int CalculateValueTypeInstance(Type type)
    {
        var instance = GetDefaultAsObject(type);
        var fields = type.GetFields(BindingFlags.DeclaredOnly | BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic)
            .Where(it => !it.IsStatic)
            .ToArray();

        if (fields.Length == 0) return 0;
        var tupleType = typeof(ValueTuple<,>).MakeGenericType(type, type);
        var tupple = tupleType.GetConstructors()[0].Invoke(new object?[] { instance, instance });
        var addresses = GenerateFieldAddressAccessor(tupleType.GetFields()).Invoke(tupple).OrderBy(it => it).ToArray();
        return (int)(addresses[2] - addresses[0]);
    }

    private static T GetDefault<T>() where T : struct => default!;
    private static object? GetDefaultAsObject(Type type) => _getDefaultMethod.MakeGenericMethod(type).Invoke(null, Array.Empty<object>());
}

如上面的程式碼片段所示, 假設我們需要計算的結構體型別為T,我們呼叫GetDefaultAsObject方法以反射的形式得到default(T)物件,進而將ValueTuple<T,T>元組建立出來。在呼叫GenerateFieldAddressAccessor方法得到用於計算範例及其欄位地址的Func<object?, long[]> 委託後,我們將這個元組作為引數呼叫這個委託。對於得到的三個記憶體地址,程式碼元組和第1、2個欄位的地址是相同的,我們使用代表Item2的第三個地址減去第一個地址,得到的就是我們希望的結果。

八、計算參照型別位元組數

參照型別的位元組計算要複雜一些,具體採用這樣的思路:我們在得到範例自身和每個欄位的地址後,我們對地址進行排序進而得到最後一個欄位的偏移量。我們讓這個偏移量加上最後一個欄位自身的位元組數,再補充上必要的「頭尾位元組」就是我們希望得到的結果,具體計算體現在如下這個CalculateReferneceTypeInstance方法上。

public class SizeCalculator
{
    public int CalculateReferenceTypeInstance(Type type, object instance)
    {
        var fields = GetBaseTypesAndThis(type)
            .SelectMany(type => type.GetFields(BindingFlags.DeclaredOnly | BindingFlags.Instance | BindingFlags.Public | BindingFlags.NonPublic))
            .Where(it => !it.IsStatic).ToArray();

        if (fields.Length == 0) return type.IsValueType ? 0 : 3 * IntPtr.Size;
        var addresses = GenerateFieldAddressAccessor(fields).Invoke(instance);
        var list = new List<FieldInfo>(fields);
        list.Insert(0, null!);
        fields = list.ToArray();
        Array.Sort(addresses, fields);

        var lastFieldOffset = (int)(addresses.Last() - addresses.First());
        var lastField = fields.Last();
        var lastFieldSize = lastField.FieldType.IsValueType ? CalculateValueTypeInstance(lastField.FieldType) : IntPtr.Size;
        var size = lastFieldOffset + lastFieldSize;

        // Round up to IntPtr.Size
        int round = IntPtr.Size - 1;
        return ((size + round) & (~round)) + IntPtr.Size;

        static IEnumerable<Type> GetBaseTypesAndThis(Type? type)
        {
            while (type is not null)
            {
                yield return type;
                type = type.BaseType;
            }
        }
    }
}

如上面的程式碼片段所示,如果指定的型別沒有定義任何欄位,CalculateReferneceTypeInstance 返回參照型別範例的最小位元組數:3倍地址指標位元組數。對於x86架構,一個應用型別物件至少佔用12位元組,包括ObjectHeader(4 bytes)、方法表指標(bytes)和最少4位元組的欄位內容(即使沒有型別沒有定義任何欄位,這個4個位元組也是必需的)。如果是x64架構,這個最小位元組數會變成24,因為方法表指標和最小欄位內容變成了8個位元組,雖然ObjectHeader的有效內容只佔用4個位元組,但是前面會新增4個位元組的Padding。

對於最後欄位所佔位元組的結算也很簡單:如果型別是值型別,那麼就呼叫前面定義的CalculateValueTypeInstance方法進行計算,如果是參照型別,欄位儲存的內容僅僅是目標物件的記憶體地址,所以長度就是IntPtr.Size。由於參照型別範例在記憶體中預設會採用IntPtr.Size對齊,這裡也做了相應的處理。最後不要忘了,參照型別範例的參照指向的並不是記憶體的第一個位元組,而是存放方法表指標的位元組,所以還得加上ObjecthHeader 位元組數(IntPtr.Size)。

九、完整的計算

分別用來計算值型別和參照型別範例位元組數的兩個方法被用在如下這個SizeOf方法中。由於Ldflda指令的呼叫需要提供對應的範例,所以該方法除了提供目標型別外,還提供了一個用來獲得對應範例的委託。該委託對應的引數是可以預設的,對於值型別,我們會使用預設值。對於參照型別,我們也會試著使用預設建構函式來建立目標物件。如果沒有提供此委託物件,也無法建立目標範例,SizeOf方法會丟擲異常。雖然需要提供目標範例,但是計算出的結果只和型別有關,所以我們將計算結果進行了快取。為了呼叫方便,我們還提供了另一個泛型的SizeOf<T>方法。

public class SizeCalculator
{
    private static readonly ConcurrentDictionary<Type, int> _sizes = new();
    public static readonly SizeCalculator Instance = new();
    public int SizeOf(Type type, Func<object?>? instanceAccessor = null)
    {
        if (_sizes.TryGetValue(type, out var size)) return size;
        if (type.IsValueType) return _sizes.GetOrAdd(type, CalculateValueTypeInstance);

        object? instance;
        try
        {
            instance = instanceAccessor?.Invoke() ?? Activator.CreateInstance(type);
        }
        catch
        {
            throw new InvalidOperationException("The delegate to get instance must be specified.");
        }

        return _sizes.GetOrAdd(type, type => CalculateReferenceTypeInstance(type, instance));
    }
    public int SizeOf<T>(Func<T>? instanceAccessor = null)
    {
        if (instanceAccessor is null) return SizeOf(typeof(T));
        Func<object?> accessor = () => instanceAccessor();
        return SizeOf(typeof(T), accessor);
    }
}

在如下的程式碼片段中,我們使用它輸出了兩個具有相同欄位定義的結構體和型別的位元組數。在下一篇文章中,我們將進一步根據計算出的位元組數得到範例在記憶體中的完整二進位制內容,敬請關注。

Debug.Assert( SizeCalculator.Instance.SizeOf<FoobarStructure>() == 16);
Debug.Assert( SizeCalculator.Instance.SizeOf<FoobarClass>() == 32);

public struct FoobarStructure
{
    public byte Foo;
    public long Bar;
}

public class FoobarClass
{
    public byte Foo;
    public long Bar;
}