參考資料
[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類