深入理解string和如何高效地使用string
一個月以前我寫了一篇討論字符串的駐留(string interning)的文章,我今天將會以字符串的駐留為基礎,進一步來討論.NET中的string。string interning的基本前提是string的恒定性(immutability),即string一旦被創建將不會改變。我們就先來談談string的恒定性。
一、string是恒定的(immutable)
和其他類型比較,string最為顯著的一個特點就是它具有恒定不變性:我們一旦創建了一個string,在managed heap 上為他分配了一塊連續的內存空間,我們將不能以任何方式對這個string進行修改使之變長、變短、改變格式。所有對這個string進行各項操作(比如調用ToUpper獲得大寫格式的string)而返回的string,實際上另一個重新創建的string,其本身并不會產生任何變化。
String的恒定性具有很多的好處,它首先保證了對于一個既定string的任意操作不會造成對其的改變,同時還意味著我們不用考慮操作string時候出現的線程同步的問題。在string恒定的這些好處之中,我覺得最大的好處是:它成就了字符串的駐留。
CLR通過一個內部的interning table保證了CLR只維護具有不同字符序列的string,任何具有相同字符序列的string所引用的均為同一個string對象,同一段為該string配分的內存快。字符串的駐留極大地較低了程序執行對內存的占用。
對于string的恒定性和字符串的駐留,還有一點需要特別指出的是:string的恒定性不單單是針對某一個單獨的AppDomain,而是針對一個進程的。
二、String可以跨AppDomain共享的(cross-appDomain)
我們知道,在一個托管的環境下,Appdomain是托管程序運行的一個基本單元。AppDomain為托管程序提供了良好的隔離機制,保證在同一個進程中的不同的Appdomain不可以共享相同的內存空間。在一個Appdomain創建的對象不能被另一個Appdomain直接使用,對象在AppDomain之間傳遞需要有一個Marshaling的過程:對象需要通過by reference或者by value的方式從一個Appdomain傳遞到另一個Appdomain。具體內容可以參照我的另一篇文章:用Coding證明Appdomain的隔離性。
但是這里有一個特例,那就是string。Appdomain的隔離機制是為了防止一個Application的對內存空間的操作對另一個Application 內存空間的破壞。通過前面的介紹,我們已經知道了string是恒定不變的、是只讀的。所以它根本不需要Appdomain的隔離機制。所以讓一個恒定的、只讀的string被同處于一個進程的各個Application共享是沒有任何問題的。
String的這種跨AppDomain的恒定性成就了基于進程的字符串駐留:一個進程中各個Application使用的具有相同字符序列的string都是對同一段內存的引用。我們將在下面通過一個Sample來證明這一點。
三、證明string垮AppDomain的恒定性
在寫這篇文章的時候,我對如何證明string跨AppDomain的interning,想了好幾天,直到我偶然地想到了為實現線程同步的lock機制。
我們知道在一個多線程的環境下,為了避免并發操作導致的數據的不一致性,我們需要對一個對象加鎖來阻止該對象被另一個線程 操作。相反地,為了證明兩個對象是否引用的同一個對象,我們只需要在兩個線程中分別對他們加鎖,如果程序執行的效果和對同一個對象加鎖的情況完全一樣的話,那么就可以證明這兩個被加鎖的對象是同一個對象。基于這樣的原理我們來看看我們的Sample:















"Artech.ImmutableString", "Artech.ImmutableString.MarshalByRefType") as MarshalByRefType;

"Artech.ImmutableString", "Artech.ImmutableString.MarshalByRefType") as MarshalByRefType;





























我們來簡單地分析一下上面的coding.
我們創建了一個繼承自MarshalByRefObject,因為我需要讓它具有跨AppDomain傳遞的能力。在這個Class中定義了兩個為實現線程同步的helper字段,一個是string類型的_stringLockHelper和object類型的_objectLockHelper,并為他們定義了相應的Property。此外定義了兩個方法:ExecuteWithStringLocked和ExecuteWithStringLocked,他們的操作類似:在先對_stringLockHelper和_objectLockHelper加鎖的前提下,輸出出操作執行的AppDomain和確切時間。我們通過調用Thread.Sleep模擬10s的時間延遲。
在Main方法中,首先創建了兩個AppDomain,名稱分別為Artech.AppDomain1和Artech.AppDomain2。隨后在這兩個AppDomain中創建兩個MarshalByRefType對象,并為它們的StringLockHelper屬性賦上相同的值:Hello World。最后,我們創建了兩個新的線程,并在它們中分別調用在兩個不同AppDomain 中創建的MarshalByRefType對象的ExecuteWithStringLocked方法。我們來看看運行后的輸出結果:
從上面的輸出結果中可以看出,兩個分別在不同線程中執行操作對應的AppDomain的name分別為Artech.AppDomain1和Artech.AppDomain2。執行的時間(確切地說是操作成功地對MarshalByRefType對象的_stringLockHelper字段進行加鎖的時間)相隔10s,也就是我們在程序中定義的時間延遲。
為什么會出現這樣的結果呢?我們只是對兩個處于不同AppDomain的不同的MarshalByRefType對象的stringLockHelper字段進行加鎖。由于我們是同時開始他們對應的線程,照理說它們之間不會有什么關聯,顯示出來的時間應該是相同的。唯一的解釋就是:雖然這兩個在不同的AppDomain中創建的對象是兩個完全不同的對象,由于他們的stringLockHelper字段具有相同的字符序列,它們引用的是同一個string。這就證明了我們提出的跨AppDomain進行string interning的結論。
為了進一步印證我們的結論,我們是使兩個MarshalByRefObject對象的stringLockHelper字段具有不同的值,看看結果又如何。于是我們把其中一個對象的stringLockHelper字段改為”Hello World!”(多加了一個!) 。



