StringBuilder內存碎片對性能的影響


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;
}

關於這段代碼,請注意以下幾點:

  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,以使得訪問時獲得最佳的內存連續性,即可解決此問題:

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騷操作】

DotNet騷操作


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM