透过WinDBG的视角看String

摘要 : 最近在博客园里面看到有人在讨论 C# String的一些特性. 大部分情况下是从CODING的角度来讨论String. 本人觉得非常好奇, 在运行时态, String是如何与这些特性联系上的. 本文将侧重在通过WinDBG来观察String在进程内的布局, 以此来解释C# String的一些特性.

问题

C# String有两个比较有趣的特性.

  1. String的恒定性.
    字符串横定性是指一个字符串一经创建,就不可改变。那么也就是说当我们改变string值的时候,便会在托管堆上重新分配一块新的内存空间,而不会影响到原有的内存地址上所存储的值。

  2. String的驻留.
    CLR runtime通过维护一个表来存放字符串,该表称为拘留池,它包含程序中以编程方式声明或创建的每个唯一的字符串的一个引用。因此,具有特定值的字符串的实例在系统中只有一个。

对应着两个特性, 我产生了一些疑问.

  • String的恒定性是怎么样让string进行比较的时候出现有趣的结果的? 它的比较结果为什么会与其他引用类型的结果不一样?
  • 什么样的String会被放到拘留池中?
  • 拘留池是怎样的数据结构? 它真是个Hashtable吗?
  • 驻留在拘留池内的String会不会被GC, 它的生命周期会有多长(什么时候才会被回收)?

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
25
26
27
28
29
private static void Comparation()
{

string a = “Test String”;
string b = “Test String”;
string c = a;

Console.WriteLine(“a vs b : “ + object.ReferenceEquals(a, b));
Console.WriteLine(“a vs c : “ + object.ReferenceEquals(a, c));

SimpleObject smp1 = new SimpleObject(a);
SimpleObject smp2 = new SimpleObject(a);

Console.WriteLine(“smp1 vs smp2 : “ + object.ReferenceEquals(smp1, smp2));
Console.ReadLine();

}

class SimpleObject
{

public string name = string.Empty;

public SimpleObject(string name)
{
this.name = name;
}

}

执行后结果如下:
logo

从结果上看, 虽然是不同的变量 a, b, c. 由于字符串的内容是相同的, 所以比较的结果也是完全相同的. 对比SimpleObject的实例, smp1和smp2的值虽然也是相同的,但是比较的结果为false.

下面看一下运行时, 这些objects的的情况.

在运行时态, 一切皆是地址. 判断两个变量是否是相同的对象, 直观的可以从它地址是否是相同的地址来进行判断.

用!dso命令打印出栈上对应的Objects. 可以看到Test String”虽然出现了3次, 但是他们都对应了一个地址0000000002473f90 . SimpleObject的对象实例出现了2次, 而且地址不一样, 分别是0000000002477670
0000000002477688
所以, 在使用String的时候, 实质上是重用了相同的String 对象. 在new一个SimpleObject的实例时候, 每一次new都会在新的地址上初始化该对象的结构. 每次都是一个新的对象.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51

0:000> !dso
OS Thread Id: 0x3f0c (0)
RSP/REG Object Name
……

000000000043e730 0000000002473f90 System.String

000000000043e738 0000000002473f90 System.String

000000000043e740 0000000002473f90 System.String

000000000043e748 0000000002477670 ConsoleApplication3.SimpleObject

000000000043e750 0000000002477688 ConsoleApplication3.SimpleObject

…….

0:000> !do 0000000002473f90

Name: System.String

MethodTable: 00007ffdb0817df0

EEClass: 00007ffdb041e560

Size: 48(0x30) bytes

GC Generation: 0

(C:\windows\assembly\GAC_64\mscorlib\2.0.0.0__b77a5c561934e089\mscorlib.dll)

String: Test String

Fields:

MT Field Offset Type VT Attr Value Name

00007ffdb081f060 4000096 8 System.Int32 1 instance 12 m_arrayLength

00007ffdb081f060 4000097 c System.Int32 1 instance 11 m_stringLength

00007ffdb0819838 4000098 10 System.Char 1 instance 54 m_firstChar

00007ffdb0817df0 4000099 20 System.String 0 shared static Empty

>> Domain:Value 0000000000581880:0000000002471308 <<

00007ffdb08196e8 400009a 28 System.Char[] 0 shared static WhitespaceChars

>> Domain:Value 0000000000581880:0000000002471be0 <<

当字符串内容发生改变的时候, 任何微小的变化都会重新创建出一个新的String对象. 在我们调用这段代码的时候

1
Console.WriteLine("a vs b : " + object.ReferenceEquals(a, b));

