.NET,你忘记了么?(六)——再谈String

 一. 文章伊始

在文章之前,说下写出这篇文章的目的。在我昨天的一篇文章<<重温设计模式(一)——享元模式>>中,我在文中提到了关于String的字符串驻留机制。在文章的评论中,杨同学对我的字符串相关观点提出质疑,并且成文,不过我现在无法找到那个链接了。

于是,我想把这个老掉牙的话题在此重谈。

究竟我们对String这个常用的类型有多少理解。

二. 从C看起

C语言是我接触的第一个程序语言。还记得当时给我的C语言老师是一个专业做Java SOA的老师。

于是,她在讲授C的时候经常给我们时不时地与Java做着对比,尽管我们当时并不懂Java是个什么东东,只知道这个词经常出现于手机游戏上。

当时我还记得老师一句很经典的话:我们要记得,C中没有字符串这个概念(其实我们当时还不懂什么是字符串),所谓的字符串在C中表现为字符数组。

那就让我们来复习一下,在C中的“字符串”的表现形式:

char s[]=”abc”;

接下来,我们便可以使用s去调用各种“字符串”函数。

那么我们可以清楚地看到在C语言中,“字符串”其实存储的就是字符数组的首地址,那么在.NET中又是如何呢?

三. String vs string

在学校的时候,这个问题被同学无数次问过,尤其是很多学Java的朋友。

string其实就是String的别名,当二者编译为IL代码时,二者并无区别,正如int之于System.Int32。

二者的分别仅仅在于:

1. string是C#语言的基元类型,看起来更C#。

2.System.String是FCL的基元类型。

我常常是这样来使用:

1. 如果涉及到语言的互操作,那么毋容置疑,一定是System.String,不再赘述,如有问题,请参考<<.NET,你忘记了么?(一)——遵从CLS>>.

2. 如果只是声明一段字符串,我会使用string,看上去可读性更高,类似于你会使用int i=3;而很少见到System.Int32 i=3一样。

3. 如果是涉及到使用字符串的静态方法,那么我常常使用System.String,因为String看起来更像一个类。

四. 字符串的不变模式

我们在这里先看例子:

static void Main(string[] args)
{
    string s1 = "Hello";
    string s2 = "Hello";
    Console.WriteLine(Object.ReferenceEquals(s1, s2));
    s2 = "Hello world";
    Console.WriteLine(Object.ReferenceEquals(s1, s2));
}

结果呢?

image

那为什么s1和s2第一次的引用明明相等,可是经过一次改变却又不同了呢?

那就让我们来揭晓其中的秘密。

五. 深入字符串驻留

我们首先要先了解.NET的运行过程,如果还不太了解,请参考我的<<解析.NET运行全程>>

在CLR被加载之后,便SystemDomain所对应的托管堆中初始化了一个HashTable。这个HashTable的目的就是为了存储我们所创建过的字符串。

在Hashtable中,Key是string的内容,Value是这个字符串所对应的内存地址。

那我们来分析下上面的代码,其实过程如下:

string s1=”Hello”;

string s2=”Hello”;

无标题

现在的s1和s2是指向同一块地址,然后我们改变了s2的值,假设s2=”World”;那么这个时候:

无标题

当然,这个时候s1和s2就不指向同一块引用地址了。

好,现在让我们来系统的分析一下这个过程。

当初始化一个字符串时,会首先在这个系统初始化的Hashtable中查找每一个字符串常量在Hashtable中是否存在,

如果不存在,那么他便首先在托管堆中分配一块内存地址来存储这个字符串常量,然后在Hashtable中增加一个键值对,将字符串的内容存储为Key,而将分配的内存地址存储为Value。

如果存在,那么就在将该引用指向原有的地址。下面的代码进一步印证了这个观点:

static void Main(string[] args)
{
    string s1 = "Hello";
    string s2 = "Hello";
    string s3 = "Hello world";
    Console.WriteLine("Compare s1 and s2:"+Object.ReferenceEquals(s1, s2));
    s2 = "Hello world";
    Console.WriteLine("Compare s1 and s2:" + Object.ReferenceEquals(s1, s2));
    Console.WriteLine("Compare s2 and s3:" + Object.ReferenceEquals(s2, s3));
}

image

那么当改变该字符串值的时候又发生了什么?这就是不变模式的精髓,我们也称字符串横定性。

六. 深入字符串恒定性

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

其实这也就是为什么说字符串是特殊的引用类型的原因。他有着值类型的特点,也有着引用类型的特点。

微软之所以这样设计我认为是出于两点:

1. 保持字符串的恒定性意味着多用户共享同一块字符串地址时不会出现线程同步的问题。

2. 如果没有了字符串恒定,那么字符串的驻留基本也就无从实现了。

七. 追寻设计本源

微软为什么要这样去设计字符串的结构呢?

我想这也是杨同学对我的观点提出质疑的根本原因。

当然,本质当然是为了节省内存。至于究竟这样的内存节省会有多少。我从下面的例子来说明。

