引用类型的开销

  1. 1. 值类型与引用类型
  2. 2. Sync block index 与 Type object pointer
  3. 3. 特殊的类型-数组
  4. 4. 使用WinDBG
  5. 5. CLR2.0 下的数组
    1. 5.1. System.String[]
    2. 5.2. System.Int32[]
  6. 6. CLR 4.0下的数组
    1. 6.1. System.String[]
    2. 6.2. System.Int32[]
  7. 7. 总结
  8. 8. 参考

值类型与引用类型

C#中的类型分为引用类型和值类型。使用structenum关键字修饰的类型定义是值类型,使用classdelegate关键字修饰的类型是引用类型。引用类型和值类型各有限制,分别适用于不同的场景。不同于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扩展。

1
.loadby sos CLRNameHere

sos后跟的是CLR的名称

  • CLR2.0(.Net framework 3.5及以前)是mscorwks

  • CLR4.0(.Net framework 4.0及以后)是clr

  • .net core是 coreclr

加载模块的过程中需要从微软的服务器上下载相关的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了。

我们查看下位于地址03591390String[]:

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,位于0359139078ae46e4看起来有点摸不着头脑,在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的值就是它。

地址0359138878b10f14不知道是什么。没关系,CTRF+F查找后发现它在上文出现过。

1
Element Methodtable: 78b10f14

应该是StringMethod 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是第一个元素的值而不是StringMethod 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为何要改实现*/

参考