CSharp Stringbuiler
提高 .NET Framework 应用程序的字符串处理性能
James Musson
Developer Services,Microsoft UK
2003年4月
适用于:
Microsoft® .NET Framework®
Microsoft Visual Basic .NET®
Microsoft Visual C#®
摘要:许多 .NET Framework 应用程序都使用字符串连接来构建数据表现形式,例如 XML、HTML 和其他专用格式。本文对使用标准字符串连接及使用由 .NET Framework 专门为构建数据表现形式而提供的 System.Text.StringBuilder 类来创建数据流的情况进行了比较。本文假定您已经具备一定的 .NET Framework 编程知识。
目录
简介
在编写 .NET Framework 应用程序时,开发人员总是需要通过连接一些字符串数据来创建某些字符串数据表现形式。一般是通过重复使用一种连接运算符(“&”或“+”)来实现连接的。在对过去的大量应用程序的性能和可缩放性等特性进行检查时,我们发现:往往只需进行非常少的开发工作,就能够在性能和可缩放性方面得到很高的收益。
字符串连接
请考虑来自 Visual Basic .NET 类的以下代码片断。BuildXml1 函数仅仅采用大量的迭代 (Reps),并使用标准字符串连接来创建带有所需 Order 元素数的 XML 字符串。
' 使用标准连接来构建 XML 字符串 Public Shared Function BuildXml1(ByVal Reps As Int32) As String Dim nRep As Int32 Dim sXml As String For nRep = 1 To Reps sXml &= "<Order orderId=""" _ & nRep _ & """ orderDate=""" _ & DateTime.Now.ToString() _ & """ customerId=""" _ & nRep _ & """ productId=""" _ & nRep _ & """ productDescription=""" _ & "此产品的 Id 是: " _ & nRep _ & """ quantity=""" _ & nRep _ & """/>" Next nRep sXml = "<Orders method=""1"">" & sXml & "</Orders>" Return sXml End Function
等效的 Visual C# 代码如下所示。
// 使用标准连接来构建 XML 字符串 public static String BuildXml1(Int32 Reps) { String sXml = ""; for( Int32 nRep = 1; nRep<=Reps; nRep++ ) { sXml += "<Order orderId=\"" + nRep + "\" orderDate=\"" + DateTime.Now.ToString() + "\" customerId=\"" + nRep + "\" productId=\"" + nRep + "\" productDescription=\"" + "此产品的 Id 是: " + nRep + "\" quantity=\"" + nRep + "\"/>"; } sXml = "<Orders method=\"1\">" + sXml + "</Orders>"; return sXml; } .NET Framework 应用程序和在其他环境中编写的应用程序经常会使用此方法来构建大块的字符串数据。很显然,这里使用的 XML 数据仅仅是一个示例,.NET Framework 还为构建 XML 字符串提供了其他更好的方法,例如 System.Xml.XmlTextWriter。BuildXml1 代码的问题在于,由 .NET Framework 提供的 System.String 数据类型表示的是一个不可改变的字符串。这意味着每当字符串数据更改时,内存中字符串的原始表现形式都将被破坏,而且将创建一个包含新字符串数据的新的字符串表现形式,这会导致分配内存和解除分配内存的操作。当然,这一切都是在后台进行的,因此实际的开销不会立即显现出来。但分配内存和解除分配内存的操作将导致公共语言运行库 (CLR) 中与内存管理和垃圾回收相关的活动增加,因此系统开销非常高。当字符串变大并且大块的内存被连续快速地分配和解除分配时,此问题尤为突出,就象在大型字符串连接过程中出现的情况一样。尽管这一问题对单用户环境的影响不大,但在服务器环境(例如,在 Web 服务器上运行的 ASP.NET® 应用程序)中,它却可能会导致严重的性能和可缩放性问题。
下面,我们回到上述代码片段:此代码中要执行多少个字符串分配操作?答案是 14 个。在这种情况下,“&”(或“+”)运算符的每次应用都会导致变量 sXml 指向的字符串被破坏和重新创建。前面已经提到,字符串分配的开销很大,并且会随着字符串的增大而增加,这就是我们在 .NET Framework 中提供 StringBuilder 类的原因。
什么是 StringBuilder?
有关 StringBuilder 类的概念已经讨论过多次了。在我的上一篇文章改进 ASP 应用程序中的字符串处理性能中曾经介绍过如何使用 Visual Basic 6 来编写 StringBuilder。基本的原则是 StringBuilder 要维护自己的字符串缓冲区。当在 StringBuilder 上执行的操作可能会更改字符串数据的长度时,StringBuilder 首先会检查缓冲区,看其是否能容纳新的字符串数据,如果不能,则把缓冲区大小扩大预设的数量。.NET Framework 提供的 StringBuilder 类还提供了一种有效的 Replace 方法,可用来代替 String.Replace。
图 1 显示了标准连接方法与 StringBuilder 连接方法在内存使用模式方面的比较。请注意,标准连接方法在每次进行连接操作时都会创建一个新字符串,而 StringBuilder 每次使用的都是同一个字符串缓冲区。

