一. 文章伊始
在文章之前,說下寫出這篇文章的目的。在我昨天的一篇文章<<重溫設計模式(一 )——享元模式(Flyweight) >>中,我在文中提到了關於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));
}
結果呢?
那為什麼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));
}
那麼當改 變該字符串值的時候又發生了什麼?這就是不變模式的精髓,我們也稱字符串橫定性。
六. 深入字符串恆定性
字符串橫定性是指一個字符串一經創建,就不可改變 。那麼也就是說當我們改變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));
}
這也是當 時楊同學反駁我的一個理論依據。
原因就是在於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));
}
因為 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是個特殊的引用對象。
關鍵就在於他的字符串駐留技術和字符串的恆定不變性。