Introduction
你在代码中处理字符串的方法可能会对性能产生令人吃惊的影响。在本文中,我需要考虑两个由于使用字符串而产生的问题:临时字符串变量的使用和字符串连接。
Background
每个项目都有需要你为其考虑编码标准的时候。使用 FxCop 是一个好的开始。我最喜爱的一组 FxCop 规则是“性能”那组。
于是,我就用 FxCop 来检查我的项目并发现一系列的字符串问题。我必须承认一件事:我经常遇到与 C# 的不可变(immutable)的字符串有关的问题。当我看到 myString.ToUpper() 时,我经常都会忘记它并不是改变 myString 的内容而是返回一整个全新的字符串(这是由于 C# 中字符串是不可变的)。
我对代码进行一番修正以便去掉 FxCop 的警告,接着我就发现代码的确比之前快了。我决定开展调查,而最终我会写出上面那些测试的代码的。
Using the code
测试的代码很简单。一个控制台程序调用四个测试方法,其中每个方法执行一种字符串处理例程 1000 次(整个执行时间已经足够长以便看出其中的性能差别了)。
这四个测试方法被分成两组,每组两个。第一组比较两个方法,它们用于非大小写敏感(case-insensitive)的字符串比较。
String Comparison and Temporary String Creation
第一个测试例程是一个蹩脚的非大小写敏感的字符串比较。用于比较的例程的代码是:
static bool BadCompare(string stringA, string stringB){
return (stringA.ToUpper() == stringB.ToUpper(),',',');
}
对于这段代码,FxCop 给出如下的建议:
"StringCompareTest.BadCompare(String, String):Boolean calls String.op_Equality(String, String):Boolean after converting 'stack1', a local, to upper or lowercase. If possible, eliminate the string creation and call the overload of String.Compare that performs a case-insensitive comparison."
这项建议的意思是每次对 ToUpper() 的调用都会创造一个临时字符串,而这个临时字符串是由垃圾收集器来创建和管理的。这需要额外的时间和使用更多的内存。 String.Compare 方法(相对来说)更加高效。
第二个测试例程使用 String.Compare :
static bool GoodCompare( string stringA, string stringB){
return (string.Compare(stringA, stringB, true , System.Globalization.CultureInfo.CurrentCulture) == 0,',',');
}
这个方法防止多余的临时字符串的创建。
根据 nprof 的分析结果 , GoodCompare 的执行时间只占代码总执行时间的 1.69%, 而 BadCompare 的执行时间则占总执行时间的 5.50% 。
因此 String.Compare 方法比 ToUpper 方法快了三倍有余。如果你的代码您执行了很多字符串的比较(尤其是在循环里面执行),使用 String.Compare 能(使你的代码在性能上)有较大的改善。
String Concatenation inside a loop
最后那对测试例程设想字符串的连接是在一个循环里面进行的。
“蹩脚”的测试例程的代码如下:
static string BadConcatenate(string [] items){
string strRet = string .Empty;
foreach (string item in items)
{
strRet += item;
}
return strRet;
}
当 FxCop 看到这段代码,它就会很愤怒,甚至用红色标记这项被破的规条! FxCop 这样说道:
"Change StringCompareTest.BadConcatenate(String[]):String to use StringBuilder instead of String.Concat or +="
“优良”的测试例程的代码如下:
static string GoodConcatenate(string [] items){
System.Text.StringBuilder builder = new System.Text.StringBuilder(,',',');
foreach (string item in items)
{
builder.Append(item,',',');
}
return builder.ToString(,',',');
}
这段代码几乎被用作展示 System.Text.StringBuilder 的用法的首选例子。蹩脚的代码的问题是创建了过多的临时字符串。由于字符串的不可变特性,连接操作符(+=)实际上用原来那两个字符串来创建一个新的字符串,然后把原来的字符串实例指向这个新的字符串。
但是,依据 nprof 来研究代码性能,我们发现运行 BadConcatenate 只需总执行时间的 5.67% ,而 GoodConcatenate 则是 22.09% 。也就是说:
使用 StringBuilder 耗费的时间几乎是简单的字符串连接的四倍!
为什么呢?
部分原因在于这个测试的设计——连接例程仅仅连接了十个简短的字符串。 StringBuilder 是一个比简单的不可变的字符串类更复杂的类,因此创建一个 StringBuilder 比起进行十个简单的字符串连接在性能上是昂贵很多的。
我重复地做不同数目的字符串连接的测试,并且发现以下结果:
注意:这里所显示的数值是测试例程的执行时间占总执行时间的百分比(%)。 GoodConcatenate 实际上并没有快很多,但与 BadConcatenate 比却相对地快了。
因此, StringBuilder 通常只有在你要连接的字符串数目超过 600 时才会显示出真正的性能优势。
当然,另外一个使用 StringBuilder 的原因就是是内存的分配。使用 CLRProfiler 生成下面这个连接 100 个简单字符串时内存使用情况的时序图:
标记为“A”的区域显示了 BadConcatenate 在内存分配和释放上的效果。被分配内存的最大值迅速增加,并伴有大数量的垃圾收集的发生(该区域有大约 215 次垃圾收集)。
紧随在“A”区后面的区域显示了 GoodConcatenate 的内存轮廓。被分配内存的最大值增量较少,且伴随着非常少的垃圾收集(该区域有大致 60 次垃圾收集)。
所以在某些情况下使用 StringBuilder 类并不会(使你的代码运行得)更快 , 但它对垃圾收集器是友好的。
Conclusions
使用 String.Compare 方法进行非大小写敏感的字符串比较。这样更快。而且代码优雅和简单。
仅当你在一个循环里进行超过 600 次的字符串连接时,使用 StringBuilder 来获得更好的速度。这里需要提醒的是,你所处理的字符串的长度也会影响最终的速度,同样会影响垃圾收集器的效果,所以你应该根据你实际的代码具体问题具体分析。
Points of Interest
令我惊讶的是,在真实世界运用正确的代码字符串操作方法的还是很不同(虽然我们已在当前的项目中进行了很多字符串的比较和连接)。
FxCop 的性能规则是发现潜在低性能代码的好起点,并能指导你进行一些简易修正来改善代码性能。这里所讨论的两个问题都被 FxCop 标记为“NON-BREAKING”,这是指改动不应破坏依赖于被改动代码的代码。认为为改善性能而做的改动都是“NON-BREAKING”则是没头脑的想法。
Further Considerations By Allen Lee
使用 StringBuilder 来处理字符串的连接应该是绝大多数 .NET 开发人员的共识了。但你有否曾经怀疑过这一经验原则的适用性是否真如想象中那么广泛呢?读过本文后,或许你已经意识到这是个适度的问题。对小规模的字符串连接使用 StringBuilder 所带来的改善根本不足以抵偿因 StringBuilder 本身的复杂性所产生的开销;只有当连接规模达到临界规模,两者才能相互抵偿从而达至平衡。
对于实际的代码,一个可供使用的临界规模值可能是必需的,尤其是在受限系统上进行开发。你可能因为对影响临界规模的因素有所了解而怀疑作者在这里所给出的数字。或许本文用于测试的设计显得有点简单以至于未必能使更多的人信服,但你的确透过本文了解到 StringBuilder 并不是任何情况都适用的。由于影响临界规模的因素总有可能发生变化,你不可能找到一个对任何情况都适用的确定的临界规模值。你应该为你的代码量身订造一个,并随时做好调整的准备(因为变化总是存在的),只要你真的那么在意这方面的性能影响。作为一个开始,你可以以作者在本文所提到的那个数字作为一个参照基础,并就具体的情况进行微调,直到你满意为止。