在C#中使用字符串拼接和字符串池化

3

我知道这个问题已经解决了,但是我有一个稍微不同的想法。一些人指出这是过早优化,如果我只是为了实用性而提问的话,这完全是正确的。我的问题根源于一个实际问题,但我仍然很好奇。


我正在创建一堆 SQL 语句来创建一个脚本(保存到磁盘),以便重新创建数据库架构(很容易有许多表,视图等)。这意味着我的字符串连接是仅追加的。根据 MSDN,StringBuilder 通过保持内部缓冲区(肯定是 char[])并将字符串字符复制到其中,并根据需要重新分配数组来工作。
然而,我的代码有很多重复的字符串("CREATE TABLE [","GO\n" 等),这意味着我可以利用它们 被池化,但如果使用 StringBuilder,则无法利用它们,因为每次都会复制它们。唯一的变量基本上是表名和其他已经存在于内存中的字符串形式的对象。
所以据我所知,在读取数据并创建保存模式信息的对象之后,所有的字符串信息都可以通过池化进行重用,对吗?
假设如此,那么 List 或 LinkedList 的字符串会更快,因为它们保留对池化字符串的指针?然后只需调用 String.Concat() 一次即可获得整个字符串的单个内存分配,该字符串的长度正好正确。
一个列表需要重新分配内存,而链表需要创建节点并修改指针,所以它们不是“自由的”,但如果我正在连接许多数千个国际化字符串,则它们似乎会更有效。
现在,我想我可以为每个SQL语句计算字符数,然后计算每种类型的字符数,并预设我的StringBuilder容量,以避免重新分配其char[],但我必须超额预留一定的余量来减少重新分配的概率。
因此,对于这种情况,哪种方法最快地获取一个单一的连接字符串:
  • StringBuilder
  • 已经国际化的字符串列表
  • 已经国际化的字符串链表
  • 带有容量启发式的StringBuilder
  • 其他方法?
作为上面的 另一个问题(我可能不总是去磁盘):单个StreamWriter写入输出文件是否更快?或者,先使用List或LinkedList,然后从列表中将它们写入文件,而不是首先在内存中连接它们。 编辑: 根据要求,参考资料(.NET 3.5)在MSDN上的解释如下:“如果有空间,新数据将附加到缓冲区的末尾;否则,将分配一个更大的缓冲区,将原始缓冲区中的数据复制到新缓冲区,然后将新数据附加到新缓冲区。” 对我来说,这意味着需要重新分配大小的char[](这需要将旧数据复制到调整大小的数组中),然后再进行附加。

这听起来像是过早的优化。是否有比字符串构建器更好的性能要求? - kevindaub
如果你正在编写一个程序来复制数据库模式,并且你一直在关注字符串连接的性能,那么你应该重新考虑你的优先事项。 - Robert Rossney
是的,我不是新手,我了解过早优化和优先级(以及至少还有其他几个方面)。但我并不是在寻求关于这两个方面的建议。:) 虽然这个问题源于一个实际的问题,但我并不是仅仅为了实用性而提问。话虽如此:你能回答这个问题吗? - Colin Burnett
7个回答

3

如果我要实现这样的功能,我不会构建StringBuilder(或任何其他脚本内存缓冲区)。

相反,我将其直接流式输出到文件中,并使所有字符串成为内联。

下面是伪代码示例(语法不正确或其他什么的):

FileStream f = new FileStream("yourscript.sql");
foreach (Table t in myTables)
{
    f.write("CREATE TABLE [");
    f.write(t.ToString());
    f.write("]");
    ....
}

那么,您将永远不需要脚本的内存表示,也不需要复制所有字符串。

有什么看法?


1
这回答了我的第二个问题。那么如果我正在执行这个脚本,该怎么办?往返磁盘是不必要的,因为磁盘比内存慢得多。 - Colin Burnett
“实际上”,如何使用内存文件系统?为了实现“直接执行它”的部分,将文件指向内存文件系统(我在谷歌上搜索了Windows的Ramdisk,得到了一些结果……Linux可以免费使用),然后执行它? - paquetp

3
针对你的单独问题,Win32有一个WriteFileGather函数,可以高效地将一组(内部化的)字符串写入磁盘 - 但仅在异步调用时才会产生显著差异,因为磁盘写入将掩盖除极大串联外的所有操作。
针对你的主要问题:除非你正在处理兆字节级别的脚本或数万个脚本,否则不用担心。
你可以预期StringBuilder在每次重新分配时将分配大小加倍。这意味着从256字节增长到1MB的缓冲区只需要12次重新分配 - 这相当不错,考虑到你最初的估计偏了3个数量级。
纯粹作为练习,一些估计:构建1MB缓冲区将清理大约3MB的内存(1MB源,1MB目标,1MB由于重新分配时的复制)。
链表实现将清理大约2MB的内存(而这忽略了每个字符串引用的8字节/对象开销)。因此,与典型的内存带宽为10Gbit/s和1MB L2缓存相比,您可以节省1MB的内存读/写。
是的,链表实现可能更快,并且如果您的缓冲区大一个数量级,则差异会很大。
对于更常见的小字符串情况,算法上的收益微不足道,并且很容易被其他因素抵消:StringBuilder代码可能已经在代码缓存中,是微优化的可行目标。此外,使用内部字符串意味着如果最终字符串适合初始缓冲区,则根本不需要复制。
使用链接列表还将将重新分配问题从O(字符数)降低为O(段数)-你的字符串引用列表面临与字符串相同的问题!
因此,在我看来,StringBuilder的实现是正确的选择,针对常见情况进行了优化,并且主要用于意外大的目标缓冲区。我预计,列表实现将首先在非常多的小段上退化,这实际上是StringBuilder试图进行优化的极端场景。
尽管如此,比较这两种想法并确定何时开始使用列表会很有趣。

