參考資料 [1] @毛星雲【《Effective C 》提煉總結】 https://zhuanlan.zhihu.com/p/24553860 [2] 《C 捷徑教程》 [3] @flashyiyi【C NoGCString】 https://zhuanlan.zhihu.com/p/3552560 ...
參考資料
[1] @毛星雲【《Effective C#》提煉總結】 https://zhuanlan.zhihu.com/p/24553860
[2] 《C# 捷徑教程》
[3] @flashyiyi【C# NoGCString】 https://zhuanlan.zhihu.com/p/35525601
[4] 如何理解 String 類型值的不可變? @胖君和@程式媛小雙的回答 https://www.zhihu.com/question/20618891
基礎知識
- String類型在C#中用於保存字元,為引用類型,一旦創建,就不能再進行修改,其底層是根據字元數組(char[])實現的。
- StringBuilder表示可變字元字元串類型,其中的字元可以被改變、增加、刪除,當向一個已滿的StringBuilder添加字元時,其會自動申請記憶體進行擴容。
- Unity中Profiler視窗的GC Alloc那一列的信息表示的是當前幀產生了多少垃圾(指一塊存儲不再使用的數據的記憶體)。Unity官方文檔對此標簽是這樣的解釋的:
The GC Alloc column shows how much memory has been allocated in the current frame, which is later collected by the garbage collector.
大致意思是,GC Alloc這一列表示當前幀有多少記憶體被分配,這些記憶體將會在之後被垃圾回收器進行清理。
疑難解答
- 如何理解String類型值的不可變?
- 為什麼String類型的連接(加法和Concat)性能低下?與之相比,為什麼StringBuilder更快?
- String類型與GC(垃圾回收器)的關係?
- 如何正確的使用String與StringBuilder?
如何理解String類型值的不可變?
在C#中string類型的底層由char[],即字元數組進行實現,但我們並不能像修改字元數組的方式來對字元串進行修改。事實上,我們以為的修改(字元串的連接,字元串的賦值)對於字元串來說都不是真正的修改,每當我們對字元串進行賦值時,底層會進行兩個操作。
- 首先會去查找字元串池,如果字元串池有這個字元串,那麼直接將當前變數指向字元串池內的字元串。
- 如果字元串池內沒有這個字元串,那麼在堆上創建一塊記憶體用於放置這個字元串,並將當前變數指向這個新建的字元串。
一個新建字元串的簡單例子如下:
public static void Main(string[] args) {
string s = "abc";
Console.WriteLine(s);
s = "123";
Console.WriteLine(s);
}
其中第4行s的賦值語句並不是將原本"abc"的字元串修改成"123",而是另外在堆上創建了一個新的記憶體"123",並將s變數指向這個新字元串,而舊的字元串"abc"就被丟棄了,但它仍然在堆上占據著記憶體,等待GC將其回收。
對於字元串的連接(加法或Concat函數),其原理同上,事實上原來的字元串並沒有真正在後面增加了字元,而是創建了一個新的字元串,其值是兩個字元串連接後的結果。
字元串的這種特性,使得它的賦值和連接操作很容易造成記憶體浪費,因為每一次都將在堆上創建一個新的字元串對象。所以一個比較明確的思路是,不要頻繁的調用字元串的連接操作(比如放在Unity的Update函數中)。
既然不可變特性使得我們不得不小心的使用字元串,那麼字元串為什麼還會被設計成不可變的形式呢?很顯然,不可變的形式對於字元串可變的形式是利大於弊的,下麵根據參考資料[4][3],嘗試列舉、闡述一下為什麼字元串一定要是不可變的。
- 線程安全。在多線程環境下,只有對資源的修改是有風險的,而不可變對象只能對其進行讀取而非修改,所以是線程安全。如果字元串是可修改的,那麼在多線程環境下,需要對字元串進行頻繁加鎖,這是比較影響性能的。
- 為了安全(防止程式員意外修改了字元串)。想象下麵這樣一種情況,一個靜態方法用於給字元串(或StringBuilder)後面增加一個字元串。
public class StringTest{
public static string AppendString(string s) {
s += "abc";
return s;
}
public static StringBuilder AppendString(StringBuilder s) {
s = s.Append("abc");
return s;
}
public static void Main(string[] args) {
string s = "123";
string s2 = AppendString(s);
Console.WriteLine("原字元串:"+s+" 經過添加後的字元串:"+s2);
StringBuilder sb = new StringBuilder("123");
StringBuilder sb2 = AppendString(sb);
Console.WriteLine("原字元串:" + sb.ToString() + " 經過添加後的字元串:" + sb2.ToString());
}
}
運行結果如下:
原字元串:123 經過添加後的字元串:123abc
原字元串:123abc 經過添加後的字元串:123abc
可以看到StringBuilder因為是可變的,所以原字元串直接在靜態方法中被修改成了"123abc",而string類型因為其不可變的特性,所以它的原字元串和修改後的新字元串是不同的,這種不可變特性也就避免了程式員直接在方法裡面直接對字元串進行連接操作,導致字元串在不知情的情況下被修改了(就像StringBuilder一樣)。
- 因為字元串的不可變特性,所以其可以放心地作為Dictionary和Set的鍵(在Java中則是Map和Set)。在Dictionary和Set中使用可變類型作為鍵是極其危險的事,因為可修改鍵可能會導致Set和Dictionary中鍵值的唯一性被破壞。
為什麼String類型的連接(加法和Concat)性能低下?與之相比,為什麼StringBuilder更快?
先解決第一個問題,為什麼String類型的連接(加法和Concat)性能低下?
前面提到了,因為字元串是不可變的,所以所有看似對其進行了修改的操作,都是在堆上另外創建了一個新的字元串,而這創建過程是耗費性能(申請記憶體,檢查記憶體是否足夠,不夠的情況還要讓GC對垃圾記憶體進行回收),所以可想而知字元串連接性能是比較低的。
當然,性能高低是需要有一個參照物的,與StringBuilder的連接操作相比,string類型就是相當慢了,除了慢以外,字元串的連接操作還會產生大量GC,因為每一次連接,都創建了新的字元串,而舊的字元串理所當然就被丟棄了,在沒有任何變數引用這些舊字元串的情況下,GC要對這些舊字元串占據的記憶體進行回收,而GC的觸發是十分耗費性能的(簡單來說就是費時,因為GC是要遍歷堆上所有無引用的對象),表現在Unity中,就是在某一幀相比其他幀額外消耗了幾十ms來處理GC。
那麼,StringBuilder的連接操作為什麼快呢?
這要從StringBuilder的底層開始說起,StringBuilder的底層與string一樣都是字元數組(即char[]),與string被設計為不可變不同的是,StringBuilder是可變的。
當StringBuilder進行連接操作時,它會經歷以下步驟:
- 檢查當前字元數量是否大於長度,如果大於,那麼對StringBuilder進行擴容。
- 向char[]數組後面添加字元
很顯然,只有在StringBuilder長度小於添加的字元時,才會額外申請記憶體對char[]數組進行擴容,其他情況下,就是對數組內的元素進行變換而已,與string類型每次連接都會廢棄掉一個對象相比,StringBuilder就顯得更快一些了。
當然,除了連接操作,StringBuilder還支持刪除、修改字元串,這當然也是根據其中的char []數組進行操作的(而字元串因為其不可變性,是不支持這些操作的)。
考慮到StringBuilder擴容也是會產生GC的,所以一般比較好的做法是,在StringBuilder創建時就根據之後的使用情況為其指定一個容量。
String類型與GC(垃圾回收器)的關係?
這裡主要研究在Unity3D引擎下,string類型和StringBuilder進行連接操作產生的GC Alloc情況。
之前一直說string類型的連接操作浪費記憶體,那麼具體是什麼情況呢?這裡可以使用Unity3D引擎進行試驗,下麵嘗試在每幀進行1000次字元串加法,然後使用Profiler查看GC Alloc。
public class StringAppendGC : MonoBehaviour {
string s = "";
// Use this for initialization
void Start () {
}
// 測試字元串加法在每一幀帶來的GC
void Update () {
s = "";
// 每一幀進行1000次字元串加法
for (int i=0;i<=1000;i++) {
s += i;
}
}
}
GC產生情況如下:
可以看到上面的函數每一幀都產生了2.7M的垃圾,而Unity官方對於GC Alloc這一列的描述是這樣的:
Keep this value at zero to prevent the garbage collector from causing hiccups in your framerate
大致意思是,保持該值為0以防止垃圾回收器使得某一幀(與其他幀相比)耗費的時間過長,造成“大幀”現象。
那麼如果將上面的String改用StringBuilder會怎麼樣呢?將上面的代碼改為如下所示:
public class StringAppendGC : MonoBehaviour {
// Use this for initialization
void Start () {
}
// 測試字元串加法在每一幀帶來的GC
void Update () {
StringBuilder stringBuilder = new StringBuilder(1000);
// 每一幀進行1000次字元串加法
for (int i=0;i<=1000;i++) {
stringBuilder.Append(i);
}
}
}
GC Alloc情況如下:
可以看到每幀分配記憶體的情況從2.7M下降到了44KB,相比於String類型有了明顯的改善。
如何正確的使用String與StringBuilder?
既然知道了String類型在某些操作上會造成浪費,那麼我們使用它的時候就要萬分小心,根據參考資料[1]淺墨大佬所說,正確使用String與StringBuilder的姿勢如下:
創建不可變類型的最終值。比如string類的+=操作符會創建一個新的字元串對象並返回,多次使用會產生大量垃圾,不推薦使用。對於簡單的字元串操作,推薦使用string.Format。對於複雜的字元串操作,推薦使用StringBuilder類