詳解C#如何計算一個實例占用多少內(nèi)存
我們都知道CPU和內(nèi)存是程序最為重要的兩類指標,那么有多少人真正想過這個問題:一個類型(值類型或者引用類型)的實例在內(nèi)存中究竟占多少字節(jié)?我們很多人都回答不上來。其實C#提供了一些用于計算大小的操作符和API,但是它們都不能完全解決我剛才提出的問題。本文提供了一種計算值類型和引用類型實例所占內(nèi)存字節(jié)數(shù)量的方法。
一、sizeof操作符
sizeof操作用來確定某個類型對應實例所占用的字節(jié)數(shù),但是它只能應用在Unmanaged類型上。所謂的Unmanaged類型僅限于:
- 原生類型(Primitive Type:Boolean, Byte, SByte, Int16, UInt16, Int32, UInt32, Int64, UInt64, IntPtr, UIntPtr, Char, Double, 和Single)
- Decimal類型
- 枚舉類型
- 指針類型
- 只包含Unmanaged類型數(shù)據(jù)成員的結構體
顧名思義,一個Unmanaged類型是一個值類型,對應的實例不能包含任何一個針對托管對象的引用。如果我們定義如下這樣一個泛型方法來調(diào)用sizeof操作符,泛型參數(shù)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類型,所以程序會出現(xiàn)編譯錯誤。
unsafe { Debug.Assert(sizeof(Foobar) == 16); } public struct Foobar { public string Foo; public int Bar; }
二、Marshal.SizeOf方法
靜態(tài)類型Marshal定義了一系列API用來幫助我們完成非托管內(nèi)存的分配與拷貝、托管類型和非托管類型之間的轉換,以及其他一系列非托管內(nèi)存的操作(Marshal在計算科學中表示為了數(shù)據(jù)存儲或者傳輸而將內(nèi)存對象轉換成相應的格式的操作)。靜態(tài)其中就包括如下4個SizeOf方法重載來確定指定類型或者對象的字節(jié)數(shù)。
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方法的調(diào)用都會拋出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方法不支持泛型,還對結構體的布局有要求,它支持支Sequential和Explicit布局模式。由于如下所示的Foobar結構體采用Auto布局模式(由于非托管環(huán)境具有更加嚴格的內(nèi)存布局要求,所以不支持Auto這種根據(jù)字段成員對內(nèi)存布局進行“動態(tài)規(guī)劃”的方式),所以針對SizeOf方法的調(diào)用還是會拋出和上面一樣的ArgumentException異常。
Marshal.SizeOf<Foobar>(); [StructLayout(LayoutKind.Auto)] public struct Foobar { public int Foo; public int Bar; }
三、Unsafe.SizeOf方法>
靜態(tài)Unsafe提供了針對非托管內(nèi)存更加底層的操作,類似的SizeIOf方法同樣定義在該類型中。該方法對指定的類型沒有任何限制,但是如果你指定的是引用類型,它會返回“指針字節(jié)數(shù)”(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; }
四、可以根據(jù)字段成員的類型來計算嗎
我們知道不論是值類型還是引用類型,對應的實例都映射為一段連續(xù)的片段(或者直接存儲在寄存器)。類型的目的就在于規(guī)定了對象的內(nèi)存布局,具有相同類型的實例具有相同的布局,字節(jié)數(shù)量自然相同(對于引用類型的字段,它在這段字節(jié)序列中只存儲引用的地址)。既然字節(jié)長度由類型來決定,如果我們能夠確定每個字段成員的類型,那么我們不就能夠將該類型對應的字節(jié)數(shù)計算出來嗎?實際上是不行的。
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的字節(jié)數(shù)分別是1、2、4和8,所以一個針對byte的二元組的字節(jié)數(shù)為2,但是對于一個針對類型組合分別為byte + short,byte + int,byte + long的二元組來說,對應的字節(jié)并不是3、5和9,而是3、8和16。因為這涉及內(nèi)存對齊(memory alignment)的問題。
五、值類型和應用類型的布局
對于完全相同的數(shù)據(jù)成員,引用類型和子類型的實例所占的字節(jié)數(shù)也是不同的。如下圖所示,值類型實例的字節(jié)序列全部用來存儲它的字段成員。對于引用類型的實例來說,在字段字節(jié)序列前面還存儲了類型對應方法表(Method Table)的地址。方法表幾乎提供了描述類型的所有元數(shù)據(jù),我們正是利用這個引用來確定實例屬于何種類型。在最前面,還具有額外的字節(jié),我們將其稱為Object Header,它不僅僅用來存儲對象的鎖定狀態(tài),哈希值也可以緩存在這里。當我們創(chuàng)建了一個引用類型變量時,這個變量并不是指向實例所占內(nèi)存的首字節(jié),而是存放方法表地址的地方。
六、Ldflda指令
上面我們介紹sizeof操作符和靜態(tài)類型Marshal/Unsafe提供的SizeOf方法均不能真正解決實例占用字節(jié)長度的計算。就我目前的了解,這個問題在單純的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方法,它會根據(jù)指定類型的字段列表生成一個Func<object?, long[]> 類型的委托,該委托幫助我們返回指定對象及其所有字段的內(nèi)存地址。有了對象自身的地址和每個字段的地址,我們自然就可以得到每個字段的偏移量,進而很容易地計算出整個實例所占內(nèi)存的字節(jié)數(shù)。
七、計算值類型的字節(jié)數(shù)
由于值類型和引用類型在內(nèi)存中采用不同的布局,我們也需要采用不同的計算方式。由于結構體在內(nèi)存中字節(jié)就是所有字段的內(nèi)容,所有我們采用一種討巧的計算方法。假設我們需要結算類型為T的結構體的字節(jié)數(shù),那么我們創(chuàng)建一個ValueTuple<T,T>元組,它的第二個字段Item2的偏移量就是結構體T的字節(jié)數(shù)。具體的計算方式體現(xiàn)在如下這個CalculateValueTypeInstance方法中。
public class SizeCalculator { 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 tuple = tupleType.GetConstructors()[0].Invoke(new object?[] { instance, instance }); var addresses = GenerateFieldAddressAccessor(tupleType.GetFields()).Invoke(tuple).OrderBy(it => it).ToArray(); return (int)(addresses[2] - addresses[0]); } }
如上面的代碼片段所示, 假設我們需要計算的結構體類型為T,我們調(diào)用GetDefaultAsObject方法以反射的形式得到default(T)對象,進而將ValueTuple<T,T>元組創(chuàng)建出來。在調(diào)用GenerateFieldAddressAccessor方法得到用于計算實例及其字段地址的Func<object?, long[]> 委托后,我們將這個元組作為參數(shù)調(diào)用這個委托。對于得到的三個內(nèi)存地址,代碼元組和第1、2個字段的地址是相同的,我們使用代表Item2的第三個地址減去第一個地址,得到的就是我們希望的結果。
八、計算引用類型字節(jié)數(shù)
引用類型的字節(jié)計算要復雜一些,具體采用這樣的思路:我們在得到實例自身和每個字段的地址后,我們對地址進行排序進而得到最后一個字段的偏移量。我們讓這個偏移量加上最后一個字段自身的字節(jié)數(shù),再補充上必要的“頭尾字節(jié)”就是我們希望得到的結果,具體計算體現(xiàn)在如下這個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 返回引用類型實例的最小字節(jié)數(shù):3倍地址指針字節(jié)數(shù)。對于x86架構,一個應用類型對象至少占用12字節(jié),包括ObjectHeader(4 bytes)、方法表指針(bytes)和最少4字節(jié)的字段內(nèi)容(即使沒有類型沒有定義任何字段,這個4個字節(jié)也是必需的)。如果是x64架構,這個最小字節(jié)數(shù)會變成24,因為方法表指針和最小字段內(nèi)容變成了8個字節(jié),雖然ObjectHeader的有效內(nèi)容只占用4個字節(jié),但是前面會添加4個字節(jié)的Padding。
對于最后字段所占字節(jié)的結算也很簡單:如果類型是值類型,那么就調(diào)用前面定義的CalculateValueTypeInstance方法進行計算,如果是引用類型,字段存儲的內(nèi)容僅僅是目標對象的內(nèi)存地址,所以長度就是IntPtr.Size。由于引用類型實例在內(nèi)存中默認會采用IntPtr.Size對齊,這里也做了相應的處理。最后不要忘了,引用類型實例的引用指向的并不是內(nèi)存的第一個字節(jié),而是存放方法表指針的字節(jié),所以還得加上ObjecthHeader 字節(jié)數(shù)(IntPtr.Size)。
九、完整的計算
分別用來計算值類型和引用類型實例字節(jié)數(shù)的兩個方法被用在如下這個SizeOf方法中。由于Ldflda指令的調(diào)用需要提供對應的實例,所以該方法除了提供目標類型外,還提供了一個用來獲得對應實例的委托。該委托對應的參數(shù)是可以缺省的,對于值類型,我們會使用默認值。對于引用類型,我們也會試著使用默認構造函數(shù)來創(chuàng)建目標對象。如果沒有提供此委托對象,也無法創(chuàng)建目標實例,SizeOf方法會拋出異常。雖然需要提供目標實例,但是計算出的結果只和類型有關,所以我們將計算結果進行了緩存。為了調(diào)用方便,我們還提供了另一個泛型的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); } }
在如下的代碼片段中,我們使用它輸出了兩個具有相同字段定義的結構體和類型的字節(jié)數(shù)。在下一篇文章中,我們將進一步根據(jù)計算出的字節(jié)數(shù)得到實例在內(nèi)存中的完整二進制內(nèi)容,敬請關注。
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; }
以上就是詳解C#如何計算一個實例占用多少內(nèi)存的詳細內(nèi)容,更多關于C#計算內(nèi)存的資料請關注腳本之家其它相關文章!
相關文章
C#使用遠程服務調(diào)用框架Apache Thrift
這篇文章介紹了C#使用遠程服務調(diào)用框架Apache Thrift的方法,文中通過示例代碼介紹的非常詳細。對大家的學習或工作具有一定的參考借鑒價值,需要的朋友可以參考下2022-06-06