StringBuilder在C#中的内部工作原理是什么?

55

StringBuilder是如何工作的?

它在内部做了什么?它是否使用了不安全的代码?为什么它比+操作符更快?


4
如果您对详细内容感到好奇,您也可以直接下载参考源代码并查看。在RefSrcDirectory\Source\.Net\4.0\DEVDIV_TFS\Dev10\Releases\RTMRel\ndp\clr\src\BCL\System\Text\StringBuilder.cs\1305376路径下有一份带注释的StringBuilder.cs副本。我列出的目录结构可能与参考源代码中的不完全相同,而且只适用于 .Net 4.0。 - Brian
4个回答

84

当你使用 + 操作符来构建字符串时:

string s = "01";
s += "02";
s += "03";
s += "04";

在第一次连接时,我们创建了一个长度为四的新字符串,并将“01”和“02”复制到其中--复制了四个字符。在第二次连接中,我们创建了一个长度为六的新字符串,并将“0102”和“03”复制到其中--复制了六个字符。在第三次连接中,我们创建了一个长度为八的新字符串,并将“010203”和“04”复制到其中--复制了八个字符。到目前为止,这个包含八个字符的字符串已经复制了4 + 6 + 8 = 18个字符。继续进行。

...
s += "99";
第98次连接操作时,我们生成一个长度为198的字符串,将"010203...98"和"99"复制到其中。这使得我们总共需要4+6+8+...+198个字符来生成这个字符串。
与此不同的是,字符串构建器并没有进行所有这些复制操作。相反,它维护了一个可变数组,该数组希望比最终字符串更大,并在必要时将新内容放入该数组中。
当猜测错误且数组已满时会发生什么?有两种策略。在框架的先前版本中,字符串构建器在其满时重新分配和复制数组,并将其大小加倍。在新实现中,字符串构建器维护相对较小的数组链表,并在旧链表已满时将新数组附加到链表的末尾。
此外,正如您所猜测的那样,字符串构建器可以使用“不安全”代码技巧来提高性能。例如,将新数据写入数组的代码可能已经检查了数组写入是否在范围内。通过关闭安全系统,它可以避免每次写入到数组时及时调用jitter以验证数组每次写入是否安全,从而提高性能。字符串构建器执行许多此类技巧,以确保重用缓冲区而不是重新分配缓冲区,并确保避免不必要的安全检查等。我建议不要使用这些诡计,除非您真的擅长正确编写不安全的代码,并确实需要挤出性能的每个最后一点。

3
或许值得添加一条说明,说如果使用String.Concat通过s = x + y + z;进行拼接的话,结果并非如此,以防有些傻瓜决定要将它们全部优化成StringBuilder(我认识真的有这样的人)。 - ShuggyCoUk
2
我不知道StringBuilder的新版本有这个优化 - 这是一个很好的优化。如果我将一个巨大的字符串附加到字符串构建器中,它是否使用您提到的新创建数组的“相对较小”的数组大小,还是使用更大的大小来适应我的整个新字符串? - configurator
没关系,我已经自己找到答案了 - 一个字符串构建器通常会根据需要扩展,或者将其大小加倍 - 取决于哪个更大。 - configurator
2
如果你写了 string s = "01" + "02" + "03" + "04",它会编译成 string s = string.Concat("01","02","03","04") 吗?(实际上我认为编译器会将其优化为 string s = "01020304",但如果所有的字符串值都不是硬编码的,它会使用 String.Concat 吗?) - Nick
2
@Nick:是的。每次对Concat的调用都会获取其所有参数的总长度并分配一个恰好足够大的新字符串。 - Eric Lippert
显示剩余2条评论

24

StringBuilder 的实现在不同版本之间有所改变。但基本原理是保持一种可变的数据结构。我认为它过去使用的是一个仍在被修改的字符串(使用内部方法),并确保在返回后它不会再被修改。

使用 StringBuilder 而非字符串拼接 循环中 更快的原因,正是由于其可变性 - 每次修改后不需要构建新的字符串,这意味着不需要复制字符串中的所有数据等。

对于单个字符串连接而言,使用 + 实际上比使用 StringBuilder 稍微更有效率。只有当您执行 多个 操作时,并且您不需要中间结果时,StringBuilder 才会表现出色。

有关更多信息,请参见我的关于 StringBuilder 的文章


我在大约6年前的这个主题上找到了你的评论:http://bytes.com/topic/c-sharp/answers/230649-stringbuilder-how-does-internally-work - user195488

3

微软CLR确实使用内部调用进行一些操作(与不安全代码不完全相同)。与一堆连接的字符串相比,最大的性能优势在于它将内容写入char[],并且不会创建太多中间字符串。当您调用ToString()时,它会从您的内容构建一个完成的、不可变的字符串。


您能否提供更多细节?当您将项目分组并创建一个巨大的字符串时,它是否重新定义数组大小?它只是一个指向char数组(或链表)的指针数组,在调用tostring时转换为单个对象吗?您能否引用一下来源? - JSWork
内部是透明的,但由于它具有像StringBuilder.EnsureCapacity这样的方法,因此人们会认为它是一个单个的大缓冲区,如果需要则增长。 - agent-j
那样做的效率不是比使用链表并在末尾合并要低吗?我的意思是,如果你有一个1兆字节的字符串要添加,你必须创建一个已经存在的东西的副本,这需要时间和资源。如果你只是指向原始字符串的指针,你就不必担心它会改变,因为它是不可变的,gac也不会删除它,因为你引用了不可变字符串。 - JSWork
1
@JSWork,如果我说stringBuilder.Remove(1023, 2000),那会怎么样?如果你有一个字符串的链表,那就是一个复杂的算法。我相信这不会很有效率。但是,如果你知道自己不需要插入、删除、替换等功能,可以随意实现自己的LLStringBuilder类。 - agent-j
@agent-j:我想这是一个权衡。你必须遍历链表并最终将其放入字符串中 - 我的主要目标是尽可能少地重新分配数组长度。使用链接列表进行附加操作可以让您做到这一点。您可以拥有一个单独的私有方法,根据链接列表的总大小(替换链接列表),在需要时创建字符数组(removeat、tostring等)- 无论如何,您都需要它来进行tostring操作。我正在寻找的是尽可能少地调整数组大小。由于数组不适合而导致的GC重新分配可能会严重妨碍。 - JSWork
1
@JSWork,使用new StringBuilder(2048*1024),您可以指定足够大的初始容量,从而最小化重新调整大小的成本。(您可能已经知道了这一点,但它可能会使未来的读者受益。) - agent-j

2
StringBuilder使用可以被修改的字符串缓冲区,相比不可修改的普通字符串。当你调用StringBuilder的ToString方法时,它会将字符串缓冲区冻结并转换为普通字符串,这样就不需要再复制所有数据了。
由于StringBuilder可以修改字符串缓冲区,它不必为每个字符串数据更改创建一个新的字符串值。当你使用+运算符时,编译器会将其转换为String.Concat调用,从而创建一个新的字符串对象。这看似无害的代码:
str += ",";

编译成这样:

str = String.Concat(str, ",");

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接