string 是極特殊的資料類型:它既是基元類型,又是引用類型。.NET 在編譯與運行期對其進行多重優化,這些優化有時會誤導開發者,使字串顯得難以捉摸。本系列分上下兩章、共四節,深入揭秘字串的陌生一面。
C# 值類型:bool、byte、char、enum、sbyte、數值類型(含可空型別)
C# 引用類型:class、interface、delegate、object、string
string 赫然在列,聲明為 string 的變量存放於堆中,是徹頭徹尾的引用類型。
string a = "str_1";
string b = a;
a = "str_2";
第 3 行的 = 隱藏祕密:它的作用是「新建」,而非修改變量 a。IL 代碼證實:
IL_0001: ldstr "str_1" // 建立 "str_1" 關聯 a
IL_0007: ldloc.0 // 將 a 壓棧
IL_0008: stloc.1 // 彈出關聯 b
IL_0009: ldstr "str_2" // 建立 "str_2" 關聯 a(並未修改舊值)
IL_000e: stloc.0
ldstr 指令表示 IL 希望建立新字串(最終是否建立由運行期駐留機制決定)。第 3 行看似修改 a,實則建立新字串 "str_2" 並讓 a 指向新位址,"str_1" 依然存在於記憶體中,b 值不變——這就是字串的恆定性:.NET 中 string 物件一旦建立即不可修改,含 ToUpper、Substring、Trim 等操作皆在記憶體產生新字串。
許多開發者誤以為 string 展現「值類型特性」,實則是不了解恆定性。例如:
string a = "str_1";
a = "str_2";
記憶體中同時存在 "str_1" 和 "str_2",但只有後者被使用,前者不會被修改或消失,造成記憶體浪費——這也是大量字串操作推薦使用 StringBuilder 的原因。
字串恆定性造就了駐留機制,也為線程同步打開方便之門:同一字串物件可跨應用程式域訪問,駐留字串為進程級,GC 無法釋放,僅進程結束時釋放。
string a = "str_1";
string b = "str_1";
Response.Write(ReferenceEquals(a,b)); // True
雖聲明兩個變量,但引用同一記憶體位址!第 2 行 未產生新字串。
CLR 內部維護一個 字串池(可視為 HashTable),Key 為字串值,Value 為記憶體位址。建立 string 變量時,CLR 先在池中查找相同 Hash Code 字串,找到則返回位址,否則在記憶體新建並加入池中。
string a = "str_1"; // 新建 "str_1",a 指向它
a = "str_2"; // 池中無 "str_2",新建並讓 a 指向它,"str_1" 不變(恆定性)
string c = "str_2"; // 池中有 "str_2",c 直接指向它(駐留機制)
string a = "str_1" + "str_2";
string b = "str_1str_2";
ReferenceEquals(a,b); // True
IL 顯示編譯期已計算結果,ldstr 直接處理 "str_1str_2",駐留生效。
string a = "str_1";
string b = a + "str_2";
string c = "str_1str_2";
ReferenceEquals(b,c); // False
IL 使用 Concat 方法在運行期拼接,駐留失效。
string a = "a";
string b = new string('a',1);
ReferenceEquals(a,b); // False
IL 使用 newobj 實例化,駐留失效。揭示 string 實際由 char[] 實現,建立過程需棧堆配合。
string a = "a";
string b = new string('a',1);
ReferenceEquals(a, string.Intern(b)); // True
Intern 將字串手動加入池中,後續相同值共享記憶體。
下兩節將透過實例展示字串內部秘密,敬請期待!