值类型与引用类型
C#中的类型分为引用类型和值类型。使用struct
或enum
关键字修饰的类型定义是值类型,使用class
或delegate
关键字修饰的类型是引用类型。引用类型和值类型各有限制,分别适用于不同的场景。不同于C++,C#中的值类型只能分配在栈上*1,引用类型只能分配在GC堆上。C#中的GC是精确式GC,这就对GC堆上的指针有了一些要求。这是引用类型有开销的原因之一。
Sync block index 与 Type object pointer
读过C# vir CLR的同学会知道,引用类型的开销是Sync blockindex和Type object pointer。他们的长度是都是一个字长。即在32位CLR上是4字节,在64位CLR上是8字节。Sync block index在CLR中是用于实现lock
,Monitor
等线程同步原语,Type object pointer是指向当前对象运行时类型信息的一个指针。Sync block index与Type object pointer只在CLR层面存在,对C#程序来说是透明的。但是CLR将它们暴封装后露C#,例如线程同步原语和反射API。
特殊的类型-数组
C#中绝大部分类型的大小在编译期就可以确定,只要把对应的成员的大小相加即可
1
| SizeOf(T) = T.GetFields.Select(f=>f.IsClass?WordSize:Sizeof(f)).Sum();
|
数组是个很特殊的存在,特殊在它的大小是与元素的类型和数量有关。数组的内存布局包含了所有的元素。(另一个这样的类型是string
)。byte[4]
和byte[3]
的类型都是byte[]
,然而它们的大小却不一样。我们这次就好仔细观察下数组
使用WinDBG
WinDBG是Windows下常用的Debugger。虽然是以调试非托管代码设计,但是加上相关的插件以后也可以用来调试托管代码。SOS.dll是一个提供托管代码调试支持的的插件。它同时支持CLR和CoreCLR。
安装好WinDBG后,就可以开始调试了。简单起见,这里使用如下代码。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
| namespace SOSFromEE { class Program { const int Length = 10; static void Main(string[] args) { var ints = Enumerable.Range(1, Length).ToArray(); var strs = ints.Select(i => i.ToString()).ToArray(); Console.ReadLine(); GC.KeepAlive(ints); GC.KeepAlive(strs); Console.Read(); } } }
|
在WinDBG中选择Launch对应的可执行程序即可。
在第一个断点时CLR还没有加载,我们继续让程序运行,等到不再出现ModLoad相关的提示时就可以让程序暂停了。
我们在这时加载SOS扩展。
sos后跟的是CLR的名称
加载模块的过程中需要从微软的服务器上下载相关的pdb文件。由于你懂的原因需要很长时间
CLR2.0 下的数组
我们首先在32位的CLR2.0下观察。
首先使用.loadby sos mscorwks
加载SOS
扩展模块。
!dumpheap -type TypeNameHere
命令可以查看当前托管堆上类型名为TypeNameHere
的对象。
System.String[]
我们先看看String[]
都有哪些:
1 2 3 4 5 6 7 8 9 10
| 0:006> !dumpheap -type System.String[] Address MT Size 03591390 78ae46e4 80 .......................... total 17 objects Statistics: MT Count TotalSize Class Name 78ae46e4 17 676 System.Object[] Total 17 objects
|
Address
显示的是对象在托管堆中的地址,Method Table
就是上文中说的Type object pointer了。
我们查看下位于地址03591390
的String[]
:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24
| 0:006> !dumparray 03591390 Name: System.String[] MethodTable: 78ae46e4 EEClass: 788cda74 Size: 80(0x50) bytes Array: Rank 1, Number of elements 16, Type CLASS Element Methodtable: 78b10f14 [0] 035911c8 [1] 03591250 [2] null [3] null [4] null [5] null [6] null [7] null [8] null [9] null [10] null [11] null [12] null [13] null [14] null [15] null
|
可以看到这个数组一共有16个元素,元素的类型是string
。
我们再来看看03591390
位置的内存布局
1 2 3 4 5 6 7
| 0:006> dd 03591390 -0x4 0359138c 00000000 78ae46e4 00000010 78b10f14 0359139c 035911c8 03591250 00000000 00000000 035913ac 00000000 00000000 00000000 00000000 035913bc 00000000 00000000 00000000 00000000 035913cc 00000000 00000000 00000000 00000000
|
dd 03591390 -0x4
告诉WinDBG从03591390
向前偏移4个字节的位置开始展示内存。之所以向前偏移4个字节是为了展示引用类型的开销。0359138c
位置的值00000000
是Sync block index,位于03591390
的78ae46e4
看起来有点摸不着头脑,在WinDBG的输出中查找后发现是System.String[]
的MethodTable
。
1 2
| Name: System.String[] MethodTable: 78ae46e4
|
MethodTable
是CLR级别的概念,对应到这里就是Type Object Pointer。
GC堆上的对象往前偏4字节就能得到Sync block index,接下来是对象的Type object pointer。为了叙述方便,下文统称为对象头:)
由此推测,对象头长度是两个字长。起始位置为当前指针的位置往前偏移一个字长。有兴趣的同学可以在64位CLR上自行验证。
数组的长度是16(0x10),地址03591395
的值就是它。
地址03591388
的78b10f14
不知道是什么。没关系,CTRF+F查找后发现它在上文出现过。
1
| Element Methodtable: 78b10f14
|
应该是String
的Method table
。
dump
一下System.String
:
1 2 3 4
| 0:006> !dumpheap -type System.String MT Count TotalSize Class Name 78b10f14 154 6224 System.String
|
看来猜得没错。总结一下,引用类型的数组有4字长的开销,分别是2字长的对象头,1字长的长度,1字长的元素类型指针
接下来看看值类型数组的布局
System.Int32[]
老样子,首先先找到堆上的Int32[]
.
1 2 3 4 5 6 7 8 9
| 0:006> !dumpheap -type System.Int32[] Address MT Size 03591e50 78b130b0 296 ................................ total 18 objects Statistics: MT Count TotalSize Class Name 78b130b0 18 1440 System.Int32[] Total 18 objects
|
dump一下相关属性
1 2 3 4 5 6 7
| !dumparray 03591e50 Name: System.Int32[] MethodTable: 78b130b0 EEClass: 788ce6a8 Size: 296(0x128) bytes Array: Rank 1, Number of elements 71, Type Int32 Element Methodtable: 78b13160
|
查看内存布局
1 2 3 4 5 6 7 8 9
| 0:006> dd 03591e50 -0x4 03591e4c 00000000 78b130b0 00000047 00000000 03591e5c 00000000 00000000 00000000 00000000 03591e6c 00000000 00000000 00000000 00000000 03591e7c 00000000 00000000 00000000 00000000 03591e8c 00000000 00000000 00000000 00000000 03591e9c 00000000 00000000 00000000 00000000 03591eac 00000000 00000000 00000000 00000000 03591ebc 00000000 00000000 00000000 00000000
|
首先Int32[]
也有对象头和长度的开销,但是却没有元素类型指针的开销。
有心的同学可能已经发现了,Int32[]
明确指出了元素的类型,而String[]
却没有。
1 2 3 4 5 6
| !dumparray 03591390 Name: System.String[] MethodTable: 78ae46e4 EEClass: 788cda74 Size: 80(0x50) bytes Array: Rank 1, Number of elements 16, Type *CLASS*
|
1 2 3 4 5 6
| !dumparray 03591e50 Name: System.Int32[] MethodTable: 78b130b0 EEClass: 788ce6a8 Size: 296(0x128) bytes Array: Rank 1, Number of elements 71, Type *Int32*
|
String[]
的Type
是个CLASS
而不是String
,难道说System.String[]是个’假的’的字符串数组?
….
….
….
恭喜你猜对了。
使用!objsize
命令发现String[]
是个带了层皮的Object[]
1 2 3 4
| !objsize 03591e50 sizeof(03591e50) = 296 ( 0x128) bytes (System.Int32[]) 0:006> !objsize 03591390 sizeof(03591390) = 392 ( 0x188) bytes (System.Object[])
|
C# in depth中对泛型有这样的描述
对于一个泛型类MyGeneric<T>
,对于T
的是引用类型的情况,JIT只会为其生成一份代码;对于T
是值类型的情况,则为每一个不同的T
生成各自的代码。其中的原因是,在JIT运行时,指针的长度总是固定的,因而可以共用一套代码相同的代码。而值类型的长度是不确定的,因此需要为每个值类型单独生成代码。
这里可能也是相同的原因吧。指针的长度相同,因而才需要储存元素的类型指针,实现类型检查。而值类型的代码不共用,所以不需要储存元素的类型指针。
本来到已经可以结束了,可是我在32位的CLR4.0观察到的结果却不太一样。
CLR 4.0下的数组
与CLR 2.0不同,CLR 4.0下加载SOS
的名字是clr
.loadby sos clr
System.String[]
首先dumpheap
:
1 2 3 4 5 6 7 8
| 0:006> !dumpheap -type System.String[] Address MT Size 02531590 6979dfe0 84 ........................... Statistics: MT Count TotalSize Class Name 6979dfe0 24 912 System.String[] Total 24 objects
|
然后是dumparray
:
1 2 3 4 5 6 7
| Name: System.String[] MethodTable: 6979dfe0 EEClass: 69374b80 Size: 84(0x54) bytes Array: Rank 1, Number of elements 18, Type CLASS Element Methodtable: 6979d488 [0] 02531254
|
最后dd
:
1 2 3 4 5 6 7
| 0:006> dd 02531590 -0x4 0253158c 00000000 6979dfe0 00000012 02531254 0253159c 025312d8 00000000 00000000 02531568 025315ac 00000000 00000000 00000000 00000000 025315bc 00000000 00000000 00000000 00000000 025315cc 00000000 00000000 00000000 00000000 025315dc 00000000 00000000
|
位于02531598
的值02531254
是第一个元素的值而不是String
的Method Table
!
1 2 3 4 5
| !dumpheap -type String Statistics: MT Count TotalSize Class Name 6979d488 193 5932 System.String Total 224 objects
|
CLR 4.0把Method Table
去掉了?
经过简单的算术,确实是这样的
18个元素,占据空间18*4=72。
对象头和数组大小占据2*4+4=12
84=72+12,跟dumparray
出来的值一样。(有兴趣的同学根据上文中CLR2.0的数据计算下)
!objsize
也确认了我们的猜测
1 2
| 0:006> !objsize 02531590 sizeof(02531590) = 428 (0x1ac) bytes (System.String[])
|
System.Int32[]
!dumpheap
:
1 2 3 4 5 6 7 8
| 0:006> !dumpheap -type System.Int32[] Address MT Size 02531f1c 6979f2a0 300 .............................. Statistics: MT Count TotalSize Class Name 6979f2a0 20 844 System.Int32[] Total 20 objects
|
!dumparray
:
1 2 3 4 5 6 7 8 9
| 0:006> !dumparray 02531f1c Name: System.Int32[] MethodTable: 6979f2a0 EEClass: 693752d8 Size: 300(0x12c) bytes Array: Rank 1, Number of elements 72, Type Int32 Element Methodtable: 6979f2dc [0] 02531f24 [1] 02531f28
|
dd
:
1 2 3 4 5 6 7 8 9
| 0:006> dd 02531f1c -0x4 02531f18 00000000 6979f2a0 00000048 00000003 02531f28 00000007 0000000b 00000011 00000017 02531f38 0000001d 00000025 0000002f 0000003b 02531f48 00000047 00000059 0000006b 00000083 02531f58 000000a3 000000c5 000000ef 00000125 02531f68 00000161 000001af 00000209 00000277 02531f78 000002f9 00000397 0000044f 0000052f 02531f88 0000063d 0000078b 0000091d 00000af1
|
值类型数组倒是没有什么变化。
总结
C#中引用类型有2个字长的对象头开销。分别是Sync block index和Type Object Pointer。数组是特殊的类型,它的大小与包含的元素相关,因此具有额外的开销。
在CLR 2.0下
- 引用类型的数组包含额外的2字长的开销。分别是长度和元素的类型指针
- 值类型的数组包含长度的额外开销开销。大小是一个字长。
在CLR 4.0和CoreCLR中
- 引用类型和值类型的数组包含长度的额外开销开销。大小是一个字长。
限于篇幅,CoreCLR的情况不再赘述,还请读者自行验证。
/*其实本来看到Stackoverflow的回答只是想自己验证下的,但是自己动手的结果和答案里提到的不太一样,查了原因发现答案里用的是CLR2.0,我自己用的是CLR4.0。这就挖出来了CLR实现的更改。CoreCLR里使用的是CLR4.0里的规则。目前还不清楚MS为何要改实现*/
参考