图 1:标准连接与 StringBuilder 连接的内存使用模式比较
使用 StringBuilder 类来构建 XML 字符串数据的代码如以下 BuildXml2 所示。
' 使用 StringBuilder 来构建 XML 字符串 Public Shared Function BuildXml2(ByVal Reps As Int32) As String Dim nRep As Int32 Dim oSB As StringBuilder ' 确保 StringBuilder 的容量 ' 足以容纳结果文本 oSB = New StringBuilder(Reps * 165) oSB.Append("<Orders method=""2"">") For nRep = 1 To Reps oSB.Append("<Order orderId=""") oSB.Append(nRep) oSB.Append(""" orderDate=""") oSB.Append(DateTime.Now.ToString()) oSB.Append(""" customerId=""") oSB.Append(nRep) oSB.Append(""" productId=""") oSB.Append(nRep) oSB.Append(""" productDescription=""") oSB.Append("此产品的 Id 是: ") oSB.Append(nRep) oSB.Append(""" quantity=""") oSB.Append(nRep) oSB.Append("""/>") Next nRep oSB.Append("</Orders>") Return oSB.ToString() End Function 等效的 Visual C# 代码如下所示。
// 使用 StringBuilder 来构建 XML 字符串 public static String BuildXml2(Int32 Reps) { // 确保 StringBuilder 的容量 // 足以容纳结果文本 StringBuilder oSB = new StringBuilder(Reps * 165); oSB.Append("<Orders method=\"2\">"); for( Int32 nRep = 1; nRep<=Reps; nRep++ ) { oSB.Append("<Order orderId=\""); oSB.Append(nRep); oSB.Append("\" orderDate=\""); oSB.Append(DateTime.Now.ToString()); oSB.Append("\" customerId=\""); oSB.Append(nRep); oSB.Append("\" productId=\""); oSB.Append(nRep); oSB.Append("\" productDescription=\""); oSB.Append("此产品的 Id 是: "); oSB.Append(nRep); oSB.Append("\" quantity=\""); oSB.Append(nRep); oSB.Append("\"/>"); } oSB.Append("</Orders>"); return oSB.ToString(); } 执行 StringBuilder 方法与执行标准连接方法的差别取决于很多因素,包括连接的数量、所构建的字符串的大小以及 StringBuilder 缓冲区的初始化参数的选择情况。请注意,在多数情况下,将缓冲区所需的空间量估计得略高一些要远远优于让其不断增长。
创建测试程序
我决定使用 Application Center Test® (ACT) 来测试这两种字符串连接方法,这意味着要由 ASP.NET Web 应用程序来提供这两种方法。因为不想在结果中显示为每个请求建立 ASP.NET 页时所涉及到的处理,我创建并注册了一个 HttpHandler,它接受我的逻辑 URL (StringBuilderTest.jemx) 请求,并调用相关的 BuildXml 函数。 尽管对 HttpHandler 的深入探讨超出了本文的范围,我还是在下面提供了测试所使用的代码。
Public Class StringBuilderTestHandler Implements IHttpHandler Public Sub ProcessRequest(ByVal context As HttpContext) _ Implements IHttpHandler.ProcessRequest Dim nMethod As Int32 Dim nReps As Int32 ' 从 QueryString 中检索测试参数 If Not context.Request.QueryString("method") Is Nothing Then nMethod = Int32.Parse( _ context.Request.QueryString("method").ToString()) Else nMethod = 0 End If If Not context.Request.QueryString("reps") Is Nothing Then nReps = Int32.Parse( _ context.Request.QueryString("reps").ToString()) Else nReps = 0 End If context.Response.ContentType = "text/xml" context.Response.Write( _ "<?xml version=""1.0"" encoding=""utf-8"" ?>") ' 将 XML 写入响应流 Select Case nMethod Case 1 context.Response.Write( _ StringBuilderTest.BuildXml1(nReps)) Case 2 context.Response.Write( _ StringBuilderTest.BuildXml2(nReps)) End Select End Sub Public ReadOnly Property IsReusable() As Boolean _ Implements IHttpHandler.IsReusable Get Return True End Get End Property End Class 等效的 Visual C# 代码如下所示。
public class StringBuilderTestHandler : IHttpHandler { public void ProcessRequest(HttpContext context) { Int32 nMethod = 0; Int32 nReps = 0; // 从 QueryString 中检索测试参数 if( context.Request.QueryString["method"]!=null ) nMethod = Int32.Parse( context.Request.QueryString["method"].ToString()); if( context.Request.QueryString["reps"]!=null ) nReps = Int32.Parse( context.Request.QueryString["reps"].ToString()); // 将 XML 写入响应流 context.Response.ContentType = "text/xml"; context.Response.Write( "<?xml version=\"1.0\" encoding=\"utf-8\" ?>"); switch( nMethod ) { case 1 : context.Response.Write( StringBuilderTest.BuildXml1(nReps)); break; case 2 : context.Response.Write( StringBuilderTest.BuildXml2(nReps)); break; } } public Boolean IsReusable { get{ return true; } } } ASP.NET HttpPipeline 创建了一个 StringBuilderTestHandler 实例,并对 StringBuilderTest.jemx 的每个 HTTP 请求调用 ProcessRequest 方法。ProcessRequest 仅仅从查询字符串中提取了几个参数,并选择了正确的 BuildXml 函数来进行调用。在创建了一些标头信息后,BuildXml 函数的返回值被传回 Response 流。
有关 HttpHandler 的详细信息,请参阅 IHttpHandler 文档(英文)。
测试
我们使用 ACT 从单个客户端(Windows® XP Professional,PIII-850MHz,512MB RAM)针对 100 mbit/sec 网络中的单个服务器(Windows Server 2003 Enterprise Edition,双 PIII-1000MHz,512MB RAM)执行了测试。ACT 配置为使用 5 个线程,以模拟 5 个用户连接至网站时的负载。每个测试包括 10 秒的预热期和 50 秒的负载期,在负载期内创建了尽可能多的请求。
我们通过更改主循环中的迭代次数(如 BuildXml 函数中的代码片断所示),对不同数量的连接操作反复进行了测试。
结果
下面的一系列图表显示了每种方法对整个应用程序吞吐量的影响,同时还显示了 XML 数据流返回到客户端的响应时间。通过这些图表,我们可以了解应用程序能够处理多少请求,以及用户或客户端应用程序要经过多长时间才能接收到数据。
表 1:使用的连接方法缩写的说明
| 方法缩写 | 说明 |
| CAT | 标准字符串连接方法 (BuildXml1) |
| BLDR | StringBuilder 方法 (BuildXml2) |
在模拟典型应用程序的工作量方面,此测试与实际情况相差甚远,从表 2 可以很明显地看出,即使在重复 425 次的情况下,XML 数据字符串都不是很大,而很多应用程序的数据传输平均大小都会很大。
表 2:测试示例的 XML 字符串大小和连接次数
| 迭代次数 | 连接次数 | XML 字符串大小(字节) |
| 25 | 350 | 3,897 |
| 75 | 1,050 | 11,647 |
| 125 | 1,750 | 19,527 |
| 175 | 2,450 | 27,527 |
| 225 | 3,150 | 35,527 |
| 275 | 3,850 | 43,527 |
| 325 | 4,550 | 51,527 |
| 375 | 5,250 | 59,527 |
| 425 | 5,950 | 67,527 |

图 2:吞吐量结果图

图 3:响应时间结果图
从图 2 和图 3 中可以清楚地看到,StringBuilder 方法 (BLDR) 不论是在处理请求的数量上,还是在生成返回给客户端的响应所需的时间上(在图上用“到第一个字节的时间”或 TTFB 表示),都要优于标准连接 (CAT) 方法。在 425 次迭代时,StringBuilder 方法处理的请求数比标准连接方法多 17 倍,而每个请求的运行时间仅仅是标准连接的 3%。

表 4:测试期间系统状况指示图
图 4 给出了测试期间的服务器负载情况。从图中我们可以看到一个很有趣的现象,即 StringBuilder 方法 (BLDR) 不仅在各个阶段都优于标准连接方法 (CAT),而且它在内存回收时使用的 CPU 及花费的时间也远远少于标准连接方法。虽然这并不能从实际上证明在 StringBuilder 操作过程中,服务器上的资源使用得更加有效,但它确实是一项强有力的证据。
小结
从这些测试结果中得到的结论非常明确。除非是最无关紧要的字符串连接(或替换)操作,否则都应该使用 StringBuilder 类。与在性能和可缩放性方面可能获得的好处相比,使用 StringBuilder 类所需的额外投入完全可以忽略不计。
浙公网安备 33010602011771号