全面剖析C#之String对象

  相信有很多开发人员都有这样的面试经历:面试官就某个问题对你追着问,不仅问你是什么,还要问你为什么以及它的内部机制,直至他认为你把问题阐述的非常透彻才肯罢手,这就要求我们的开发人员对这些问题要做到深刻的理解。正是基于此,才有了本篇随笔的产生,在这篇文章里我将着重阐述我对String对象的理解,例如String的类型,它的内存分配模型以及它适合在什么情况下使用等等。

String VS string

 其实二者的作用是一样的,之所以说它们是一样的,是因为在编译的时候,CLR在其内部使用了using string = System.String这样一个表达式,换句话说string就代表了String,或者说string是String的一个别名,只不过需要注意的是前者是C#的一个对象,而后者是C#的一个关键字,C#中类似的关键字还有例如int, bool, float等等。

String之类型

String是一个引用类型,虽然其行为看起来像是一个值类型,下面将通过一个Sample来说明,为此我们先建一个Console应用程序如下:

ConsoleApplication_C#
 1 using System;
 2 
 3 namespace ConsoleApplication_CSharp
 4 {
 5     class Program
 6     {
 7         static void Main(string[] args)
 8         {
 9             int i = 100;
10             Console.WriteLine(i);
11             string str1 = "This is a string";
12             Console.WriteLine(str1);
13             string str2 = "Hello," + str1;
14             Console.WriteLine(str2);
15             Console.ReadKey();
16         }
17     }
18 }

下面我们再来看一下生成的IL代码(使用MS自带的ILDASM.exe):

