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