CLR runtime实际上做了两件事情. 为字符“a vs b”分配了到了一个新的地址. 将对比结果与刚才的字符拼接到了一起, 分配到了另外一个新的地址. 如果多次拼接字符串, 就会分配到更多的新地址上, 从而可能会快速的占用大量的虚拟内存. 这就是为什么微软建议在这种情况下使用StringBuilder的原因.

1
2
3
4
5
6
7
8
9
10
0:000> !dso
Listing objects from: 0000000000435000 to 0000000000440000 from thread: 0 [3f0c]
Address Method Table Heap Gen Size Type
…..

0000000002473fc0 00007ffdb0817df0 0 0 44 System.String a vs b : False

0000000002474138 00007ffdb0817df0 0 0 52 System.String a vs b : True

…..

String的驻留

CLR runtime通过维护一个表来存放字符串,该表称为拘留池,它包含程序中以编程方式声明或创建的每个唯一的字符串的一个引用。因此,具有特定值的字符串的实例在系统中只有一个。

我们看一下如何来理解这句话.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
static void Main(string[] args)
{
int i = 0;
while (true)
{
SimpleString(i++);
Console.WriteLine( i + ": Run GC.Collect()");
GC.Collect();
Console.ReadLine();
}
}

private static void SimpleString(int i)
{
string s = "SimpleString method";
string c = "Concat String";
Console.WriteLine(s + c);
Console.WriteLine(s + i.ToString());
Console.ReadLine();
}

这是第一次的执行结果. 此时只执行到了SimpleString里面, 还没有从这个方法返回.
logo

我们可以看到stack上有4个string. 分别是按照代码逻辑拼接起来的string的内容. 从这里我们就可以当我们在拼接字符串的时候, 实际上会在Heap上创建出多个String的对象, 以此来完成这个拼接动作.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
0:000> !dso

Listing objects from: 0000000000386000 to 0000000000390000 from thread: 0 [3f50]

…..

0000000002a93f70 00007ffdb0817df0 0 0 66 System.String SimpleString method

0000000002a93fb8 00007ffdb0817df0 0 0 52 System.String Concat String

0000000002a93ff0 00007ffdb0817df0 0 0 92 System.String SimpleString method Concat String

0000000002a97a90 00007ffdb0817df0 0 0 28 System.String 0

0000000002a97ab0 00007ffdb0817df0 0 0 68 System.String SimpleString method 0

……

随意用其中一个来检查它的引用情况.

从!gcroot的结果看, 这个string被两个地方引用到. 一个是当前的线程. 因为正在被当前线程使用到, 所以能够看到这个非常正常.

另外一个是root在一个System.Object[]数组上. 这个数组被PINNED在了App Domain 0000000000491880 上面. 这里显示出来, String其实是驻留在一个System.Object[]上面, 而不是很多人猜测的Hashtable. 不过料想CLR 应该有一套机制可以从这个数组中快速的获取正确的String. 不过这点不在本篇的讨论范围之内.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
0:000> !gcroot 0000000002a93f70

Note: Roots found on stacks may be false positives. Run “!help gcroot” for

more info.

Scan Thread 0 OSTHread 81a0

RSP:b9e9b8:Root:0000000002a93f70(System.String)

Scan Thread 2 OSTHread 7370

DOMAIN(0000000000C51880):HANDLE(Pinned):217e8:Root:0000000012a93030(System.Object[])->

0000000002a93f70(System.String)

我们可以检查一下这个System.Object[]里面都有什么.

从这个数组里面可以看到代码中显示声明的的字符串. 第一个元素是一个空值, 这个里面保留的是我们最常用的String.Empty的实例. 第二个元素是”Run GC.Collect()”. 这个在code的里面的main函数中. 当前还没有被执行到, 但是已经被JITed到了该数组中. 其他两个被显示定义的字符串也能够在这个数组中被找到. 另外可以确认的是, 拼接出来的字符串, 临时生成的字符串都没有在这里出现. 然而, 通过拼接出来的String并不在这个数组里面. 虽然拼接出来的String同样分配到了heap上面, 但是不会被收纳到数组中.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
0:000> !dumparray -details 0000000012a93030
Name: System.Object[]
MethodTable: 00007ffdb0805be0
EEClass: 00007ffdb041eb88

Size: 1056(0x420) bytes

Array: Rank 1, Number of elements 128, Type CLASS

Element Methodtable: 00007ffdb08176e0

[0] 0000000002a91308

