StringBuilder內存碎片對性能的影響
TL;DR:
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, 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。
喜歡的朋友請關注我的微信公眾號:【DotNet騷操作】