C# 中如何计算一个实例占用多少内存?

前言

我们都知道CPU和内存是程序最为重要的两类指标,那么有多少人真正想过这个问题:一个类型(值类型或者引用类型)的实例在内存中究竟占多少字节?我们很多人都回答不上来。

其实C#提供了一些用于计算大小的操作符和API,但是它们都不能完全解决我刚才提出的问题。本文提供了一种计算值类型和引用类型实例所占内存字节数量的方法。

源代码下载:https://files.cnblogs.com/files/artech/SizeCalculator.7z?t=1685930731&download=true

一、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标记。

代码语言:javascript
复制
public static unsafe int SizeOf<T>() where T : unmanaged => sizeof(T);

只有原生类型和枚举类型可以直接使用sizeof操作符,如果将它应用在其他类型(指针和自定义结构体),必须添加/unsafe编译标记,还需要放在unsafe上下文中。

代码语言:javascript
复制
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方法重载来确定指定类型或者对象的字节数。

代码语言:javascript
复制
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类型的限制,但是依然要求指定一个值类型。如果传入的是一个对象,该对象也必须是对一个值类型的装箱。

代码语言:javascript
复制
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.

代码语言:javascript
复制
Marshal.SizeOf<Foobar>();
Marshal.SizeOf(new Foobar());

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

Marshal.SizeOf方法不支持泛型,还对结构体的布局有要求,它支持支Sequential和Explicit布局模式。由于如下所示的Foobar结构体采用Auto布局模式(由于非托管环境具有更加严格的内存布局要求,所以不支持Auto这种根据字段成员对内存布局进行“动态规划”的方式),所以针对SizeOf方法的调用还是会抛出和上面一样的ArgumentException异常。

代码语言:javascript
复制
Marshal.SizeOf<Foobar>();

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

三、Unsafe.SizeOf方法

静态Unsafe提供了针对非托管内存更加底层的操作,类似的SizeIOf方法同样定义在该类型中。该方法对指定的类型没有任何限制,但是如果你指定的是引用类型,它会返回“指针字节数”(IntPtr.Size)。

代码语言:javascript
复制
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;
}

四、可以根据字段成员的类型来计算吗?

我们知道不论是值类型还是引用类型,对应的实例都映射为一段连续的片段(或者直接存储在寄存器)。

类型的目的就在于规定了对象的内存布局,具有相同类型的实例具有相同的布局,字节数量自然相同(对于引用类型的字段,它在这段字节序列中只存储引用的地址)。

既然字节长度由类型来决定,如果我们能够确定每个字段成员的类型,那么我们不就能够将该类型对应的字节数计算出来吗?实际上是不行的。

代码语言:javascript
复制
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,它不仅仅用来存储对象的锁定状态,哈希值也可以缓存在这里。当我们创建了一个引用类型变量时,这个变量并不是指向实例所占内存的首字节,而是存放方法表地址的地方。

六、Ldflda指令

上面我们介绍sizeof操作符和静态类型Marshal/Unsafe提供的SizeOf方法均不能真正解决实例占用字节长度的计算。

就我目前的了解,这个问题在单纯的C#领域都无法解决,但IL层面提供的Ldflda指令可以帮助我们解决这个问题。顾名思义,Ldflda表示Load Field Address,它可以帮助我们得到实例某个字段的地址。

由于这个IL指令在C#中没有对应的API,所以我们只有采用如下的形式采用IL Emit的来使用它。

代码语言:javascript
复制
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 &lt; 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&lt;object?, long[]&gt;)method.CreateDelegate(typeof(Func&lt;object, long[]&gt;));
}
...

}

如上面的代码片段所示,我们在SizeCalculator类型中定了一个GenerateFieldAddressAccessor方法,它会根据指定类型的字段列表生成一个Func<object?, long[]> 类型的委托,该委托帮助我们返回指定对象及其所有字段的内存地址。

有了对象自身的地址和每个字段的地址,我们自然就可以得到每个字段的偏移量,进而很容易地计算出整个实例所占内存的字节数。

七、计算值类型的字节数

由于值类型和引用类型在内存中采用不同的布局,我们也需要采用不同的计算方式。由于结构体在内存中字节就是所有字段的内容,所有我们采用一种讨巧的计算方法。

假设我们需要结算类型为T的结构体的字节数,那么我们创建一个ValueTuple<T,T>元组,它的第二个字段Item2的偏移量就是结构体T的字节数。

具体的计算方式体现在如下这个CalculateValueTypeInstance方法中。

代码语言:javascript
复制
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 =&gt; !it.IsStatic)
        .ToArray();

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

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

}

如上面的代码片段所示, 假设我们需要计算的结构体类型为T,我们调用GetDefaultAsObject方法以反射的形式得到default(T)对象,进而将ValueTuple<T,T>元组创建出来。

在调用GenerateFieldAddressAccessor方法得到用于计算实例及其字段地址的Func<object?, long[]> 委托后,我们将这个元组作为参数调用这个委托。对于得到的三个内存地址,代码元组和第1、2个字段的地址是相同的,我们使用代表Item2的第三个地址减去第一个地址,得到的就是我们希望的结果。

八、计算引用类型字节数

引用类型的字节计算要复杂一些,具体采用这样的思路:我们在得到实例自身和每个字段的地址后,我们对地址进行排序进而得到最后一个字段的偏移量。

我们让这个偏移量加上最后一个字段自身的字节数,再补充上必要的“头尾字节”就是我们希望得到的结果,具体计算体现在如下这个CalculateReferneceTypeInstance方法上。

代码语言:javascript
复制
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&lt;FieldInfo&gt;(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) &amp; (~round)) + IntPtr.Size;

    static IEnumerable&lt;Type&gt; 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>方法。

代码语言:javascript
复制
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(&#34;The delegate to get instance must be specified.&#34;);
    }

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

}

在如下的代码片段中,我们使用它输出了两个具有相同字段定义的结构体和类型的字节数。

在下一篇文章中,我们将进一步根据计算出的字节数得到实例在内存中的完整二进制内容,敬请关注。

代码语言:javascript
复制
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;
}