string作為我們在編程當中用的最多的數據類型,同時又由於它的特殊性,怎麼強調它的重要性都不為過,理解string的一些類型和存儲機制,有助於我們寫出正確且高效的代碼.
一.string類型
1.string的類型
string類型直接繼承Object類型,Object類型是引用類型,因而string類型是引用類型無疑.
我們借助VS的類視圖可以看到這一點:
這意味著:
(a).string類型不會在線程的堆棧中存儲任何字符串,而是存儲在堆上
(b).未初始時,它被設置為null
PS:在內部,string是用字符串char的集合來維護的
2.string聲明的IL描述
在IL中,構造新實例的IL指令是newobj,是不是string也是這樣?
我們使用如下代碼:
1 class Program 2 { 3 static void Main(string[] args) 4 { 5 string str = "Hello World!"; 6 string str2 = "Hello" + " My" + " World!"; 7 Person person = new Person(); 8 } 9 } 10 11 class Person 12 { 13 string Name; 14 }
我們查看IL代碼如下:
可以看出
(a).對比1和3,構造Person對象使用了newobj指令,但是在構造字符串的時候,使用了專門的ldstr(load string)指令
(b).更進一步,編譯器將這些字面值字符串放到模塊的元數據中,在運行時加載和引用它們
(c).看2,對於使用+符合將各literal連接起來的寫法,編譯器在編譯的過程中會直接連接他們.
二.string的操作帶來的疑問
OK,通過第1部分,我們知道了,string是引用類型,它存儲在堆中.
我們知道對於引用類型,賦值操作=會傳遞的是引用,不是值,但構造不同的引用類型時通常它們的引用也不同.如下面這種:
1 class Program 2 { 3 static void Main(string[] args) 4 { 5 //Person實害?例 6 Person person1 = new Person("A"); 7 Person person2 = new Person("A"); 8 Console.WriteLine(object.ReferenceEquals(person1, person2)); 9 10 //string 11 string str1 = "Hello World!"; 12 string str2 = "Hello World!"; 13 string str3 = "Hello " + "World!"; 14 Console.WriteLine(object.ReferenceEquals(str1, str2)); 15 Console.WriteLine(object.ReferenceEquals(str1, str3)); 16 17 Console.Read(); 18 } 19 } 20 21class Person 22 { 23 public Person(string strName) 24 { 25 26 } 27 }
我們先給出運行結果:
我們知道object.ReferenceEquals是比較兩個對象的引用是否一樣,對於第1種Person的情況,我們可以理解,因為他們都是構造了不同的對象,引用的存儲地址也是不同的.但對於第2種,第3種,string就像成為了值類型一樣,返回了True,那麼問題來了:
A.在聲明的時候,string存儲的是什麼?
B.什麼原因使得兩個string的引用地址是一樣的?
這就引出了我們要討論的核心問題:字符串駐留.
三.字符串駐留
1.string存儲的是引用
string對象存儲的是引用,引用對象存儲在堆中,會生成一個對象,同時將這個對象的地址(引用)給堆棧去使用.也就是說兩個string引用了堆中同一塊對象.
2.字符串駐留讓兩個string的引用地址是一樣
在CLR初始化時,會創建一個Hash表,在這個表中,Key是字符串,值是字符串在堆中的地址.當聲明一個字符串的時候,會先去這個HashTable中去找是否存在這個Key,如果存在則返回對應的引用,如果不存在則納入HashTable.如下圖所示:
Step1:當執行語句string str1 = "Hello World!";時,str1拿到了Add1;
Step2:當執行語句string str2= "Hello World!";時,CLR會去HashTable中去找,找到,返回Add1給str2;
Step3:現在用object.ReferenceEquals比較str1和str2的引用,因為都是Add1,因而返回True.
我們現在通過內存分析工具ANTS Memory Profile來證明,字符串駐留機制是確實存在的.
代碼如下:
1 static void Main(string[] args) 2 { 3 Console.ReadLine();//第台?一?次?快ì照?位?置? 4 string str1 = "Hello World!"; 5 string str2 = "Hello World!"; 6 Console.ReadLine();//第台?二t次?快ì照?位?置? 7 }
加載兩次快照,對比差異:
我們可以看到,在這裡有一個string的實例進去了,而且整個過程當中,也只有這一個string實例進去了,我們可以進一步看下進去的內容是什麼.
我們在這裡發現了”Hello World!”字符串,並且只有一個.這也就從內存分析的角度證明了字符串駐留的存在.
3.駐留字符串的HashTable是不受GC管理,但表達式中存在variable時,則不駐留在HashTable
我們實驗如下:
1 static void Main(string[] args) 2 { 3 Console.ReadLine();//第1次快照位置 4 Test(); 5 GC.Collect(); 6 Console.ReadLine();//第3次快照位置 7 } 8 9 static void Test() 10 { 11 string str1 = "Hello World!"; 12 string str2 = "Hello World!" + str1; 13 Console.ReadLine();//第2次快照位置 14 }
第2次快照,我們可以看到:
進去了3個對象,分別是:byteIndex,”Hello World!”,”Hello World!Hello World!”
第3次快照是在調用了GC.Collect()後再進行的快照,以快照2為對比線,我們查看第3次快照.
我們看到,有一個對象被GC回收掉了,具體是什麼被回收了?我們再看:
現在只剩下byteIndex,”Hello World!”兩個對象,什麼被回收了呢?顯然是:”Hello World!Hello World!”
這也就證明了我們所說的:駐留字符串的HashTable是不受GC管理,但表達式中存在variable時,則不駐留在HashTable.
進一步:除非卸載AppDomain或進程終止,否則HashTable引用的string對象不能被釋放.
4.字符串的駐留是基於整個進程的
我們添加兩個不同的AppDomain,在各自的應用程���域中執行BuildString()方法,同時由於應用程序域之間本是不能訪問彼此對象的,我們使用"封送(Marshaling)"機制,封送又分為按值分送(主要采用序列化的方式)和按引用封送(如采用.Net Remoting).這裡,要實現按引用封送,Test類繼承MarshalByRefObject類.
測試代碼
class Program { static void Main(string[] args) { Console.ReadLine(); AppDomain domina1 = AppDomain.CreateDomain("First"); Test t1 = (Test)domina1.CreateInstanceAndUnwrap(typeof(Test).Assembly.FullName, typeof(Test).FullName); t1.BuildString(); AppDomain domina2 = AppDomain.CreateDomain("Second"); Test t2 = (Test)domina1.CreateInstanceAndUnwrap(typeof(Test).Assembly.FullName, typeof(Test).FullName); t2.BuildString(); Console.ReadLine(); } } public class Test : MarshalByRefObject { public void BuildString() { var str1 = "Hello"; var str2 = "Hello"; var str3 = "World"; var str4 = "World"; } }
我們拿到兩張快照,在第1張跟第2張快照對比後我們發現:
我們再具體查看內容(“World”字符串就不截圖了):
通過以上的分析,我們確信,字符串的駐留是基於整個進程的.
5.我們可以通過string.Intern方法來將字符串強制加入HashTable,也可以通過string.IsInterned來判斷字符串是否在HashTable中存在。
四.字符串池
在編譯時,編譯器會處理所有的literal字符串,並嵌入托管模塊的元數據中,但如果每次都寫入元數據,假設這個字符串在程序中多次出現,那就需要多次寫入元數據,這會使生成的文件無限地增大.
C#編譯器,只在元數據中將literal字符串寫入一次,將多個實例合並成一個實例,所有引用該字符串的代碼都被修改成引用元數據中的同一個字符串,這能顯著地減少生成文件的大小.這種特性,我們稱之為字符串池.
五.string的不可變性
string是不可變的,這意味著:
a.字符串一經創建便不能更改,不能變長、變短或修改其中的任何字符;
b.每次對於字符串的變更操作,如果是帶變量操作,都會在堆上生成新的字符串,並返回新的引用,會造成頻繁的GC回收,從而造成性能問題,如果不帶變量操作則會采用字符串駐留;
c.操作和訪問字符串不會發生線程同步問題,線程安全;
d.String類是sealed(密封)的,這是為了保護string的不可變性。
問題來了,如何實現string的不可變性呢?
string在內部是用char數組實現的,在char數據中,我們不可以改變數組的引用,但是我們可以直接修改char數組的值,為了實現string的不可變性,string在實現各種方法時,不會觸動char數組中的元素。
參見7.
六.StringBuilder:為解決string的性能而生
通過前面的內容我們可以知道,string容易產生性能問題,StringBuilder可以解決這個問題。
它的內部使用char[]來進行操作,默認為16,如果超過容量,則在堆中產生一個倍增容易的新char[]數組,復制字符,並開始使用新數組,前一個數組則被GC回收。如果不超過當前容量,是不是會產生一個新的char[]數組的。
使用ToString()方法也會在堆中產生一個新的對象。
七.總結
1.string是引用類型
2.string使用了字符串池來減少元數據文件的大小
3.string使用了字符串駐留來提升效率,駐留的字符串采用HashTable來存儲,它不受GC管轄,HashTable是基於進程共享的.
4.string是不可變的,由此帶來的性能問題,可以通過StringBuilder來解決.