C# StringBuilder 详解:常用 API、缓冲区扩容原理与 string 对比(入门+速查)
导语:
string就像一块石碑,字一旦刻上去就改不了,想改只能重新刻一块;而StringBuilder是一块黑板,提前预留了板面,你可以在上面反复写、反复擦,写完再一次性誊抄成正式文本。本文就带你彻底搞懂:循环里拼字符串为什么慢、StringBuilder怎么用、它底层是怎么做到的,以及哪些场景其实根本不需要它。
一、什么是 StringBuilder
StringBuilder 是 C# 专门用来高效地拼接、修改字符串 的一个类,位于 System.Text 命名空间下。
它的核心特点只有一个:可变。
我们平时用的 string 是"不可变"的(immutable),任何修改都会生成一个新字符串;而 StringBuilder 内部维护着一块可以反复写入的字符缓冲区,在你调用 ToString() 之前,不会产生新的字符串对象。
一句话总结二者的分工:
string:用来存放、查找、比较、传递文本。StringBuilder:用来拼装、编辑 文本,拼好之后再转成string。
二、为什么需要 StringBuilder:从 string 的"脾气"说起
2.1 string 是不可变的
什么叫不可变?看下面这段代码:
csharp
string s = "ABC";
string s2 = s; // s2 和 s 指向同一个字符串 "ABC"
s += "DEF"; // 注意:这里不是在原字符串后面写字,而是造了一个新字符串
Console.WriteLine(s); // 输出:ABCDEF
Console.WriteLine(s2); // 输出:ABC(s2 完全没受影响)
s += "DEF" 看起来是"在 s 后面加了几个字",但底层实际做的是:
- 申请一块新的内存;
- 把旧字符串
"ABC"和新内容"DEF"一起拷贝过去; - 让变量
s指向新字符串"ABCDEF"; - 原来的
"ABC"没人用了,等着垃圾回收器(GC)回收。
也就是说,每拼接一次,就会产生一个被丢弃的旧对象。
【插图 1:string 拼接产生临时对象示意图】
2.2 循环里拼接,性能问题被放大
一次两次拼接无所谓,编译器甚至会帮你优化。但如果放在循环里,比如拼接 5 万次:
csharp
using System;
using System.Diagnostics; // 计时用的 Stopwatch 在这个命名空间
using System.Text; // StringBuilder 在这个命名空间
class Program
{
static void Main(string[] args)
{
const int count = 50000; // 拼接次数
// 方式一:用 string 直接 +=
Stopwatch sw1 = Stopwatch.StartNew();
string s = "";
for (int i = 0; i < count; i++)
{
s += i; // 每次循环都在造新字符串、拷贝旧内容
}
sw1.Stop();
// 方式二:用 StringBuilder
Stopwatch sw2 = Stopwatch.StartNew();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < count; i++)
{
sb.Append(i); // 内容写进缓冲区,不产生新字符串
}
string result = sb.ToString(); // 最后只生成一次 string
sw2.Stop();
Console.WriteLine($"string 拼接耗时:{sw1.ElapsedMilliseconds} ms");
Console.WriteLine($"StringBuilder 拼接耗时:{sw2.ElapsedMilliseconds} ms");
Console.ReadKey();
}
}
【插图 2:两种拼接方式的控制台运行结果】
运行后你会直观地看到:循环次数越大,两者的差距越夸张,5 万次循环时往往能相差几百上千倍。原因很简单:
string +=拼到第 N 次时,要把前 N-1 个字符全部拷贝一遍,越往后拷贝量越大,总工作量大约是 1+2+3+...+N,而且会产生 N 个等待回收的对象。StringBuilder只往缓冲区里写,整个过程最终只生成一个字符串。
补充:如果只是
"a" + "b" + "c"这种固定的、次数很少 的拼接,编译器会自动优化成一次string.Concat,不会真的产生一堆中间对象,这种场景就不必动用StringBuilder,后面"常见坑"一节还会细说。
三、准备:创建 StringBuilder
使用前先引入命名空间:
csharp
using System.Text;
常用的构造方式有四种:
csharp
// 1. 创建一个空的 StringBuilder,之后再往里追加内容
StringBuilder sb1 = new StringBuilder();
// 2. 创建时带上初始字符串
StringBuilder sb2 = new StringBuilder("你好");
// 3. 指定初始容量(知道大概要拼多长时,提前预留位置,减少后续扩容次数)
StringBuilder sb3 = new StringBuilder(1024);
// 4. 同时指定初始字符串和容量
StringBuilder sb4 = new StringBuilder("你好", 1024);
构造函数归纳:
| 构造写法 | 作用 |
|---|---|
new StringBuilder() |
创建空对象,默认初始容量为 16 |
new StringBuilder("初始内容") |
带初始字符串 |
new StringBuilder(1024) |
指定初始容量 |
new StringBuilder("初始内容", 1024) |
初始字符串 + 容量 |
new StringBuilder(1024, 1024 * 1024) |
同时指定初始容量和最大容量(了解即可) |
四、核心用法:常用 API 全家桶
4.1 追加内容:Append、AppendLine、AppendFormat、AppendJoin
csharp
StringBuilder sb = new StringBuilder();
sb.Append("姓名:"); // 追加内容,不换行
sb.Append("张三");
sb.AppendLine(); // 只追加一个换行符(相当于回车换行)
sb.AppendLine("年龄:18"); // 追加一行,末尾自动带换行
// 按占位符格式追加,用法和 string.Format 完全一样
sb.AppendFormat("成绩:{0:F1} 分", 95.5);
sb.AppendLine();
// 用分隔符拼接一组数据,类似 string.Join,免去手动加分隔符
string[] hobbies = { "看书", "跑步", "编程" };
sb.AppendJoin("、", hobbies);
string result = sb.ToString();
Console.WriteLine(result);
输出:
text
姓名:张三
年龄:18
成绩:95.5 分
看书、跑步、编程
小提示:
Append有几十个重载,字符串、数字、bool、char、DateTime等基本类型都能直接追加,不需要自己先ToString(),它内部会帮你转。
4.2 插入内容:Insert
Insert 可以在指定下标位置插入内容,下标从 0 开始。
csharp
StringBuilder sb = new StringBuilder("Hello World");
sb.Insert(5, ","); // 在下标 5 的位置插入一个逗号
Console.WriteLine(sb.ToString()); // 输出:Hello, World
4.3 删除与替换:Remove、Replace、Clear
csharp
StringBuilder sb = new StringBuilder("Hello, World!");
// Remove(起始下标, 删除长度):从下标 5 开始,删除 2 个字符(删掉逗号和空格)
sb.Remove(5, 2);
Console.WriteLine(sb.ToString()); // 输出:HelloWorld!
// Replace:把所有匹配的字符(或字符串)全部替换掉
sb.Replace('o', '0'); // 字符重载:所有 'o' 换成 '0'
Console.WriteLine(sb.ToString()); // 输出:Hell0W0rld!
sb.Replace("World", "C#"); // 字符串重载:所有 "World" 换成 "C#"
sb.Clear(); // 清空全部内容,Length 变为 0
4.4 读取与转换:Length、索引器、ToString
csharp
StringBuilder sb = new StringBuilder("ABCDEF");
Console.WriteLine(sb.Length); // 输出:6(当前字符个数)
Console.WriteLine(sb[0]); // 输出:A(可以像数组一样按下标读字符)
sb[0] = 'a'; // 还可以直接修改某个位置的字符------这就是"可变"
Console.WriteLine(sb.ToString()); // 输出:aBCDEF
// 把 Length 设小,还能实现"截断"效果
sb.Length = 3;
Console.WriteLine(sb.ToString()); // 输出:aBC
重点:StringBuilder 不是字符串,编辑完成后必须调用 ToString() 转成正式的 string,才能用于赋值、传参、返回等场合。
csharp
StringBuilder sb = new StringBuilder("正在拼接...");
sb.Append("完成");
string text = sb.ToString(); // 正确:转成 string
// textBox1.Text = sb; // 错误:编译都通不过,Text 属性只接受 string
textBox1.Text = sb.ToString(); // 正确
4.5 容量控制:Capacity、EnsureCapacity
StringBuilder 内部有一块缓冲区,就像黑板有大小。两个相关概念:
Length:当前实际写了多少个字符(用了多少板面)。Capacity:缓冲区最多能放多少个字符(黑板总大小)。
当 Length 要超过 Capacity 时,它会自动扩容。如果你事先知道内容大概有多长,可以提前预留容量,避免扩容时的数据拷贝:
csharp
StringBuilder sb = new StringBuilder();
sb.EnsureCapacity(1000); // 保证容量至少为 1000,循环前调用一次即可
// 也可以在构造时直接指定容量,效果一样
StringBuilder sb2 = new StringBuilder(1000);
4.6 链式调用
Append、AppendLine、Insert、Remove、Replace 这些方法的返回值都是 StringBuilder 自己(也就是 this),所以可以像流水一样一直点下去:
csharp
string text = new StringBuilder()
.AppendLine("第一行")
.AppendLine("第二行")
.Append("第三行")
.ToString();
4.7 常用 API 速查表
【插图 3:StringBuilder 常用 API 体系图】
| 方法 / 属性 | 作用 | 备注 |
|---|---|---|
Append(value) |
追加内容 | 重载非常多,基本类型都能直接追加 |
AppendLine() |
追加一个换行符 | 无参时只换行 |
AppendLine(value) |
追加内容并换行 | 写一行文本常用 |
AppendFormat(fmt, args) |
按占位符格式追加 | 用法同 string.Format |
AppendJoin(sep, values) |
用分隔符拼接多个值 | 类似 string.Join |
Insert(index, value) |
在指定下标处插入 | 下标从 0 开始 |
Remove(start, length) |
从指定位置删除若干字符 | 按"位置+长度",不是按内容 |
Replace(old, new) |
全部替换 | 有 char 和 string 两个重载 |
Clear() |
清空内容 | 清空后 Length = 0,容量保留 |
ToString() |
转成 string |
拼完务必调用 |
Length |
当前字符个数 | 可读可写,设小等于截断 |
Capacity |
缓冲区容量 | 可读可写 |
[index] |
索引器,读写单个字符 | 只能访问已存在的位置 |
EnsureCapacity(n) |
保证容量至少为 n | 提前预留,减少扩容 |
五、原理:StringBuilder 为什么快
5.1 缓冲区与扩容
StringBuilder 内部维护着一块字符缓冲区(你可以理解为一个 char 数组):
- 追加内容时,直接把字符写进缓冲区的空位里,不产生新字符串。
- 当缓冲区放不下时,它才申请一块更大的新缓冲区,把旧内容搬过去,然后继续写,这个过程叫"扩容"。
- 最后调用
ToString(),才一次性生成正式的string。
【插图 4:StringBuilder 缓冲区与扩容原理示意图】
扩容不是每次追加都发生,而是"容量不够时才扩一次",所以即使循环拼接 5 万次,真正的扩容也只有十几次,产生的临时对象数量可以忽略不计。
补充(了解即可,不影响使用):在较老的 .NET Framework 中,扩容是"申请一块翻倍大小的单块缓冲区并拷贝全部内容";在 .NET Core 2.1 / .NET 5 及更高版本中,内部改成了分块链式结构,容量不够时直接挂上一块新的字符块,连"拷贝全部内容"都省掉了。对使用者来说,记住"往缓冲区写、不够才扩容、最后 ToString"这个模型就足够了。
5.2 与 string 的本质对比
| 对比项 | string | StringBuilder |
|---|---|---|
| 可变性 | 不可变,改一次造一个新对象 | 可变,直接在缓冲区上改 |
| 命名空间 | System |
System.Text |
| 每次拼接 | 申请新内存、拷贝全部内容 | 写入缓冲区,不产生新字符串 |
| 擅长的操作 | 查找、截取、比较、分割、正则匹配 | 追加、插入、删除、替换等编辑操作 |
| 适用场景 | 少量、固定次数的拼接;文本处理 | 循环拼接、大量动态文本拼装 |
| 线程安全 | 不可变,天然安全 | 不是线程安全的,多线程共用需加锁 |
5.3 什么场景该用谁
推荐用 StringBuilder 的场景:
- 循环里拼接字符串(最典型的场景);
- 逐行读取文本文件、把多行拼成一个大字符串;
- 动态拼接 SQL、HTML、JSON、报表、日志等。
直接用 string 更合适的场景:
- 固定几行文本、一次性拼接,用
+或字符串插值$""更直观; - 拼完之后还要做
Split、Substring、Trim、IndexOf、正则匹配等操作------这些StringBuilder基本不提供,要转成string再处理。
六、常见坑
-
用完忘记
ToString()。StringBuilder不是string,直接给TextBox.Text、方法参数赋值会编译报错。养成"拼完立刻ToString()"的习惯。 -
两种极端用法都不对:少量拼接乱用、循环拼接没用对。
"a" + "b" + "c"这种固定拼接编译器会优化成一次string.Concat,写日志、拼路径这种一锤子买卖,用$"姓名:{name},年龄:{age}"更直观,没必要动用StringBuilder;而真正循环拼接时,要在循环外创建一次、循环里只Append、循环结束后统一ToString(),把new和ToString()都写进循环体,等于把它的优势完全浪费了。 -
以为
Append会自动换行。Append只追加内容,换行必须用AppendLine(),或自己追加Environment.NewLine、"\r\n"。 -
Remove按"位置+长度"删,Replace是全部替换。 二者都不是按内容操作:想删除某个子串,要先用ToString().IndexOf(...)找到位置再算长度;Replace没有"只替换第一个"的重载,只替换一次时需要自己定位后用Remove+Insert。 -
多线程共用同一个实例会出问题。
StringBuilder不是线程安全的,多个线程同时Append可能导致内容错乱甚至异常。做法是加锁(lock),或者让每个线程各用一个实例,最后再合并。
七、小结
string是不可变的,每次修改都生成新对象;循环拼接时反复拷贝旧内容,会产生大量临时对象,次数越多越慢。StringBuilder在System.Text下,内部维护一块可反复写入的字符缓冲区 ,追加不产生新字符串,只有容量不够时才扩容,最后ToString()一次成型。- 常用 API:追加用
Append/AppendLine/AppendFormat/AppendJoin,插入用Insert,删除替换用Remove/Replace/Clear,别忘了最终ToString();已知内容长度时用构造函数或EnsureCapacity提前预留容量。 - 选型原则:循环、大量、动态拼接用
StringBuilder;少量、固定、需要文本处理用string。 - 牢记高频坑:忘记
ToString()、Append不换行、Clear()不释放容量、多线程共用不加锁。