Name: System.String

MethodTable: 00007ffdb0817df0

EEClass: 00007ffdb041e560

Size: 26(0x1a) bytes

(C:\windows\assembly\GAC_64\mscorlib\2.0.0.0__b77a5c561934e089\mscorlib.dll)

String:

Fields:

MT Field Offset Type VT Attr Value Name

00007ffdb081f060 4000096 8 System.Int32 1 instance 1 m_arrayLength

00007ffdb081f060 4000097 c System.Int32 1 instance 0 m_stringLength

00007ffdb0819838 4000098 10 System.Char 1 instance 0 m_firstChar

00007ffdb0817df0 4000099 20 System.String 0 shared static Empty

>> Domain:Value 0000000000c51880:0000000002a91308 <<

00007ffdb08196e8 400009a 28 System.Char[] 0 shared static WhitespaceChars

>> Domain:Value 0000000000c51880:0000000002a91be0 <<

[1] 0000000002a93f30

Name: System.String

MethodTable: 00007ffdb0817df0

EEClass: 00007ffdb041e560

Size: 64(0x40) bytes

(C:\windows\assembly\GAC_64\mscorlib\2.0.0.0__b77a5c561934e089\mscorlib.dll)

String: : Run GC.Collect()

Fields:

MT Field Offset Type VT Attr Value Name

00007ffdb081f060 4000096 8 System.Int32 1 instance 20 m_arrayLength

00007ffdb081f060 4000097 c System.Int32 1 instance 19 m_stringLength

00007ffdb0819838 4000098 10 System.Char 1 instance 20 m_firstChar

00007ffdb0817df0 4000099 20 System.String 0 shared static Empty

>> Domain:Value 0000000000c51880:0000000002a91308 <<

00007ffdb08196e8 400009a 28 System.Char[] 0 shared static WhitespaceChars

>> Domain:Value 0000000000c51880:0000000002a91be0 <<

[2] 0000000002a93f70

Name: System.String

MethodTable: 00007ffdb0817df0

EEClass: 00007ffdb041e560

Size: 66(0x42) bytes

(C:\windows\assembly\GAC_64\mscorlib\2.0.0.0__b77a5c561934e089\mscorlib.dll)

String: SimpleString method

Fields:

MT Field Offset Type VT Attr Value Name

00007ffdb081f060 4000096 8 System.Int32 1 instance 21 m_arrayLength

00007ffdb081f060 4000097 c System.Int32 1 instance 20 m_stringLength

00007ffdb0819838 4000098 10 System.Char 1 instance 53 m_firstChar

00007ffdb0817df0 4000099 20 System.String 0 shared static Empty

>> Domain:Value 0000000000c51880:0000000002a91308 <<

00007ffdb08196e8 400009a 28 System.Char[] 0 shared static WhitespaceChars

>> Domain:Value 0000000000c51880:0000000002a91be0 <<

[3] 0000000002a93fb8

Name: System.String

MethodTable: 00007ffdb0817df0

EEClass: 00007ffdb041e560

Size: 52(0x34) bytes

(C:\windows\assembly\GAC_64\mscorlib\2.0.0.0__b77a5c561934e089\mscorlib.dll)

String: Concat String

Fields:

MT Field Offset Type VT Attr Value Name

00007ffdb081f060 4000096 8 System.Int32 1 instance 14 m_arrayLength

00007ffdb081f060 4000097 c System.Int32 1 instance 13 m_stringLength

00007ffdb0819838 4000098 10 System.Char 1 instance 43 m_firstChar

00007ffdb0817df0 4000099 20 System.String 0 shared static Empty

>> Domain:Value 0000000000c51880:0000000002a91308 <<

00007ffdb08196e8 400009a 28 System.Char[] 0 shared static WhitespaceChars

>> Domain:Value 0000000000c51880:0000000002a91be0 <<

继续让代码执行下去, 我们需要来几次GC. 验证一下驻留的字符串是否会在不使用之后被GC掉.
logo

GC完成之后, 按照所设想的, CallStack上面的String都已经被清除掉了.同时因为已经做过了GC动作, GC heap进过了压缩, 没有被PINNED住的对象地址会发生改变. 所以要验证驻留的String是否会被回收, 可以从驻留数组下手. 由于该数组是被PINNED住, 所以即使发生了GC的动作, 它的地址也不会发生改变. 所以可以通过相同的命令把数组里面驻留的String都列出来.

