StringBuilder記憶體碎片對效能的影響
阿新 • • 發佈:2020-03-21
# StringBuilder記憶體碎片對效能的影響
## TL;DR:
`StringBuilder`內部是由多段`char[]`組成的**半自動連結串列**,因此頻繁從**中間**修改`StringBuilder`,會將原本連續的記憶體分隔為多段,從而影響讀取/遍歷效能。
連續記憶體與不連續記憶體的效能差,可能高達`1600`倍。
## 背景
用`StringBuilder`的使用者可能大都想用`StringBuilder`拼接`html/json`模板、組裝動態`SQL`等正常操作。但在一些特殊場景中——如為某種程式語言寫語言服務,或者寫一個富文字編輯器時,`StringBuilder`依然也有用武之地,通過裡面的`Insert`/`Remove`兩個方法來修改。
## 測試方法
*Talk is cheap, show me the code*:
```csharp
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;
}
```
關於這段程式碼,請注意以下幾點:
1. 通過`.PadRight(n)`來直接建立長度為`n`的空白字串,可以用`new string(' ', n)`來代替;
2. `new Random(42)`處,我指定了一個隨機因子,確保每次分隔後分隔的位置**完全相同**,有利於做對照組;
3. 我分別對字串進行了`2^1 ~ 2^16`次修改,分別比較經過這麼多次修改之後的效能差異;
4. 我使用`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`,以使得訪問時獲得最佳的記憶體連續性,即可解決此問題:
```csharp
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);
// 重點
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](https://code.visualstudio.com/blogs/2018/03/23/text-buffer-reimplementation)。
喜歡的朋友請關注我的微信公眾號:【DotNet騷操作】
![DotNet騷操作](https://img2018.cnblogs.com/blog/233608/201908/233608-20190825165420518-990227633.jpg)