ConsoleApplication_IL
 1 .method private hidebysig static void  Main(string[] args) cil managed
 2 {
 3   .entrypoint
 4   // Code size       50 (0x32)
 5   .maxstack  2
 6   .locals init ([0int32 i,
 7            [1string str1,
 8            [2string str2)        --声明所有的变量
 9   IL_0000:  nop                    --如果修补操作码,则填充空间。尽管可能消耗处理周期,但未执行任何有意义的操作
10   IL_0001:  ldc.i4.s   100        --将提供的int8值即100作为int32推送到计算堆栈上(短格式)
11   IL_0003:  stloc.0                --从堆栈的顶部弹出值并将其付给内存中第一个变量i
12   IL_0004:  ldloc.0                --将内存变量i的值压入堆栈
13   IL_0005:  call       void [mscorlib]System.Console::WriteLine(int32)--调用WriteLine方法,参数为栈顶的值,即100
14   IL_000a:  nop
15   IL_000b:  ldstr      "This is a string"--推送对元数据中存储的字符串的新对象引用并压入堆栈中
16   IL_0010:  stloc.1                --从堆栈的顶部弹出值并将其付给内存中第二个变量str1
17   IL_0011:  ldloc.1                --将内存变量str1的值压入堆栈
18   IL_0012:  call       void [mscorlib]System.Console::WriteLine(string)--调用WriteLine方法,参数为栈顶的值,即This is a string
19   IL_0017:  nop
20   IL_0018:  ldstr      "Hello,"--推送对元数据中存储的字符串的新对象引用并压入堆栈中
21   IL_001d:  ldloc.1               --将内存变量str1的值压入堆栈
22   IL_001e:  call       string [mscorlib]System.String::Concat(string,
23                                                               string)--调用Concat方法,参数分别为Hello,和This is a string
24   IL_0023:  stloc.2               --从堆栈的顶部弹出值并将其付给内存中第三个变量str2,此时str2值为Hello,This is a string
25   IL_0024:  ldloc.2
26   IL_0025:  call       void [mscorlib]System.Console::WriteLine(string)
27   IL_002a:  nop
28   IL_002b:  call       valuetype [mscorlib]System.ConsoleKeyInfo [mscorlib]System.Console::ReadKey()
29   IL_0030:  pop
30   IL_0031:  ret
31 // end of method Program::Main
我已经对上面的代码做了详细的注释,读者应该非常容易理解,从中我们不难看出int和string的差别,对于int类型,IL是这样处理的:
IL_0001:  ldc.i4.s   100
只是简单地把值100推送到计算堆栈,而string的处理却大不一样:
IL_000b:  ldstr      "This is a string"
翻看IL的语法,我们知道ldstr的含义是Pushes a new object reference to a string literal stored in the metadata.用我们的大白话就是先在托管堆中创建了一个字符串对象,对象的值就是存储在元数据中的对应的字符串,然后把这个对象的引用压入计算堆栈中,至此,你应该知道string是引用类型了吧。
说到这,估计有人就会犯嘀咕了,既然string是引用类型,那么当改变一个字符串的值,为什么引用这个变量的其他字符串的值不会跟着改变,这就得从string的内存分配模型说起了。

 String内存分配模型

虽然String是引用类型,但是其行为和一般引用类型的行为根本不一样,相反倒是和值类型很相似,例如当我们试图改变某个字符串变量的值,可是引用这个变量的其他字符串的值根本不会改变,这到底是怎么回事呢?为了更好地说明问题的本质,下面我将再次通过一个例子来说明:

同样,首先我们建立一个用于测试的Console应用程序:

 1 static void Main(string[] args)
 2 {
 3     string str1 = "This is a string";
 4     string str2 = str1;
 5     Console.WriteLine(str1 == str2);
 6     str1 = "This is another string";
 7     Console.WriteLine(str1);
 8     Console.WriteLine(str2);
 9     Console.WriteLine(str1 == str2);
10     Console.ReadKey();
11 }

按道理说,我现在应该给出程序的运行结果,可是别急,还是让我先来分析一下编译之后生成的IL代码,我相信在看完IL代码之后,不用我说你肯定能知道其运行结果以及为什么是这样的结果:

代码
 1 .method private hidebysig static void  Main(string[] args) cil managed
 2 {
 3   .entrypoint
 4   // Code size       62 (0x3e)
 5   .maxstack  2
 6   .locals init ([0string str1,
 7            [1string str2)--初始化所有变量
 8   IL_0000:  nop
 9   IL_0001:  ldstr      "This is a string"--推送新对象引用至栈中
10   IL_0006:  stloc.0        --取栈顶值并赋予内存变量str1
11   IL_0007:  ldloc.0        --取内存变量str1值并入栈
12   IL_0008:  stloc.1        --取栈顶值并赋予内存变量str2
13   IL_0009:  ldloc.0        --取内存变量str1值并入栈
14   IL_000a:  ldloc.1        --取内存变量str2值并入栈
15   IL_000b:  call       bool [mscorlib]System.String::op_Equality(string,
16                                                                  string)--调用方法比较str1和str2
17   IL_0010:  call       void [mscorlib]System.Console::WriteLine(bool)--输出比较结果
18   IL_0015:  nop
19   IL_0016:  ldstr      "This is another string"--推送新对象引用至栈中
20   IL_001b:  stloc.0        --取栈顶值并赋予内存变量str1
21   IL_001c:  ldloc.0        --取内存变量str1值并入栈
22   IL_001d:  call       void [mscorlib]System.Console::WriteLine(string)--输出str1
23   IL_0022:  nop
24   IL_0023:  ldloc.1        --取内存变量str2值并入栈
25   IL_0024:  call       void [mscorlib]System.Console::WriteLine(string)--输出str2
26   IL_0029:  nop
27   IL_002a:  ldloc.0        --取内存变量str1值并入栈
28   IL_002b:  ldloc.1        --取内存变量str2值并入栈
29   IL_002c:  call       bool [mscorlib]System.String::op_Equality(string,
30                                                                  string)--调用方法比较str1和str2
31   IL_0031:  call       void [mscorlib]System.Console::WriteLine(bool)--输出比较结果
32   IL_0036:  nop
33   IL_0037:  call       valuetype [mscorlib]System.ConsoleKeyInfo [mscorlib]System.Console::ReadKey()
34   IL_003c:  pop
35   IL_003d:  ret
36 // end of method Program::Main

上面的IL代码说明几个问题,下面将一一加以解释。

第一,为什么改变str1的值不会影响str2的值:

给str1第一次初始化赋值(string str1 = "This is a string";)的IL代码是IL_0001:  ldstr      "This is a string"--推送新对象引用至栈中,

而后来改变str1值(str1 = "This is another string";)的IL代码是IL_0016:  ldstr      "This is another string"--推送新对象引用至栈中,

显然,原来每次赋值或者说改变str1的值,都会导致新对象(String)的创建,所以说无论怎么改变str1的值都不会影响str2,因为你改变str1相当于创建了一个全新的String对象,和str2一点关系都没有,另外,这也解释和说明了String是不可变的(所以说我们想‘改变’字符串值的美好愿望是徒劳的),进一步地,也告诉我们为什么不能频繁地改变String的值,因为这将导致String对象的频繁创建与与销毁(GC),这对性能是一个极大的损耗。

第二,为什么语句string str2 = str1;不会导致新对象的创建:

IL_0008:  stloc.1  --取栈顶值并赋予内存变量str2

仅仅是把str1取出来然后赋给str2,这又是为什么呢?原来CLR为了提高String的使用效率,对其使用了字符串驻留/拘留技术,即在程序编译的时候,CLR就会收集所有用到的字符串变量的值并把其放入元数据中,然后在内存中创建了一张用于维护这些字符串的散列表,键值分别为字符串的值和对象在托管堆中的引用,这样做有两个好处,1)下次如果需要创建新的字符串,CLR会先检查这个字符串的值在表中是否存在,如果存在,就不会创建新的字符串对像,而只是使字符串引用到对应的键值对;如果不存在才会创建,这样做极大地提高了字符串的使用效率;2)由于具有相同值的字符串在会在表中保存一次,这就保证了在使用时的一致性。

1 User Strings
2 -------------------------------------------------------
3 70000001 : (16) L"This is a string"
4 70000023 : (22) L"This is another string"
posted @ 2011-05-26 16:31  舍长  阅读(837)  评论(1编辑  收藏  举报