淺談C# StringBuilder記憶體碎片對效能的影響
StringBuilder內部是由多段char[]組成的半自動連結串列,因此頻繁從中間修改StringBuilder,會將原本連續的記憶體分隔為多段,從而影響讀取/遍歷效能。
連續記憶體與不連續記憶體的效能差,可能高達1600倍。
背景
用StringBuilder的使用者可能大都想用StringBuilder拼接html/json模板、組裝動態SQL等正常操作。但在一些特殊場景中——如為某種程式語言寫語言服務,或者寫一個富文字編輯器時,StringBuilder依然也有用武之地,通過裡面的Insert/Remove兩個方法來修改。
測試方法
Talk is cheap,show me the code:
int docLength = 10000; void Main() { (from power in Enumerable.Range (1,16) let mutations = (int) Math.Pow (2,power) select new { mutations,PerformanceRatio = Math.Round (GetPerformanceRatio (docLength,mutations),1) }).Dump(); } float GetPerformanceRatio (int docLength,int mutations) { var sb = new StringBuilder ("".PadRight (docLength)); var before = GetPerformance (sb); FragmentStringBuilder (sb,mutations); var after = GetPerformance (sb); return (float) after.Ticks / before.Ticks; } void FragmentStringBuilder (StringBuilder sb,int mutations) { var r = new Random(42); for (int i = 0; i < mutations; i++) { sb.Insert (r.Next (sb.Length),'x'); sb.Remove (r.Next (sb.Length),1); } } TimeSpan GetPerformance (StringBuilder sb) { var sw = Stopwatch.StartNew(); long tot = 0; for (int i = 0; i < sb.Length; i++) { char c = sb[i]; tot += (int) c; } sw.Stop(); return sw.Elapsed; }
關於這段程式碼,請注意以下幾點:
- 通過.PadRight(n)來直接建立長度為n的空白字串,可以用new string(' ',n)來代替;
- new Random(42)處,我指定了一個隨機因子,確保每次分隔後分隔的位置完全相同,有利於做對照組;
- 我分別對字串進行了2^1 ~ 2^16次修改,分別比較經過這麼多次修改之後的效能差異;
- 我使用sb[i]來逐一訪問StringBuilder中的位置,使記憶體不連續性更加突顯。
執行結果
mutations | PerformanceRatio |
---|---|
2 | 1 |
4 | 1 |
8 | 1 |
16 | 1 |
32 | 1 |
64 | 1.1 |
128 | 1.2 |
256 | 1.8 |
512 | 5.2 |
1024 | 19.9 |
2048 | 81.3 |
4096 | 274.5 |
8192 | 745.8 |
16384 | 1578.8 |
32768 | 1630.4 |
65536 | 930.8 |
可見如果在StringBuilder中間進行大量修改,其效能會急據下降,注意看32768次修改的情況下,遍歷時會產生高達1630.4倍的效能差!
解決方式
如果一定要用StringBuilder,可以考慮在修改一定次數後,重新建立一個新的StringBuilder,以使得訪問時獲得最佳的記憶體連續性,即可解決此問題:
void FragmentStringBuilder (StringBuilder sb,1); // 重點 const int defragmentCount = 250; if (i % defragmentCount == defragmentCount - 1) { string buf = sb.ToString(); sb.Clear(); sb.Append(buf); } } }
如上,每經過250次修改,即將原StringBuilder刪除,然後重新建立一個新的StringBuilder,此時執行效果如下:
mutations | PerformanceRatio |
---|---|
2 | 1.2 |
4 | 0.7 |
8 | 1 |
16 | 1 |
32 | 1 |
64 | 1.1 |
128 | 1.2 |
256 | 1 |
512 | 1 |
1024 | 1 |
2048 | 1 |
4096 | 1.1 |
8192 | 1.5 |
16384 | 1.3 |
32768 | 1 |
65536 | 1 |
可見,在幾乎所有情況下,受記憶體不連續造成的訪問效能問題,解決——同時250可能是一個相對比較合理的數字,在插入效能與查詢/遍歷效能中,獲得平衡。
反思與總結
眾所周知,由於string的不可變性,拼接大量字串時,會浪費大量記憶體。但使用StringBuilder也需要了解它的結構。
StringBuilder這樣做成鏈式的結構並非沒有原因,如果考慮插入效能,做成鏈式介面是最優秀的。但如果考慮查詢效能,鏈式結構就非常不利了,如果設計為非鏈式結構,從中間插入時,StringBuilder的記憶體空間可能不夠,因此需要重新分配記憶體,這樣相當於將StringBuilder降格為string,因此完全喪失了StringBuilder適合做“頻繁插入”的優勢。
本文說的其實是一個非常特殊的例子,現實中除了語言服務、編輯器外,很少會需要這種即要頻繁插入快,也要頻繁修改快的場景。如果想簡單點搞,用StringBuilder會是一個有條件合適的解決方案。更適合的解決方案當然是專門的資料結構——PieceTable,微軟在VSCode編輯器中,為了確保大檔案編輯效能,使用了該資料結構,取得了非常不錯的成果,參考連結:Text Buffer Reimplementation。
到此這篇關於淺談StringBuilder記憶體碎片對效能的影響的文章就介紹到這了,更多相關StringBuilder 記憶體碎片內容請搜尋我們以前的文章或繼續瀏覽下面的相關文章希望大家以後多多支援我們!