.Net 4.0重构了StringBuilder的实现,采用了新的数据存储方式,不仅在效率上有大的提高,并且彻底避免了中间处理过程出现临时String对象进入LOH大对象堆的情况。本文对此进行分析。
回顾.Net 2.0的StringBuilder实现
Reflector查看StringBuilder的实现:
其内部数据存储结构为string(对应成员变量m_StringValue)。StringBuilder的构造函数可以依据指定的字符串和容量来初始化,默认为空串(string.Empty),默认容量为16。构造函数使用string的GetStringForStringBuilder方法对m_StringValue变量赋值:
查看GetStringForStringBuilder的实现,m_StringValue被视为char数组,其大小由capacity容量决定,该值可以大于实际存储的string对象的大小:
在Append等方法中,若发现当前字符数组已不能满足存储要求,则调用GetNewString方法扩展,否则用wstrcpy拷贝字符到存储区域:
GetNewString采用容量翻倍的方式扩展字符数组,如果发现翻倍的扩展不能满足要求,则直接设置为requiredLength大小:
由以上分析可知,容量增长并非一定严格以16为基数增长,例如16、32、64、128,某次扩展可能导致基数发生变化。例如,假设用字符串“ABCDE”初始化一个StringBuilder,则字符数组容量为16,目前使用了其中的5个位置。随后Append一个长度为40的字符串,由于翻倍增长到32仍不能满足要求,将调整capacity=45,容量增长序列将变为16、45、90、180。
.Net 2.0 StringBuilder的容量扩展有几个缺陷:
1. 每次容量扩展都会生成一个新的string对象,其内容来自于复制老的string对象。之后,老的string对象就被废弃掉。在此过程中,若string对象大于85k(严格讲应该是85k / 2,因为是unicode字符),将会进入LOH大对象堆
2. 随容量的增长,例如大于1M,在LOH大对象堆中寻找连续的内存空间会变得困难,尤其若LOH碎片化严重时
3. 存在额外的拷贝消耗,会有轻微的性能损失
4. 要处理线程安全问题,包括m_StringValue本身就是被定义为volatile,也会有轻微的性能损失
可见,.Net 2.0实现的StringBuilder,内部的存储结构就是一个简单的string对象。当内部的string对象其容量不足以容纳新的Append数据时,就需要扩展容量,扩展方式为容量加倍。在容量扩展后,老的string对象及新Append的数据要拷贝到新的内存区域。这个过程中,会生成新的string对象及废弃老的string对象。如果string对象的尺寸已经大于85k,那么每次容量扩展就会有两个string对象进入到LOH中。本质上,相对于直接用“+”方式连接两个string对象,StringBuilder只是大幅降低了临时string对象的生成频率,而并不能彻底规避临时string对象的生成。
.Net 4.0新的StringBuilder实现
.Net 4.0实现StringBuilder的方式非常精彩:
存储结构不再是string对象,而明确为字符数组(对应成员变量m_ChunkChars)。每个StringBuilder字符数组的最大大小由Max_ChunkSize限定,取值为8000(对应16进制的0x1F40)。如果需要存储的数据超过8000,怎么处理呢?奥秘就在m_ChunkPrevious成员变量上,每个StringBuilder内部会维护一个指向StringBuilder实例的指针,由此构成一个StringBuilder链表。即一个逻辑上的存储,实际是被链表上所有StringBuilder实例共同分担的。
构造函数初始化m_ChunkChars的代码如下,默认字符数组大小仍为16:
容量扩展由如下方法完成:
在容量扩展时,让m_ChunkPrevious指向自己!很精彩的实现,避免了.Net 2.0时的Copy开销,仅仅就是一个指针的赋值:
而Math.Max计算决定了新字符数组的分配,其大小被控制在8000以内,从而确保不会进入LOH大对象堆。
总体看,.Net 2.0主要的实现缺陷都被优化掉了。当然,由于链表方式的引入,部分方法的处理变得复杂。例如:
不过实现同样精彩,并不需要从头到尾开始复制字符,从尾到头是高效的做法。
总结
.Net 4.0的StringBuilder实现,将原来单一String对象的存储方式,优化为一系列的内存块(Memory Chunk)。每个StringBuilder包含一个内存块,通过引用其它StringBuilder对象的方式,形成了一个内存块的链表。通过限定每个内存块的最大大小,确保StringBuilder在容量扩展过程中不会有大对象的产生。从实现算法看,最常用的Append方法效率较.Net 2.0要高,ToString大致等效,而Insert方法效率较.Net 2.0会下降。总体看,实现相当精彩。
个人认为,StringBuilder默认容量沿用16实在太小了,常常导致最初需要多次容量扩张。如果16的容量就能满足要求,那实际上就不会用StringBuilder,而直接用String+了。个人认为512或1024是较为合适的选择。