C# StringBuilder源码分析

在 .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;
}

关键步骤:

  1. 计算新 Chunk 的大小 :不超过 MaxChunkSize(默认 8000),也不小于所需字符数。
  2. 分配新内存
  3. 创建前驱节点 :将当前 Chunk 封装成一个新的 StringBuilder 实例,并赋值给 m_ChunkPrevious
  4. 更新偏移量和长度:当前 Chunk 清空,准备写入新数据。
  5. 切换字符数组:将新分配的数组设为当前 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,因此需要扩容。

步骤解析

  1. 更新当前 Chunk 的状态

    • m_ChunkLength 设置为当前已使用的长度(10)。
    • 记录当前 Chunk 的偏移量为 m_ChunkOffset + m_ChunkLength = 0 + 10 = 10
    • 清空当前 Chunk 的使用长度(设为 0)。
  2. 创建新 Chunk 并设置为前驱节点

    • 创建一个新的 StringBuilder 实例,其字符数组大小根据所需内容和最大块大小(8000)决定。
    • 将当前 Chunk 的引用赋值给新 Chunk 的 m_ChunkPrevious
  3. 切换当前 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 的字符数组拼接起来,最终返回完整的字符串。

这个过程虽然比单一块慢,但比起频繁复制数组,在大多数场景下是值得的。


相关推荐
Reggie_L几秒前
网络编程-java
java·开发语言·网络
小灰灰搞电子40 分钟前
Qt Quick 粒子系统详解
开发语言·qt
wadesir44 分钟前
Python获取网页乱码问题终极解决方案 | Python爬虫编码处理指南
开发语言·爬虫·python
As_wind_44 分钟前
Go 语言学习之测试
开发语言·学习·golang
望获linux1 小时前
【Linux基础知识系列】第五十四篇 - 网络协议基础:TCP/IP
java·linux·服务器·开发语言·架构·操作系统·嵌入式软件
liupenglove1 小时前
云端【多维度限流】技术方案设计,为服务稳定保驾护航
java·开发语言·网络
平哥努力学习ing1 小时前
C语言内存函数
c语言·开发语言·算法
minji...2 小时前
数据结构 栈(2)--栈的实现
开发语言·数据结构·c++·算法·链表
minji...2 小时前
数据结构 栈(1)
java·开发语言·数据结构
zh_xuan2 小时前
c++ 模板元编程
开发语言·c++·算法