当前位置: 首页 > news >正文

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

关键步骤:

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

步骤解析

  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 到新分配的数组

扩容后结构

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 的字符数组拼接起来,最终返回完整的字符串。

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


http://www.xdnf.cn/news/1130653.html

相关文章:

  • 2025年Java最新社招面试八股文+技术场景题(金九银十)
  • Hadoop架构演进:从1.0到2.0的深度对比与优化解析
  • Hadoop(二)
  • QT技巧之快速搭建串口收发平台
  • Taro.getRandomValues() 用法详解
  • 有哪些好用的原型设计软件?墨刀、Axure等测评对比
  • Elasticsearch+Logstash+Kibana部署
  • Taro.eventCenter 用法详解与实战
  • 深入核心:理解Spring Boot的三大基石:起步依赖、自动配置与内嵌容器
  • 【Qt+error】error: use of undeclared identifier ‘MainWindow
  • uniapp各端通过webview实现互相通信
  • qt 中英文翻译 如何配置和使用
  • Spring AI 系列之十三 - RAG-加载本地嵌入模型
  • 在 CentOS 8 上彻底卸载 Kubernetes(k8s)
  • k8s之持久化存储流程
  • JavaScript 异步编程的终极指南:从回调到 Promise、Async/Await
  • 深入解析Linux进程地址空间与虚拟内存管理
  • vivo S30评测:用设计诠释科技,以性能书写情怀
  • 电脑安装 Win10 提示无法在当前分区上安装Windows的解决办法
  • openEuler 22.03 LTS Rootless Docker 安装指南
  • Apache IoTDB(1):时序数据库介绍与单机版安装部署指南
  • 免费MCP服务:Excel CSV 转 JSON MCP by WTSolutions 文档
  • 计算机网络:(九)网络层(下)超详细讲解互联网的路由选择协议、IPV6与IP多播
  • 微服务中token鉴权设计的4种方式
  • STM32 | 定时器 PWM 呼吸灯
  • Python 程序设计讲义(2):Python 概述
  • kube-proxy 中 IPVS 与 iptables
  • SQL学习记录01
  • 【PTA数据结构 | C语言版】根据层序序列重构二叉树
  • day053-初识docker与基础命令