结果是与我的预期是一致的. 只有被显示定义的String保留在该数组内, 而这些String不会被回收. 通过拼接零时生产的String, 则不会加入到这个数组内, 在GC发生后, 由于没有被引用而被回收掉.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
0:000> !dumparray -details 0000000012a93030
Name: System.Object[]
MethodTable: 00007ffdb0805be0
EEClass: 00007ffdb041eb88

Size: 1056(0x420) bytes

Array: Rank 1, Number of elements 128, Type CLASS

Element Methodtable: 00007ffdb08176e0

[0] 0000000002a91308

Name: System.String

MethodTable: 00007ffdb0817df0

EEClass: 00007ffdb041e560

Size: 26(0x1a) bytes

(C:\windows\assembly\GAC_64\mscorlib\2.0.0.0__b77a5c561934e089\mscorlib.dll)

String:



[1] 0000000002a93f30

Name: System.String

MethodTable: 00007ffdb0817df0

EEClass: 00007ffdb041e560

Size: 64(0x40) bytes

(C:\windows\assembly\GAC_64\mscorlib\2.0.0.0__b77a5c561934e089\mscorlib.dll)

String: : Run GC.Collect()



[2] 0000000002a93f70

Name: System.String

MethodTable: 00007ffdb0817df0

EEClass: 00007ffdb041e560

Size: 66(0x42) bytes

(C:\windows\assembly\GAC_64\mscorlib\2.0.0.0__b77a5c561934e089\mscorlib.dll)

String: SimpleString method



[3] 0000000002a93fb8

Name: System.String

MethodTable: 00007ffdb0817df0

EEClass: 00007ffdb041e560

Size: 52(0x34) bytes

(C:\windows\assembly\GAC_64\mscorlib\2.0.0.0__b77a5c561934e089\mscorlib.dll)

String: Concat String


所以经过上面的观察, 可以得出的结论是驻留的String生命周期非常长. 那么, 在什么时候他才会被回收?

从上面gcroot的结果, 可以看到主流数组是被PINNED住. 而引用这个数组的App Domain 0000000000C51880.

用!dumpdomain -stat的命令将所有的app domain信息打印出来. 可以看到这个App Domain是我们代码运行的Domain (ConsoleApplication3.exe). 这个驻留数组是由CLR 来维护, 并且与当前的App Domain联系到一起. 所以, 理论上这些驻留数组的生命周期跟这个App Domain是一致的.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
0:000> !dumpdomain -stat
————————————–
System Domain: 00007ffdb1f16f60
LowFrequencyHeap: 00007ffdb1f16fa8

HighFrequencyHeap: 00007ffdb1f17038

StubHeap: 00007ffdb1f170c8

Stage: OPEN

Name: None

————————————–

Shared Domain: 00007ffdb1f17860

LowFrequencyHeap: 00007ffdb1f178a8

HighFrequencyHeap: 00007ffdb1f17938

StubHeap: 00007ffdb1f179c8

Stage: OPEN

Name: None

Assembly: 000000000047fa60

————————————–

Domain 1: 0000000000491880

LowFrequencyHeap: 00000000004918c8

HighFrequencyHeap: 0000000000491958

StubHeap: 00000000004919e8

Stage: OPEN

SecurityDescriptor: 0000000000494140

Name: ConsoleApplication3.exe

Assembly: 000000000047fa60 [C:\windows\assembly\GAC_64\mscorlib\2.0.0.0__b77a5c561934e089\mscorlib.dll]

ClassLoader: 000000000047f820

SecurityDescriptor: 000000000047f9a0

Module Name

00007ffdb03e1000 C:\windows\assembly\GAC_64\mscorlib\2.0.0.0__b77a5c561934e089\mscorlib.dll

写在最后面

  1. String的恒定性. 字符串横定性是指一个字符串一经创建,就不可改变。那么也就是说当我们改变string值的时候,便会在托管堆上重新分配一块新的内存空间,而不会影响到原有的内存地址上所存储的值。
  2. String的驻留. CLR runtime通过维护一个表来存放字符串,该表称为拘留池,它包含程序中以编程方式声明或创建的每个唯一的字符串的一个引用。因此,具有特定值的字符串的实例在系统(App Domain)中只有一个。

直接在CODE里面声明的String会被CLR runtime维护在一个Object[]内.
临时生成的string或者拼接出来的String不会维护在这个驻留数组中.
驻留数组的生命周期跟它位于的App Domain一样长. 所以GC并不会影响驻留数组所引用的String, 它们不会被GC.

参考

Sonic Guo