字符串應該是所有編程語言中使用最頻繁的一種基礎數據類型。如果使用不慎,我們就會為一次字符串的操作所帶來的額外性能開銷而付出代價。本條建議將從兩個方面來探討如何規避這類性能開銷:
1. 確保盡量少的裝箱
2. 避免分配額外的內存空間。
第一個方面:確保盡量少的裝箱
對於裝拆箱,我們應該不陌生,值類型轉換成引用類型即為裝箱, 引用類型轉換成值類型即為拆箱。 在自己編寫的代碼中,應當盡可能的避免編寫不必要的裝箱代碼。裝箱之所以會帶來性能損耗,因為它需要完成下面三個步驟:
• 首先,會為值類型在托管堆中分配內存。除了值類型本身所分配的內存外,內存總量還要加上類型對象指針和同步塊索引所占用的內存。
• 然後,將值類型的值復制到新分配的堆內存中。
• 最後,返回已經成為引用類型的對象的地址。
下面是一行最簡單的裝箱代碼
1 object obj = 1;
這行語句將整型常量1賦給object類型的變量obj; 眾所周知常量1是值類型,值類型是要放在棧上的,而object是引用類型,它需要放在堆上;要把值類型放在堆上就需要執行一次裝箱操作。
這行語句的IL代碼如下,請注意注釋部分說明:
.locals init ( [0] object objValue ) //以上三行IL表示聲明object類型的名稱為objValue的局部變量 IL_0000: nop IL_0001: ldc.i4.s 9 //表示將整型數9放到棧頂 IL_0003: box [mscorlib]System.Int32 //執行IL box指令,在內存堆中申請System.Int32類型需要的堆空間 IL_0008: stloc.0 //彈出堆棧上的變量,將它存儲到索引為0的局部變量中
以上就是裝箱所要執行的操作了,執行裝箱操作時不可避免的要在堆上申請內存空間,並將堆棧上的值類型數據復制到申請的堆內存空間上,這肯定是要消耗內存和cpu資源的。我們再看下拆箱操作是怎麼回事:
請看下面的C#代碼:
object objValue = 4; int value = (int)objValue;
上面的兩行代碼會執行一次裝箱操作將整形數字常量4裝箱成引用類型object變量objValue;然後又執行一次拆箱操作,將存儲到堆上的引用變量objValue存儲到局部整形值類型變量value中。
同樣我們需要看下IL代碼:
.locals init ( [0] object objValue, [1] int32 'value' ) //上面IL聲明兩個局部變量object類型的objValue和int32類型的value變量 IL_0000: nop IL_0001: ldc.i4.4 //將整型數字4壓入棧 IL_0002: box [mscorlib]System.Int32 //執行IL box指令,在內存堆中申請System.Int32類型需要的堆空間 IL_0007: stloc.0 //彈出堆棧上的變量,將它存儲到索引為0的局部變量中 IL_0008: ldloc.0//將索引為0的局部變量(即objValue變量)壓入棧 IL_0009: unbox.any [mscorlib]System.Int32 //執行IL 拆箱指令unbox.any 將引用類型object轉換成System.Int32類型 IL_000e: stloc.1 //將棧上的數據存儲到索引為1的局部變量即value
拆箱操作的執行過程和裝箱操作過程正好相反,是將存儲在堆上的引用類型值轉換為值類型並給值類型變量。
裝箱操作和拆箱操作是要額外耗費CPU和內存資源的。那如何避免裝箱和拆箱操作呢?有以下方法:
1. 用泛型集合取代ArrayList。
2. 用C#自帶的轉換方法,將值類型轉換為引用類型。
下面我們看下使用泛型和不使用泛型引發裝箱拆箱的情況。
1. 使用非泛型集合時引發的裝箱和拆箱操作
看下面的一段代碼:
var array = new ArrayList(); array.Add(1); array.Add(2); foreach (int value in array) { Console.WriteLine(“value is {0}”,value); }
代碼聲明了一個ArrayList對象,向ArrayList中添加兩個數字1,2;然後使用foreach將ArrayList中的元素打印到控制台。
在這個過程中會發生兩次裝箱操作和兩次拆箱操作,在向ArrayList中添加int類型元素時會發生裝箱,在使用foreach枚舉ArrayList中的int類型元素時會發生拆箱操作,將object類型轉換成int類型,在執行到Console.WriteLine時,還會執行兩次的裝箱操作;這一段代碼執行了6次的裝箱和拆箱操作;如果ArrayList的元素個數很多,執行裝箱拆箱的操作會更多。
你可以通過使用ILSpy之類的工具查看IL代碼的box,unbox指令查看裝箱和拆箱的過程
2. 使用泛型集合的情況
請看如下代碼:
1 var list = new List<int>(); 2 list.Add(1); 3 list.Add(2); 4 5 foreach (int value in list) 6 { 7 Console.WriteLine("value is {0}", value); 8 }
代碼和1中的代碼的差別在於集合的類型使用了泛型的List,而非ArrayList;我們同樣可以通過查看IL代碼查看裝箱拆箱的情況,上述代碼只會在Console.WriteLine()方法時執行2次裝箱操作,不需要拆箱操作。
可以看出泛型可以避免裝箱拆箱帶來的不必要的性能消耗;當然泛型的好處不止於此,泛型還可以增加程序的可讀性,使程序更容易被復用等等。
但是我們注意到,在使用泛型集合的時候,Console.WriteLine()方法時仍然執行2次裝箱操作。能否將這兩次裝箱操作也優化掉呢?這就使用到了第二個方法,用C#自帶的轉換方法,將值類型轉換為引用類型。如下:
var list = new List<int>(); list.Add(1); list.Add(2); foreach (int value in list) { Console.WriteLine(string.Format("value is {0}", value.ToString())); }
再查看IL代碼時可以發現,裝箱操作已經被徹底消除了。它實際調用的是整形的ToString方法。ToString方法的原型為:
public override string ToString() { return Number.FormatInt32(m_value, null, NumberFormatInfo.CurrentInfo); }
它是通過直接操作內存來完成從int到string的轉換,效率要比裝箱高很多。所以,在使用其他值類型到字符串的轉換並完成拼接時,應當避免使用操作符“+”來完成,而應該使用值類型提供的ToString方法。
第二個方面:避免分配額外的內存空間。
對CLR來說,string對象是個很特殊的對象,它一旦被賦值就不可改變。在運行時調用System.String類中的任何方法或進行任何運算(如“=”賦值,“+”拼接等),都會在內存中創建一個新的字符串對象,這也意味著要為該新對象分配新的內存空間。像下面的代碼就會帶來運行時的額外開銷。
private static void Test6() { string s1 = "abc"; s1 = "123" + s1 + "456"; // 以上兩行代碼創建了3個String對象,並執行了一次String.Contact方法 string s2 = 9 + "456"; // 該代碼發生一次裝箱,並調用一次String.Concact方法 } private static void Test7() { string s1 = "123" + "abc" + "456"; // 該代碼等效於string s1 = "123abc456" }
由於使用String類會在某些場合帶來明顯的性能損耗,所以微軟另外提供了一個類型StringBuilder來彌補String的不足。
StringBuilder並不會重新創建一個String對象,它的效率源於預先以非托管的方式分配內存。如果StringBuilder沒有先定義長度,則默認分配的長度為16,當StringBuilder字符長度小於等於16時,StringBuilder不會重新分配內存。當StringBuilder字符長度大於16時小於32時,StringBuilder又會重新分配內存,使之成為16的倍數。在上面的代碼中,如果預先判斷字符串的長度將大於16,則可以為其設定一個更加合適的長度。
微軟還提供了另外一個方法來簡化這種操作,即使用string.Format方法。string.Format方法在內部使用StringBuilder進行字符串的格式化。
private static void Test9() { string a = "t"; string b = "e"; string c = "s"; string d = "t"; StringBuilder sb = new StringBuilder(); sb.Append(a); sb.Append(b); sb.Append(c); sb.Append(d); Console.WriteLine(sb.ToString()); } private static void Test10() { string a = "t"; string b = "e"; string c = "s"; string d = "t"; Console.WriteLine(string.Format("{0}{1}{2}{3}", a, b, c, d)); }
最後總結:如何正確操作字符串:
1. 確保盡量少的拆裝箱操作:使用泛型,使用ToString()將值類型轉換為引用類型
2. 避免分配額外的內存空間:不用+=, +操作符, 使用StringBuilder, String.Format()鏈接多個String
參考引用列表:
http://www.cnblogs.com/yukaizhao/archive/2011/10/18/csharp_box_unbox_1.html
http://www.cnblogs.com/yukaizhao/archive/2011/10/19/csharp_box_unbox_2.html
《編寫高質量代碼:改善C#程序的157個建議》