感谢您回答了两个问题。关于字符串长度,这是一个非常好的观点。长+内部化的字符串由于链表开销会比短+内部化的字符串具有显着优势。我提出的问题使用了许多短字符串。也许对StringBuilder的优化是在长度大于X的字符串内部执行string[],而对于小于X的字符串则执行string.Concat。这似乎接近文本编辑器所需的Rope。 - Colin Burnett

2
根据我的经验,在处理大量字符串数据时,适当分配StringBuilder的性能比其他方式要好。即使通过高估20%或30%来防止重新分配,也值得浪费一些内存。我目前没有自己的数据来支持这一点,但可以看看此页面然而,正如Jeff所指出的那样,请不要过早地进行优化! 编辑:正如@Colin Burnett指出的那样,Jeff进行的测试与Brian的测试不一致,但链接Jeff的文章的重点是关于过早优化的问题。Jeff页面上的几位评论者注意到了他的测试问题。

这两个链接都只是比较String和StringBuilder的性能。我不确定我能推断出其他四种解决方案的性能。我觉得有点可疑的是,Brian(第一个链接)和Jeff(第二个链接)在100,000次迭代中得到了极为不同的结果。Brian的时间是189秒,而Jeff的时间是0.606 vs. .588。它们根本不可比。 - Colin Burnett
如果你读了Jeff的帖子的一些评论,他们会责备他没有测试正确的东西。"Steve H"展示了与Brian的结果相符的测试。但是,Jeff的观点仍然存在。除非你必须这样做,否则不要担心它。 - Bob King
好的,但是你能否在回答第二个涉及文件的问题时添加一些内容? - Colin Burnett

1

实际上,StringBuilder 内部使用了一个 String 实例。在 System 组件中,String 实际上是可变的,这就是为什么可以在其之上构建 StringBuilder 的原因。当创建实例时,通过分配合理的长度,可以使 StringBuilder 更加有效率,从而消除/减少调整大小操作的次数。

字符串池化适用于在编译时可以识别的字符串。因此,如果在执行期间生成了大量字符串,则除非您自己调用字符串池化方法,否则它们将不会被池化。

只有当字符串完全相同时,池化才会对您有益处。几乎相同的字符串不会从池化中受益,因此即使它们被池化,"SOMESTRINGA""SOMESTRINGB" 也将是两个不同的字符串。


如果我的对象层次结构中有一个表名为“Foo”(已经interned),然后将其添加到List<string>中,那么这个interned字符串就有两个指针。它们是相同的。对于我所有的“CREATE TABLE [”字符串也是如此。 - Colin Burnett
反射器在mscorlib上显示,String.AppendInPlace从char *复制到char *使用string.wstrcpy()。因此,StringBuilder只是一个char []包装器。因此,字符串是否可变并不重要,因为它仍将为每个附加复制char值,并且不利用任何字符串的内部化。 - Colin Burnett
也许我误解了你。我不认为你会从实习中受益很多。如果你的所有字符串都在代码中,它们将默认进行实习。但是,如果它们不在代码中,它们将不会被实习。同样地,如果你在运行时连接它们,除非你自己这样做,否则新的字符串将不会被实习。 - Brian Rasmussen

1

如果要连接的所有(或大多数)字符串都是内部化的,那么您的方案可能会为您提供性能提升,因为它可能使用更少的内存,并且可以节省一些大型字符串副本。

然而,它是否真正改善了性能取决于您正在处理的数据量,因为改进在常数因子中,而不是算法的数量级。

唯一真正的方法是使用两种方式运行应用程序并测量结果。但是,除非您面临重大的内存压力并需要节省字节,否则我不会费心,而只会使用字符串构建器。


1

StringBuilder 不使用 char[] 来存储数据,而是使用一个内部可变字符串。这意味着在连接一系列字符串时,没有额外的步骤来创建最终字符串,StringBuilder 只需将内部字符串缓冲区作为常规字符串返回。

StringBuilder 为增加容量所做的重新分配意味着数据平均复制了额外的 1.33 次。如果您在创建 StringBuilder 时可以提供良好的大小估计,则可以进一步减少这种情况。

然而,为了有一点透视,您应该看看您试图优化的内容。在程序中,写入数据到磁盘将占用大部分时间,因此即使您可以将字符串处理优化为使用 StringBuilder 的两倍速度(这是非常不可能的),总体差异仍然只有几个百分点。


0
你考虑过用C++吗?是否有一个库类已经构建了T/SQL表达式,最好是用C++编写的。
字符串中最慢的事情就是malloc。在32位平台上,每个字符串需要4KB。考虑优化创建的字符串对象数量。
如果你必须使用C#,我建议使用这样的东西:
string varString1 = tableName;
string varString2 = tableName;

StringBuilder sb1 = new StringBuilder("const expression");
sb1.Append(varString1);

StringBuilder sb2 = new StringBuilder("const expression");
sb2.Append(varString2);

string resultingString = sb1.ToString() + sb2.ToString();

我甚至会让计算机评估使用依赖注入框架进行对象实例化的最佳路径,如果性能非常重要的话。

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