看看現在的輸出結果,現在的時間是一樣了。
上面我們做的是對string類型字段加鎖的試驗。那么我們對其他類型的對象進行加鎖,又會出現怎么的情況呢?我們現在就來做這樣試驗:在各自的線程中調用兩個對象的ExecuteWithObjectLocked方法。我們修改Execute方法和Main()。











"Artech.ImmutableString", "Artech.ImmutableString.MarshalByRefType") as MarshalByRefType;

"Artech.ImmutableString", "Artech.ImmutableString.MarshalByRefType") as MarshalByRefType;














我們先來看看運行后的輸出結果:
我們發現兩個時間是一樣的,那么就是說兩個對象的ObjectLockHelper引用的不是同一個對象。雖然上面的程序很簡單,我覺得里面涉及的規程卻很值得一說。我們來分析下面3段代碼。




簡單看起來,兩個MarshalByRefObject對象的ObjectLockHelper都是引用的同一個對象obj。但是背后的情況沒有那么簡單。代碼第一行創建了一個新的對象obj,這個對象是在當前AppDomain 中創建的。二對于當前的AppDomain來說,marshalByRefObj1和marshalByRefObj2僅僅是一個Transparent proxy而已,它們包含一個在Artech.AppDomain1和Artech.AppDomain2中創立的MarshalByRefObject對象的引用。我們為它的ObjectLockHelper復制,對于Transparent proxy對象的賦值調用會傳到真正對象所在的AppDomain,由于obj是當前AppDomain的對象,它不能直接賦給另一個AppDomain的對象。所以它必須經歷一個Marshaling的過程才能被傳遞到另外一個AppDomain。實際上當復制操作完成之后,真正的ObjectLockHelper屬性對應的對象是根據原數據重建的對象,和在當前AppDomain中的對象已經沒有任何的關系。所以兩個MarshalByRefObject對象的ObjectLockHelper屬性引用的并不是同一個對象,所以對它進行加鎖對彼此不要產生任何影響。
四、從Garbage Collection的角度來看string
我們知道在一個托管的環境下,一個對象的生命周期被GC管理和控制。一個對象只有在他不被引用的時候,GC才會對他進行垃圾回收。而對于一個string來說,它始終被interning table引用,而這個interning table是針對一個Process的,是被該Process所有AppDomain共享的,所以一個string的生命周期相對比較長,只有所有的AppDomain都不具有對該string的引用時,他才有可能被垃圾回收。
五、從多線程的角度來看string
一方面由于string的恒定性,我們不用考慮多線程的并發操作產生的線程同步問題。另一方面由于字符串的駐留,我們在對一個string對象進行加鎖操作的時候,極有可能拖慢這個Application的performance,就像我們的Sample中演示的那樣。而且很有可能影響到處于同一進程的其他Application,以致造成死鎖。所以我們在使用鎖的時候,除非萬不得已,切忌對一個string進行加鎖。
六、如何高效地使用string
下面簡單介紹一些高效地使用string的一些小的建議:
1. 盡量使用字符串(literal string)相加來代替字符串變量和字符創相加,因為這樣可以使用現有的string操作指令進行操作和利用字符串駐留。
比如:

優于:



2. 在需要的時候使用StringBuilder對string作頻繁的操作:
由于string的恒定性,在我們對一個string進行某些操作的時候,比如調用ToUpper()或者ToLower()把某個string每個字符轉化成大寫或者小寫;調用SubString()取子串;會創建一個新的string,有時候會創建一些新的臨時string。這樣的操作會增加內存的壓力。所有在對string作頻繁操作的情況下,我們會考慮使用StringBuilder來高效地操作string。StringBuilder之所以能對string操作帶來更好的performance,是因為在它的內部維護一個字符數組,而不是一個string來避免string操作帶來的新的string的創建。
StringBuilder是一個很好的字符累加器,我們應該充分地利用這一個功能:



最好寫成:




避免創建一個新的臨時string來保存str1 + str2。再比如下面的Code:





最好寫好吧WorkOnString1,WorkOnString2,WorkOnString3定義成:




3. 高效地進行string的比較操作
我們知道,對象之間的比較有比較Value和比較Reference之說。一般地對Reference進行比較的速度最快。對于string,在字符串駐留的前提下,我們可以把對Value的比較用Reference的比較來代替從而會的Performance的提升。
此外,對于忽略大小寫的比較,我們最好使用string的static方法Compare(string strA, string strB, bool ignoreCase)。也就是說:

最好寫成:
