浅谈C# StringBuilder内存碎片对性能的影响

2022-10-07,,,,

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。

到此这篇关于浅谈stringbuilder内存碎片对性能的影响的文章就介绍到这了,更多相关stringbuilder 内存碎片内容请搜索以前的文章或继续浏览下面的相关文章希望大家以后多多支持!

《浅谈C# StringBuilder内存碎片对性能的影响.doc》

下载本文的Word格式文档,以方便收藏与打印。