字符串是保存文本的System.String類型對象。它跟值類型(如:Int32、Int64等)有着相似的使用方法及表達目的,但它並不是值類型。由於在編程中會大量使用字符串,所以CLR為了提高性能及開發方便,對它進行了特殊處理。這一章,我們來介紹一下字符串的駐留機制、字符串池及字符串的比較等特性。
注意,本系列所有測試代碼均運行於.NET 4.0。
字符串被定義為System.String類型的對象,既然它是引用類型,那么一個未初始化的對象聲明將保留為null,並且它的內存只能在堆上分配。它在內部維護的是字符Char的集合,所以它有一個屬性Length來表示Char集合中元數的個數。來看一下String類型的定義:

String實際上是繼承了System.Object類型,同時還實現了一系列接口,如Ienumberable、ICompareable等,所以字符串提供了對字符集合、比較等相關的操作。
盡管它是引用類型,但是編譯器不允許使用new根據一個文本常量來創建一個字符串對象,而是必須使用簡明的聲明語法來聲明及初始化,對字符串的初始化值是直接被編譯進元數據的。比如如下定義一個字符串變量:
string name1 = "Jack";
IL:
.method public hidebysig specialname rtspecialname instance void .ctor() cil managed { // 代碼大小 19 (0x13) .maxstack 8 IL_0000: ldarg.0 IL_0001: ldstr "Jack" IL_0006: stfld string ConsoleApp.Example07.Code_07::name1 IL_000b: ldarg.0 IL_000c: call instance void [mscorlib]System.Object::.ctor() IL_0011: nop IL_0012: ret } // end of method Code_07::.ctor
我們知道,通常對於引用類型創建對象是使用newobj指令,但上面的並沒有使用該指令,只是使用ldstr指令加載了字符串“Jack”,從IL_0000 - IL_0006可以看到是直接加載“Jack”串賦給變量,這是CLR的一種特殊的構造方式。
字符串對象一旦創建,在整個進程的生命周期中是不可變的,無法對其進行加長、縮短、改變等操作,既然它不會變,所以也就不存在線程同步的問題,哪怕是皇天老兒創建的線程都無法對其進行改變。如下代碼:
string str1 = "Jim"; string str2 = str1; Console.WriteLine(object.ReferenceEquals(str1, str2)); //True str1 += " China"; Console.WriteLine(object.ReferenceEquals(str1, str2)); //False
在第一次調用object.ReferenceEquals方法比較的str1和str2,它們指向的是同一個字符串對象引用,所以結果為true,而str1 += " China";的過程是重新創建了一個對象,且把新的對象引用賦給str1,此時str1與str2指向的不是同一個對象引用,所以在第二次調用object.ReferenceEquals方法時返回的是false。無論是使用+=操作符還是其他的對字符串修改的方法,都會引起重新創建字符串對象,並且復制舊的字符串到新的內存區,而不是我們常說的“對XX字符串進行修改”,如果非要說“改變”,那就是對對象引用的改變。對於str1 += " China";操作,CLR會執行以下操作:
1) 開辟新的足夠大的臨時存儲區內存來容納str1和” China”;
2) 復制str1串到臨時區的開始處;
3) 復制” China”到臨時存儲區的結尾處;
4) str1丟棄對舊對象的引用;
5) 為str1再一次分配內存區;
6) 將臨時存儲區內的字符值復制到4)新開辟的內存區,將str1指向這個內存區的引用。
所以對字符串的連接操作會大大損傷性能。我們在下面會講到.NET 提供的一個專門對付字符串連接的類StringBuilder。
通過前面的描述,我們已經知道字符串的內存是分配在托管堆上,且它是不可改變的,而在編程中,我們會大量使用字符串,這就會導致不停地創建字符串對象,不停地分配內存,並且很有可能不停地執行垃圾回收,如此以來會大大損傷性能,所以CLR對字符串進行了特殊的優化機制,下面我們來對這些機制及特性進行描述。
字符串駐留是CLR提供的一種提高性能的對待字符串的機制,它保證在一個進程內的某個字符串在內存中只分配一次。看以下代碼:
string str1 = "abc"; string str2 = "abc"; Console.WriteLine(object.ReferenceEquals(str1, str2)); //True
明明聲明了兩個對象str1和str2,調用object.ReferenceEquals方法返回的是True,為什么它們指向的是同一個引用呢?這就說明了CLR的字符串駐留,相同的字符串在托管內存中只分配一次,再次聲明相同的字符串對象時,會將后來一次的聲明指向第一次聲明所引用的對象。那么CLR 如何保證做到的呢?原來,在CLR初始化時創建一個內部的哈希表,我們知道哈希表在處理表內數據時是非常快的,這個表相當於一個字典表(Dictionary<TKey,TValue>),鍵就是字符串,而值是指向托管堆中該字符串對象的引用,當在聲明一個字符串時,會調用對象的Intern方法,該方法接收一個string對象,它會先在哈希表中檢查該字符串是否存在?如果存在,則返回這個字符串對應的對象引用;否則,將創建該字符串的副本,並將副本添加到哈希表中,最后返回對該副本對象的引用。String類還提供了一個IsInterned方法,該方法會根據字符串在哈希表中檢查是否已經存在相同的串,如果存在,則返回該字符串對象的引用,否則,返回null,但它是它不會向哈希表中添加字符串。我們對上面的代碼進行改造:
void TestIntern() { string str1 = "abc"; string str2 = "abc"; Console.WriteLine(object.ReferenceEquals(str1, str2)); //True str1 += str2; Console.WriteLine(str1); }
接下來通過內存分析器來看一下字符串駐留:

可以看到字符串”abc”是有駐留的。從前面的講解中我們知道,+=操作是要重新創建對象的,但CLR對臨時計算的新對象“abcabc”沒有進行駐留呢。繼續改造上面的代碼:
string str1 = "abc"; string str2 = "abc"; Console.WriteLine(object.ReferenceEquals(str1, str2)); //True str1 += str2; str1 = string.Intern(str1); Console.WriteLine(str1);
這次我們使用了string.Intern方法,再用內存分析器看一下:

這次我們看到字符串”abcabc”是進行了駐留,這里就驗證了剛才上面對駐留機制的討論。
有一點要注意,盡管String.Intern(string)方法的字符串參數(上面代碼中的str1)被垃圾回收器回收,但是CLR已將這個str1的副本添加到哈希表中,垃圾回收器是無法對哈希表引用的字符串進行回收。
字符串駐留也有發“懵”的時候,看以下代碼:
void Test() { string str1 = "abc"; string str2 = new string(new char[] { 'a', 'b', 'c' }); Console.WriteLine(object.ReferenceEquals(str1, str2)); //False }
對於變量str2最終的字符串也是”abc”,可是為什么這次object.ReferenceEquals方法返回了False呢?原因是CLR會為new string(char[])創建的字符串對象會重新分配內存,不再使用字符串駐留機制對它進行處理,於是str1和str2就指向了不同的對象引用。
字符串池與字符串駐留機制是分不開的,最能體現字符串池的是在在C#編譯器編譯的過程中,在編譯過程中,同樣的字符串(比如上面代碼中的”abc”)會被程序中的很多地方使用,通過第一節我們已經知道字符串的聲明是直接將字符中寫入元數據中,如果編譯器在每個使用的地方都將該相同的字符串寫入元數據中,則會大大增加元數據的體積,且也沒有必要。所以編譯器會只在模塊的元數據中寫入該字符串一次,並將引用該相同字符串的代碼都修改為指向這同一個字符串對象,如此以來,就會大大減小元數據的體積。
對字符串的比較,通常有兩層意義,一個是判斷兩個字符串對象是否具有相同的引用,另一個是判斷兩個字符串對象是不具有相同的“值”,我們一般的編程中經常使用后者。
(1) Unicode編碼與字符
為了解決使用相同的字符集表示不同的語言,於是一群人就搗鼓出了Unicode,Unicode對每一個字符都提供了唯一的值,它不依賴於任何平台和任何區域語言。Unicode采用2個字節的編碼方式,可以表示65536個字符,就是我們常說的16位Unicode編碼,然而,僅中文就有85000多個字符,所以16位的Unicode不能滿足語言文化的需要。當然如果采用32位的Unicode編碼一定能滿足,但32位編碼的每個字符占4個字節,所以Unicode定制了另一個使用代理對的機制來滿足各種語言文化的需要。
.NET Framework使用16位的Unicode編碼,每個字符對應一個確定的Unicode碼值。在第一節我們已經知道,字符串是由字符組成,所以一個字符串是由一系列的Unicode碼組成。我們通常用Int32值來表示一個字符的編碼值,如:’a’:97、’A’:65。如果你感興趣,可以使用如下代碼來測試看看每個數值所代表的字符是什么,當然,無對應數值,可能無法轉換:
for (int i = 1; i < 10000; i++) { char temp = (char)i; Debug.WriteLine(temp + " " + i); }
計算機只能識別0/1,任何一個數字都可以用0/1對其進行編碼,每個字符都是由Unicode碼值來表示,所以計算機就能“識別”出所有字符,而字符是組成字符串的元素,所以計算機進而能“識別”出字符串。
字符文本與區域語言文化有很大的依賴關系。
(2) 區域語言文化
由於全世界各地的人類文化不同,也就導致的語言文化的不同,這就是區域語言文化的不同。計算機系統使用國際化標准來處理各種語言文化的差異。.NET Framework為了方便處理各種語言文化,提供了一個System.Globalization.CultureInfo類,它提供對特殊文化信息的支持,如文化名稱、相關語言、國家/區域等。如en代表英文、en-CA代表加拿大、zh-CHS代表中文簡體。
一個應用程序一般即要處理國際化數據,也要處理本地化數據,CultureInfo為處理這兩類數據扮演了重要角色,為了完美地支持這兩類數據處理,CultureInfo類提供了兩個重要的屬性CurrentUICulture和CurrentCulture。CurrentUICulture的值決定了如何加載窗體資源及窗體元素以什么語言來顯示。CurrentCulture決定了除CurrentUICulture外的其他方面,如日期格式、數字格式、貨幣符號、字符串大小寫及比較等。CurrentUICulture和CurrentCulture在應用的線程級設定,如果未設定,那么系統將從Windows中獲取一個值來進行實初始化,這個值通常在控制面板的語言和區域中設置。
(3) 字符串的比較
我們知道,字符串是由字符組成的,而每個字符可以由一個Int32值來表示,我們當然可以比較兩個Int32的值,同樣也可以比較兩個字符的“大小” ,進而可以比較兩個字符串的“大小”,事實上字符串都是表示一系列文本內容的,它們不可能用大小來衡量,只能長度對其本身的特性進行一方面的描述,我們通常所說的比較,是對其內包含的字符對應的Int32值進行比較。在對字符或串排序的時候,這個比較很有用,另外一個,我們通常是比較兩個字符串是否相等。
注意:字符串的比較,是按順序逐個比較每個字符的Int32值。
字符串的比較通常有以下幾種方式:
比較符號 ==、實例級和靜態的Equals方法、CompareTo方法、String.Compare方法、String.CompareOrdinal方法
字符串與語言文化有很大的依賴關系,所以任何一個的字符串,都會直接或間接的使用某種語言文化信息。下面我們分別介紹一下每個比較方法。
a) 等於號==
如下代碼:
void TestEqualto() { string str1 = "abc"; string str2 = "def"; bool chk = str1 == str2; }
我們來看一下IL:
.method private hidebysig instance void TestEqualto() cil managed { // 代碼大小 22 (0x16) .maxstack 2 .locals init ([0] string str1, [1] string str2, [2] bool chk) IL_0000: nop IL_0001: ldstr "abc" IL_0006: stloc.0 IL_0007: ldstr "def" IL_000c: stloc.1 IL_000d: ldloc.0 IL_000e: ldloc.1 IL_000f: call bool [mscorlib]System.String::op_Equality(string, string) IL_0014: stloc.2 IL_0015: ret } // end of method Code_07::TestEqualto
它是調用了op_Equality方法,再來看看op_Equality方法的IL:
.method public hidebysig specialname static bool op_Equality(string a, string b) cil managed { .custom instance void System.Runtime.TargetedPatchingOptOutAttribute::.ctor(string) = ( 01 00 3B 50 65 72 66 6F 72 6D 61 6E 63 65 20 63 // ..;Performance c 72 69 74 69 63 61 6C 20 74 6F 20 69 6E 6C 69 6E // ritical to inlin 65 20 61 63 72 6F 73 73 20 4E 47 65 6E 20 69 6D // e across NGen im 61 67 65 20 62 6F 75 6E 64 61 72 69 65 73 00 00 ) // age boundaries.. // 代碼大小 8 (0x8) .maxstack 8 IL_0000: ldarg.0 IL_0001: ldarg.1 IL_0002: call bool System.String::Equals(string, string) IL_0007: ret } // end of method String::op_Equality
我們可以看到op_Equality方法內部調用的是Equals方法,也就是說,只要使用==來比較兩個字符串,最終調用的還是Equals方法,二者是等價的。
b) Equals
Equals有兩種:實例方法和類方法,它們是普通的序號比較。來看一下兩者的實現。
public bool Equals(string value) { if (this == null) { throw new NullReferenceException(); } if (value == null) { return false; } return (object.ReferenceEquals(this, value) || EqualsHelper(this, value)); } public static bool Equals(string a, string b) { return ((a == b) || (((a != null) && (b != null)) && EqualsHelper(a, b))); }
可以看到,實例方法進行了一次引用的比較,判斷兩個對象是否指向了同一個對象地址;靜態方法是判斷兩個對象是否相等。對於兩個方法,如果前面部分不成立,則它們會繼續調用EqualsHelper方法進行逐字符比較。逐字符比較是先比較第一個字符的碼值,再比較第二個字符的碼值,依次類推,比較過程中以第一個字符串的長度為基准。比如:
char a = 'a'; //97 char b = 'b'; //98 char c = 'c'; //99 "ab"<"ac" "abc">"ab" "ab"<"abc" "ab"="ab" "ac">"ab"
但是,如果要執行區域敏感規則的比較,可能就不同了,比較中可能會為非字母數字的 Unicode 字符分配特殊權重,使用字詞排序規則和特定區域的約定
c) CompareTo
CompareTo(string)方法是拿一個字符串對象與另一個串對象進行比較,返回一個Int32值,如果前者的碼值小於后者碼值,則返回-1,如果相等,則返回0,如果前者碼值大於后者碼值,則返回1。來看一下它的定義:
public int CompareTo(string strB) { if (strB == null) { return 1; } return CultureInfo.CurrentCulture.CompareInfo.Compare(this, strB, CompareOptions.None); }
它在內部使用了包含區域語言特性信息的Compare比較。CompareInfo.Compare方法接收一個CompareOptions枚舉,各個枚舉的用意可參考MSDN文檔。
d) String.Compare
Compare是CompareTo的靜態化版本,在內部都是對CultureInfo.CurrentCulture.CompareInfo.Compare方法進行調用。它同樣返回Int32值:-1、0、1。Compare的重載版本會另外的參數,如:
public static int Compare(string strA, string strB, bool ignoreCase)
接收一個bool值ignoreCase表示是否忽略大小寫。
public static int Compare(string strA, string strB, StringComparison comparisonType)
接收一個StringComparison枚舉,表示要使用的區域文化信息、排序規則等。該枚舉的詳細可參考MSDN文檔。
e) String.CompareOrdinal
CompareOrdial方法執行的是忽略區域文化信息的序號比較,不執行轉換和提供國際化支持所需的系統開銷,該方法可用於比較文件路徑、IP、URL等字符串。很顯然它的性能會比提供區域語言文化信息的Compare方法快很多。
最后,如果進行不區分大小寫的比較,或是想對字符串中的大小寫進行更改,建議使用 ToUpperInvariant()和ToLowerInvariant()方法,這兩個方法使用了固定區域性的大小寫規則,而ToUpper()方法和ToLower()方法是依賴區域語言文化信息的,所以性能會差一些。
在第2節,我們知道字符串的不可變性,如果要對字符串進行更改,則會重新創建字符串對象,這個過程會導致性能問題,CLR提供了駐留和池來提高性能,同時.NET Framework還提供了一個StringBuilder類可以高效地對字符串進行動態管理,大提高了性能,它不像我們像常說的在內部維護一個Char[]數組那么簡單。在后面,我們准備專用一個章節來討論StringBuilder。