我们做一个Web程序,成千上万的用户去访问同一台服务器,String作为最常用的类型当然被频繁使用,这毋容置疑。那么在服务器中,同一个字符串就可能被初始化成千上万,我们再提高用户量,那么也许就是百万级的数量,那么这对服务器的内存是个多么大的占用。

那么,有了字符串驻留,如果两个字符串值相等的时候,就把两个字符串指向同一块对象,那么这样是不是节省了相当多的内存呢?

至于杨同学的另外一个观点,说难道在整个运行过程中这个Hashtable都存在,并且保存着所有的数据么?

我觉得这个是很容易进行反驳的。很明显,Hashtable在整个运行过程都存在的,而数据,我个人认为应该是有两种可能:

1. 定期去清理掉无用的键值对。

2. 像垃圾回收一样,只有当存储的字符串超过了Hashtable的大小,或者对定位Key的效率造成一定影响时便对其进行回收。

八. String在IL上的特立独行

我们都知道,在构造一个引用对象时,对应的IL代码是newobj。

但是.NET为string准备了特殊的声明方式:ldstr。该指令通过元数据中获得的文本常量来构造字符串对象。

补充一点,为什么说是通过元数据中获得的文本常量呢?因为C#将String作为了自己的基元类型,于是编译器会将这些文本常量存放在托管模块的元数据中。

无论如何,这说明了String是个特殊的类型,CLR为他准备了更高效,特立独行的方式。

九. String究竟是否享元?

String通过一个Hashtable来为自己保存一个缓存,然后每次初始化一个字符串时都先通过搜索缓存找到是否存在可以重用的对象,然后进行重用或者创建新的实例。

而享元模式是采用共享技术解决大量细粒度对象的爆炸问题。

是不是一样呢?

十. Intern方法

让我们首先看看MSDN对Intern的解释:

Intern:检索系统对指定String的引用。如果存在这样的字符串,那么便返回他的引用,否则便创建一个新的字符串,然后返回他的引用。

我为什么在String那么多的方法中单单钟情于这个方法呢?让我们先来看这段代码:

static void Main(string[] args)
{
    string s1 = "Hello";
    string s2 = "HelloWorld";
    string s3 = s1 + "World";
    Console.WriteLine(Object.ReferenceEquals(s2,s3));
}

image

这也是当时杨同学反驳我的一个理论依据。

原因就是在于s3是动态生成的字符串,这样的字符串是不会添加到缓存哈希表中进行维护到的。

那么这个时候Intern就派上用场了。

让我们来改写一下上面的代码:

static void Main(string[] args)
{
    string s1 = "Hello";
    string s2 = "HelloWorld";
    string s3 = s1 + "World";
    Console.WriteLine(Object.ReferenceEquals(s2,s3));
    s3 = String.Intern(s3);
    Console.WriteLine(Object.ReferenceEquals(s2, s3));
}

image

因为Intern的作用是搜索整个Hashtable,然后去找是否有这个字符串的相关引用。于是他找到了之后便会将这个引用返回给s3,那么这个时候,当然s2和s3的引用便相等了。

而这个方法的实现也让我们来看下:

public static string Intern(string str)
{
    if (str == null)
    {
        throw new ArgumentNullException("str");
    }
    return Thread.GetDomain().GetOrInternString(str);
}

这个方法究竟在实际中有什么用呢?让我们向下看。

十一. 学以致用,雕虫小技

我们在实际的应用中,字符串比较有着很大的应用,String.Compare()。

这个方法的本质是将整个string拆开,然后比较其中的每个字符。也就是在笔试题中常常遇到的,我最常说的一句话就是:把字符串当成字符数组玩!

但是,我们知道,这样是很损耗效率的。于是我们可以根据字符串的驻留特性去想想其他方法。

在上文中,我频繁的去使用Object.ReferenceEquals()。顾名思义,这个方法就是比较两个字符串的内存地址是否相等。由于字符串的驻留技术,因此两个相等的字符串所指向的地址是相等的。

因此我们可以使用Object.ReferenceEquals()来提高我们字符串比较的效率。

当然,很可能会出现某一字符串是动态生成的情况,那么这个时候Intern就派上用场了,当然,Intern同样是个耗费效率的方法,因此如果我们只需要进行少量的比较操作,就没有必要使用这个方法了。

另外,就是说由于字符串的恒定不变性,我们在字符串拼接时,实际上就产生了大量的内存垃圾。因此,如果要大量的字符串拼接,要使用StringBuilder类来完成,这个很多人都知道,我就不详细说这个了。

十二. 要点总结

String是个特殊的引用对象。

关键就在于他的字符串驻留技术和字符串的恒定不变性。

不早了,睡一觉还要爬起来上班。谢谢大家的关注。如果有什么不同的意见,欢迎大家留言讨论。我一直认为技术是讨论出来的,而不是自己闷在家里想出的!

 

 

参考文章:《你所必须知道的.NET》

                   《.NET框架程序设计》

                   《深入理解.NET》

.NET,你忘记了么?(六)——再谈String

全文结束