.NET 500w的引用类型和值类型到底有多大差异?

大家在写代码的时候,相信有很多朋友对 struct 认知不是很足,导致能用 class 的地方绝对不用struct,但大家有没有发现,最近的几个 C# 版本中,底层框架中有很多 class 的替代品,比如说:

  1. Task 和 ValueTask

  2. Tuple 和 ValueTuple。

本质上来说都是为了提少 GC 负担,提高程序性能。.

今天就和大家简单聊下,struct 和 class 到底在内存占用上有多大差距,首先我们分别定义两个空类型,然后分别灌入 500w 。

    class Program
    {
        static void Main(string[] args)
        {
            var list = new List<Test>(5000000);

            var valueList = new List<ValueTest>(5000000);

            for (int i = 0; i < 5000000; i++)
            {
                list.Add(new Test());
                valueList.Add(new ValueTest());
            }

            Console.WriteLine("结束");
            Console.ReadLine();
        }
    }

    class Test
    {
    }

    struct ValueTest
    {
    }

接下来用 windbg 看一下差异。

0:000> !clrstack -a
OS Thread Id: 0x4040 (0)
        Child SP               IP Call Site
00000000001CE920 00007ffb8fb147bc System.Console.ReadLine() [/_/src/libraries/System.Console/src/System/Console.cs @ 629]
00000000001CE950 00007ffb2b4c621b ConsoleApp6.Program.Main(System.String[]) [D:\net5\ConsoleApp1\ConsoleApp6\Program.cs @ 24]
    PARAMETERS:
        args (0x00000000001CE9D0) = 0x000000000281a650
    LOCALS:
        0x00000000001CE9B8 = 0x000000000281b678
        0x00000000001CE9B0 = 0x000000000281b698
        0x00000000001CE9AC = 0x00000000004c4b40
        0x00000000001CE9A0 = 0x0000000000000000
        0x00000000001CE99C = 0x0000000000000000

0:000> !DumpObj /d 000000000281b678
Name:        System.Collections.Generic.List`1[[ConsoleApp6.Test, ConsoleApp6]]
MethodTable: 00007ffb2b594240
EEClass:     00007ffb2b57f0b0
Size:        32(0x20) bytes
File:        C:\Program Files\dotnet\shared\Microsoft.NETCore.App\5.0.13\System.Private.CoreLib.dll
Fields:
              MT    Field   Offset                 Type VT     Attr            Value Name
00007ffb2b597638  4001d3c        8     System.__Canon[]  0 instance 0000000012811038 _items
00007ffb2b48b258  4001d3d       10         System.Int32  1 instance          5000000 _size
00007ffb2b48b258  4001d3e       14         System.Int32  1 instance          5000000 _version
00007ffb2b597638  4001d3f        8     System.__Canon[]  0   static dynamic statics NYI                 s_emptyArray
0:000> !DumpObj /d 000000000281b698
Name:        System.Collections.Generic.List`1[[ConsoleApp6.ValueTest, ConsoleApp6]]
MethodTable: 00007ffb2b594de8
EEClass:     00007ffb2b5a5ea0
Size:        32(0x20) bytes
File:        C:\Program Files\dotnet\shared\Microsoft.NETCore.App\5.0.13\System.Private.CoreLib.dll
Fields:
              MT    Field   Offset                 Type VT     Attr            Value Name
00007ffb2b596c60  4001d3c        8 ...eApp6.ValueTest[]  0 instance 0000000014e36a70 _items
00007ffb2b48b258  4001d3d       10         System.Int32  1 instance          5000000 _size
00007ffb2b48b258  4001d3e       14         System.Int32  1 instance          5000000 _version
00007ffb2b596c60  4001d3f        8 ...eApp6.ValueTest[]  0   static dynamic statics NYI                 s_emptyArray
0:000> !objsize 000000000281b678
sizeof(000000000281B678) = 160000056 (0x9896838) bytes (System.Collections.Generic.List`1[[ConsoleApp6.Test, ConsoleApp6]])
0:000> !objsize 000000000281b698
sizeof(000000000281B698) = 5000056 (0x4c4b78) bytes (System.Collections.Generic.List`1[[ConsoleApp6.ValueTest, ConsoleApp6]])

从输出中可以看到,list=160M,而 valuelist=5M 居然相差 32 倍, 这种量级的差异,在高性能的场景下足以让我们充分考量了,对吧!

我相信有很多朋友应该能搞明白为什么会是 32 倍。真有不明白的同学,我再来分析一波吧。

先看struct,用 dp 0000000014e36a70 看内存地址。

0:000> !da 0000000014e36a70
Name:        ConsoleApp6.ValueTest[]
MethodTable: 00007ffb2b596c60
EEClass:     00007ffb2b596be0
Size:        5000024(0x4c4b58) bytes
Array:       Rank 1, Number of elements 5000000, Type VALUETYPE
Element Methodtable: 00007ffb2b594760
[0] 0000000014e36a80
[1] 0000000014e36a81
[2] 0000000014e36a82
[3] 0000000014e36a83
[4] 0000000014e36a84
[5] 0000000014e36a85
[6] 0000000014e36a86
[7] 0000000014e36a87
[8] 0000000014e36a88
[9] 0000000014e36a89
[10] 0000000014e36a8a
[11] 0000000014e36a8b
[12] 0000000014e36a8c
[13] 0000000014e36a8d
[14] 0000000014e36a8e
[15] 0000000014e36a8f
[16] 0000000014e36a90
...

0:000> dp 0000000014e36a70
00000000`14e36a70  00007ffb`2b596c60 00000000`004c4b40
00000000`14e36a80  00000000`00000000 00000000`00000000
00000000`14e36a90  00000000`00000000 00000000`00000000
00000000`14e36aa0  00000000`00000000 00000000`00000000
00000000`14e36ab0  00000000`00000000 00000000`00000000
00000000`14e36ac0  00000000`00000000 00000000`00000000
00000000`14e36ad0  00000000`00000000 00000000`00000000
00000000`14e36ae0  00000000`00000000 00000000`00000000

从输出看,对于一个空 struct 而言在内存中只占用了 1byte

接下来看一下 引用类型,用 dp 0000000012811038 即可。

0:000> dp 0000000012811038
00000000`12811038  00007ffb`2b596a80 00000000`004c4b40
00000000`12811048  00000000`028110e8 00000000`02811100
00000000`12811058  00000000`02811118 00000000`02811130
00000000`12811068  00000000`02811148 00000000`02811160
00000000`12811078  00000000`02811178 00000000`02812500
00000000`12811088  00000000`028128a8 00000000`028128c0
00000000`12811098  00000000`028128d8 00000000`028128f0
00000000`128110a8  00000000`02812908 00000000`028129e8

刚才也提到了两者相差32倍,也就是一个引用类型应该要占用 32byte才对,是吧,那这个是怎么算的呢?首先在 64bit 平台引用类型的最小size=3*8=24byte, 也即 **(对象头+方法表指针+空占位符)**, 这个 size 在 coreclr 中也是有 const 声明的, 剩下的 8byte 就是上面用 dp 命令看到的数组中的每一元素的 方法表指针 啦。

至此,大家都明白了吧。