C# StringBuilder源码分析
在 .NET 中,StringBuilder
是一个用于高效构建字符串的重要类。它通过避免频繁创建新字符串对象,从而优化了性能。但其背后的实现机制却并不简单。
一、核心字段与属性解析
StringBuilder
内部使用了字符数组(char[]
)来存储字符串数据,并通过链表的方式管理多个“块”(Chunk),以提升拼接效率。
主要字段:
internal char[] m_ChunkChars; // 当前块的字符数组
internal int m_ChunkLength; // 当前块中已使用的字符数
internal int m_ChunkOffset; // 当前块在整个字符串中的起始位置
internal int m_MaxCapacity; // 最大容量,默认为 int.MaxValue
internal const int DefaultCapacity = 16; // 默认初始容量
internal const int MaxChunkSize = 8000; // 单个 Chunk 的最大长度
Length 属性:
public int Length
{get => m_ChunkOffset + m_ChunkLength;
}
表示当前整个字符串的总长度。
二、构造函数分析
1. 默认构造函数:
public StringBuilder()
{m_MaxCapacity = int.MaxValue;m_ChunkChars = new char[DefaultCapacity]; // 初始大小为16
}
默认分配一个长度为 16 的字符数组。
2. 带字符串参数的构造函数:
public StringBuilder(string value, int startIndex, int length, int capacity)
{...m_ChunkChars = GC.AllocateUninitializedArray<char>(capacity);...
}
根据传入字符串的长度和指定容量,选择较大的值作为初始容量,避免多次扩容。
3. 复制构造函数(用于链表节点创建):
private StringBuilder(StringBuilder from)
{m_ChunkLength = from.m_ChunkLength;m_ChunkOffset = from.m_ChunkOffset;m_ChunkChars = from.m_ChunkChars;m_ChunkPrevious = from.m_ChunkPrevious;...
}
这个构造函数用于创建新的 Chunk 节点,是链表结构的关键。
三、Append 方法的工作原理
以 Append(char value, int repeatCount)
为例来看 StringBuilder
如何处理追加操作:
public StringBuilder Append(char value, int repeatCount)
{//省略边界检查代码int index = m_ChunkLength;while (repeatCount > 0){if (index < m_ChunkChars.Length){m_ChunkChars[index++] = value;--repeatCount;}else{m_ChunkLength = index;ExpandByABlock(repeatCount); // 扩容并创建新 ChunkDebug.Assert(m_ChunkLength == 0);index = 0;}}m_ChunkLength = index;return this;
}
核心逻辑:
- 如果当前字符数组还有空间,则直接插入字符。
- 如果空间不足,调用
ExpandByABlock()
创建新 Chunk,并将其链接到当前 Chunk 的前面。
四、ExpandByABlock 方法详解
该方法负责创建新的 Chunk 并更新当前 Chunk 的状态:
private void ExpandByABlock(int minBlockCharCount)
{int newBlockLength = Math.Max(minBlockCharCount, Math.Min(Length, MaxChunkSize));char[] chunkChars = GC.AllocateUninitializedArray<char>(newBlockLength);m_ChunkPrevious = new StringBuilder(this); // 创建前驱节点m_ChunkOffset += m_ChunkLength;m_ChunkLength = 0;m_ChunkChars = chunkChars;
}
关键步骤:
- 计算新 Chunk 的大小:不超过
MaxChunkSize
(默认 8000),也不小于所需字符数。 - 分配新内存。
- 创建前驱节点:将当前 Chunk 封装成一个新的
StringBuilder
实例,并赋值给m_ChunkPrevious
。 - 更新偏移量和长度:当前 Chunk 清空,准备写入新数据。
- 切换字符数组:将新分配的数组设为当前 Chunk 使用。
五、图解
1、初始状态:默认构造函数创建 StringBuilder
var sb = new StringBuilder();
内部结构:
sb (current)
│
├── m_ChunkChars: [ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ ] ← 默认大小为16
├── m_ChunkLength: 0
├── m_ChunkOffset: 0
└── m_ChunkPrevious: null
此时没有数据,只是一个空的字符数组。
2、第一次 Append 操作:添加 “HELLO”
sb.Append("HELLO");
内部结构:
sb (current)
│
├── m_ChunkChars: [ H E L L O _ _ _ _ _ _ _ _ _ _ _ ]
├── m_ChunkLength: 5
├── m_ChunkOffset: 0
└── m_ChunkPrevious: null
此时字符数组还有 11 个空间可用。
3、继续 Append:添加 “WORLD”
sb.Append("WORLD");
字符串长度为 5,刚好可以放入剩余空间中。
内部结构:
sb (current)
│
├── m_ChunkChars: [ H E L L O W O R L D _ _ _ _ _ _ _ ]
├── m_ChunkLength: 10
├── m_ChunkOffset: 0
└── m_ChunkPrevious: null
此时字符数组还剩 6 个空位。
4、扩容触发:再次 Append 超出当前容量
sb.Append("THIS_IS_A_LONG_STRING"); // 长度超过剩余空间
此时字符数组只剩 6 个位置,但要插入的字符串长度为 20,因此需要扩容。
步骤解析:
-
更新当前 Chunk 的状态:
- 将
m_ChunkLength
设置为当前已使用的长度(10)。 - 记录当前 Chunk 的偏移量为
m_ChunkOffset + m_ChunkLength = 0 + 10 = 10
。 - 清空当前 Chunk 的使用长度(设为 0)。
- 将
-
创建新 Chunk 并设置为前驱节点:
- 创建一个新的
StringBuilder
实例,其字符数组大小根据所需内容和最大块大小(8000)决定。 - 将当前 Chunk 的引用赋值给新 Chunk 的
m_ChunkPrevious
。
- 创建一个新的
-
切换当前 Chunk 到新分配的数组。
扩容后结构:
newChunk (current)
│
├── m_ChunkChars: [ T H I S _ I S _ A _ L O N G _ S T R I N G ... ] (假设新块大小为 32)
├── m_ChunkLength: 20
├── m_ChunkOffset: 10
└── m_ChunkPrevious: ↓
oldChunk
│
├── m_ChunkChars: [ H E L L O W O R L D _ _ _ _ _ _ _ ]
├── m_ChunkLength: 10
├── m_ChunkOffset: 0
└── m_ChunkPrevious: null
注意:此时
sb
变量指向的是newChunk
,也就是最后一个 Chunk。
5、继续 Append 更多内容
每次扩容都会在链表头部插入新的 Chunk,并保留对旧 Chunk 的引用。
最终形成一个“逆向链表”结构:
sb (current) → newChunk3 → newChunk2 → oldChunk → null
每个 Chunk 包含一部分字符串内容,且可以通过 m_ChunkPrevious
向前追溯完整字符串。
六、为什么使用逆向链表
每个 StringBuilder
对象维护一个指向“前一个节点”的引用 (m_ChunkPrevious
),而不是常见的“后一个节点”。
这样做的好处:
- 尾部追加操作更高效:由于用户总是从最后一个 Chunk 添加数据,采用“逆向链表”可以快速定位到最后一个节点,无需遍历整个链表。
- 时间复杂度为 O(1):每次添加新 Chunk 都是在当前节点的基础上创建前驱节点,无需查找最后一个节点。
相比之下,如果使用正向链表(每个节点保存下一个节点引用),则每次添加都需要遍历到末尾,时间复杂度为 O(n),性能下降明显。
七、链表结构带来的代价
虽然链表提升了追加效率,但也带来了一些缺点:
- 无法随机访问:不能像数组一样直接通过索引访问某个字符。
- 读取效率较低:若需要从中间或开头插入数据,需遍历整个链表,效率不如单一数组。
因此,StringBuilder
更适合尾部拼接的场景,而不适合频繁的随机修改。
八、最常用的Append方法
StringBuilder.Append(string value)
是最常用的方法
Append(string? value)
方法
/// <summary>
/// 将一个字符串追加到当前 StringBuilder 的末尾。
/// </summary>
/// <param name="value">要追加的字符串(可以为 null)。</param>
/// <returns>当前的 StringBuilder 实例,以支持链式调用。</returns>
public StringBuilder Append(string? value)
{// 如果传入的字符串为 null,则直接返回,不进行任何操作if (value != null){// 获取当前 chunk 的字符数组和已使用的长度char[] chunkChars = m_ChunkChars;int chunkLength = m_ChunkLength;int valueLen = value.Length;// 判断当前 chunk 是否还有足够的空间容纳要追加的字符串// 使用 uint 类型进行加法溢出检查,确保不会超出数组长度if (((uint)chunkLength + (uint)valueLen) < (uint)chunkChars.Length){// 如果要追加的字符串长度较小(最多两个字符),则直接逐个字符复制if (valueLen <= 2){if (valueLen > 0){// 追加第一个字符chunkChars[chunkLength] = value[0];}if (valueLen > 1){// 追加第二个字符chunkChars[chunkLength + 1] = value[1];}}else{// 如果要追加的字符串较长,使用高效的固定指针方式复制字符unsafe{// 固定字符串和当前 chunk 的内存地址fixed (char* valuePtr = value)fixed (char* destPtr = &chunkChars[chunkLength]){// 使用内部高效复制方法将字符复制到当前 chunk 中string.wstrcpy(destPtr, valuePtr, valueLen);}}}// 更新当前 chunk 已使用的字符数m_ChunkLength = chunkLength + valueLen;}else{// 当前 chunk 空间不足,调用 AppendHelper 方法进行扩容和追加AppendHelper(value);}}return this;
}
AppendHelper(string value)
方法
/// <summary>
/// 用于处理当前 chunk 空间不足时的追加操作。
/// 会分配新的 chunk 并将字符串内容复制进去。
/// </summary>
/// <param name="value">要追加的字符串(非 null)。</param>
private void AppendHelper(string value)
{unsafe{// 固定字符串的内存地址以便进行指针操作fixed (char* valueChars = value){// 调用 Append(char*, int) 方法进行实际的追加操作Append(valueChars, value.Length);}}
}
Append(char* value, int valueCount)
方法
/// <summary>
/// 将一个字符指针指向的缓冲区内容追加到当前 StringBuilder 的末尾。
/// </summary>
/// <param name="value">指向要追加的字符缓冲区的指针。</param>
/// <param name="valueCount">要追加的字符数量。</param>
/// <returns>当前的 StringBuilder 实例,以支持链式调用。</returns>
/// <exception cref="ArgumentOutOfRangeException">当 valueCount 为负数或超过最大容量时抛出。</exception>
[CLSCompliant(false)]
public unsafe StringBuilder Append(char* value, int valueCount)
{// 不检查 value 是否为 null,因为访问 null 指针会自动抛出 NullReferenceException// 检查 valueCount 是否为负数,防止非法参数if (valueCount < 0){throw new ArgumentOutOfRangeException(nameof(valueCount),SR.ArgumentOutOfRange_NegativeCount);}// 计算新字符串的总长度(当前长度 + 要追加的字符数)int newLength = Length + valueCount;// 检查是否超过最大容量(m_MaxCapacity)或发生整数溢出if (newLength > m_MaxCapacity || newLength < valueCount){throw new ArgumentOutOfRangeException(nameof(valueCount),SR.ArgumentOutOfRange_LengthGreaterThanCapacity);}// 尝试将数据直接追加到当前 chunk 的剩余空间中int newIndex = valueCount + m_ChunkLength;// 如果当前 chunk 有足够的空间容纳所有数据,直接复制if (newIndex <= m_ChunkChars.Length){ThreadSafeCopy(value, m_ChunkChars, m_ChunkLength, valueCount);m_ChunkLength = newIndex; // 更新当前 chunk 使用的字符数}else{// 当前 chunk 空间不足,只能先复制一部分// 计算当前 chunk 中剩余可用空间int firstLength = m_ChunkChars.Length - m_ChunkLength;if (firstLength > 0){// 复制第一部分到当前 chunkThreadSafeCopy(value, m_ChunkChars, m_ChunkLength, firstLength);m_ChunkLength = m_ChunkChars.Length; // 当前 chunk 已满}// 剩余要复制的字符数int restLength = valueCount - firstLength;// 扩展 StringBuilder,添加一个新的 chunkExpandByABlock(restLength);Debug.Assert(m_ChunkLength == 0, "添加新 chunk 后,chunk 的长度应为 0,表示空块");// 将剩余部分复制到新的 chunk 中ThreadSafeCopy(value + firstLength, m_ChunkChars, 0, restLength);m_ChunkLength = restLength; // 更新新 chunk 的使用长度}// 验证内部状态是否一致(调试用)AssertInvariants();return this;
}
ExpandByABlock(int minBlockCharCount)
方法
/// <summary>
/// 将当前 chunk 的字符转移到一个新的 chunk 中,并为当前 chunk 分配一个新的字符缓冲区。
/// 这是扩容机制的一部分,用于支持 StringBuilder 的动态增长。
/// </summary>
/// <param name="minBlockCharCount">新 chunk 缓冲区的最小容量。</param>
/// <remarks>
/// - 此方法要求当前 chunk 已满(即 m_ChunkLength == m_ChunkChars.Length)。
/// - 假设当前 chunk 是链表中的最后一个 chunk。
/// </remarks>
private void ExpandByABlock(int minBlockCharCount)
{// 调试断言:确保当前 chunk 已满(容量 == 长度)Debug.Assert(Capacity == Length, nameof(ExpandByABlock) + " should only be called when there is no space left.");// 调试断言:确保请求的最小容量是正数Debug.Assert(minBlockCharCount > 0);// 验证当前 chunk 的状态是否合法(调试用)AssertInvariants();// 检查扩容后是否会超过最大容量或发生整数溢出if ((minBlockCharCount + Length) > m_MaxCapacity || minBlockCharCount + Length < minBlockCharCount){throw new ArgumentOutOfRangeException("requiredLength", SR.ArgumentOutOfRange_SmallCapacity);}// 计算新 chunk 的容量:// - 至少为 minBlockCharCount(满足当前需求)// - 如果当前字符串长度较小,则取当前长度,实现“容量翻倍”策略// - 但不能超过 MaxChunkSize(防止分配过大内存块)int newBlockLength = Math.Max(minBlockCharCount,Math.Min(Length, MaxChunkSize));// 检查是否会溢出 int.MaxValue(逻辑总长度)if (m_ChunkOffset + m_ChunkLength + newBlockLength < newBlockLength){throw new OutOfMemoryException();}// 提前分配新的字符数组,避免在分配失败时留下不一致的状态char[] chunkChars = GC.AllocateUninitializedArray<char>(newBlockLength);// 创建一个新的 StringBuilder 实例(新 chunk),并将当前 chunk 设置为其前一个 chunkm_ChunkPrevious = new StringBuilder(this);// 更新当前 chunk 的偏移量:当前 chunk 的字符总数 + 前面所有 chunk 的字符总数m_ChunkOffset += m_ChunkLength;// 当前 chunk 已经“转移”了所有字符,因此长度清零m_ChunkLength = 0;// 将当前 chunk 的字符数组指向新分配的缓冲区m_ChunkChars = chunkChars;// 再次验证当前 chunk 的状态是否合法(调试用)AssertInvariants();
}
ExpandByABlock
方法是 StringBuilder
扩容机制的核心部分之一,原理和Append(char value, int repeatCount)
类似:
- 它会 创建一个新的 chunk,并将当前 chunk 的数据“转移”过去。
- 然后将当前 chunk 清空,指向一个新分配的缓冲区,准备接收新数据。
- 通过这种方式,
StringBuilder
可以高效地 链式增长,而不会频繁复制整个字符串。
九、ToString方法详解
public override string ToString()
{// 断言检查:确保当前 StringBuilder 的内部状态是合法的(调试时使用)AssertInvariants();// 如果总长度为 0,直接返回空字符串,避免不必要的操作if (Length == 0){return string.Empty;}// 快速分配一个指定长度的字符串,用于最终结果// string.FastAllocateString 是内部方法,用于高效分配字符串空间string result = string.FastAllocateString(Length);// 使用 unsafe 代码块,允许使用指针进行高效内存操作unsafe{// 将结果字符串的指针固定,防止在 GC 中被移动(否则指针会失效)fixed (char* destinationPtr = result){// 从当前 chunk 开始,沿着链表向前遍历所有 chunksStringBuilder? chunk = this;// 循环处理每一个 chunkdo{// 只有当前 chunk 中有数据才进行复制if (chunk.m_ChunkLength > 0){// 将 chunk 中的字段复制到局部变量中// 这样可以避免在多线程环境下字段被修改导致的数据不一致问题char[] sourceArray = chunk.m_ChunkChars;int chunkOffset = chunk.m_ChunkOffset; // 当前 chunk 在整个字符串中的起始位置int chunkLength = chunk.m_ChunkLength; // 当前 chunk 中实际存储的字符数// 边界检查:确保不会越界访问源数组和目标字符串if ((uint)(chunkLength + chunkOffset) <= (uint)result.Length &&(uint)chunkLength <= (uint)sourceArray.Length){// 将源字符数组的指针固定fixed (char* sourcePtr = &sourceArray[0]){// 将当前 chunk 的字符复制到最终字符串的对应位置// destinationPtr + chunkOffset 表示目标字符串中的偏移位置// sourcePtr 是源字符数组的起始地址// chunkLength 是要复制的字符数量string.wstrcpy(destinationPtr + chunkOffset, sourcePtr, chunkLength);}}else{// 如果越界,抛出异常throw new ArgumentOutOfRangeException(nameof(chunkLength), SR.ArgumentOutOfRange_Index);}}// 移动到前一个 chunk,继续处理链表中的其他部分chunk = chunk.m_ChunkPrevious;} while (chunk != null); // 直到没有更多 chunk 为止// 返回最终拼接好的字符串return result;}}
}
当调用 sb.ToString()
时,会从最后一个 Chunk 开始,沿着 m_ChunkPrevious
一路向前遍历,把所有 Chunk 的字符数组拼接起来,最终返回完整的字符串。
这个过程虽然比单一块慢,但比起频繁复制数组,在大多数场景下是值得的。