在 .NET 中,StringBuilder
是一个用于高效构建字符串的重要类。它通过避免频繁创建新字符串对象,从而优化了性能。但其背后的实现机制却并不简单。
一、核心字段与属性解析
StringBuilder
内部使用了字符数组(char[]
)来存储字符串数据,并通过链表的方式管理多个"块"(Chunk),以提升拼接效率。
主要字段:
csharp
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 属性:
csharp
public int Length
{
get => m_ChunkOffset + m_ChunkLength;
}
表示当前整个字符串的总长度。
二、构造函数分析
1. 默认构造函数:
csharp
public StringBuilder()
{
m_MaxCapacity = int.MaxValue;
m_ChunkChars = new char[DefaultCapacity]; // 初始大小为16
}
默认分配一个长度为 16 的字符数组。
2. 带字符串参数的构造函数:
csharp
public StringBuilder(string value, int startIndex, int length, int capacity)
{
...
m_ChunkChars = GC.AllocateUninitializedArray<char>(capacity);
...
}
根据传入字符串的长度和指定容量,选择较大的值作为初始容量,避免多次扩容。
3. 复制构造函数(用于链表节点创建):
csharp
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
如何处理追加操作:
csharp
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); // 扩容并创建新 Chunk
Debug.Assert(m_ChunkLength == 0);
index = 0;
}
}
m_ChunkLength = index;
return this;
}
核心逻辑:
- 如果当前字符数组还有空间,则直接插入字符。
- 如果空间不足,调用
ExpandByABlock()
创建新 Chunk,并将其链接到当前 Chunk 的前面。
四、ExpandByABlock 方法详解
该方法负责创建新的 Chunk 并更新当前 Chunk 的状态:
csharp
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
csharp
var sb = new StringBuilder();
内部结构:
csharp
sb (current)
│
├── m_ChunkChars: [ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ _ ] ← 默认大小为16
├── m_ChunkLength: 0
├── m_ChunkOffset: 0
└── m_ChunkPrevious: null
此时没有数据,只是一个空的字符数组。
2、第一次 Append 操作:添加 "HELLO"
csharp
sb.Append("HELLO");
内部结构:
csharp
sb (current)
│
├── m_ChunkChars: [ H E L L O _ _ _ _ _ _ _ _ _ _ _ ]
├── m_ChunkLength: 5
├── m_ChunkOffset: 0
└── m_ChunkPrevious: null
此时字符数组还有 11 个空间可用。
3、继续 Append:添加 "WORLD"
csharp
sb.Append("WORLD");
字符串长度为 5,刚好可以放入剩余空间中。
内部结构:
csharp
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 超出当前容量
csharp
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 到新分配的数组。
扩容后结构:
csharp
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 的引用。
最终形成一个"逆向链表"结构:
csharp
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)
方法
csharp
/// <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)
方法
csharp
/// <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)
方法
csharp
/// <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)
{
// 复制第一部分到当前 chunk
ThreadSafeCopy(value, m_ChunkChars, m_ChunkLength, firstLength);
m_ChunkLength = m_ChunkChars.Length; // 当前 chunk 已满
}
// 剩余要复制的字符数
int restLength = valueCount - firstLength;
// 扩展 StringBuilder,添加一个新的 chunk
ExpandByABlock(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)
方法
csharp
/// <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 设置为其前一个 chunk
m_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方法详解
csharp
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 开始,沿着链表向前遍历所有 chunks
StringBuilder? chunk = this;
// 循环处理每一个 chunk
do
{
// 只有当前 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 的字符数组拼接起来,最终返回完整的字符串。
这个过程虽然比单一块慢,但比起频繁复制数组,在大多数场景下是值得的。