StringBuilder 与 StringBuffer:会变的字符串
本教程共 100 篇 · 第 56 篇 · 更新于 2026-08-05 · 约 6 分钟阅读
56. StringBuilder 与 StringBuffer
本节目标:搞懂为什么大量拼接字符串要换工具,以及可变字符串 StringBuilder 的正确用法和它的线程安全兄弟 StringBuffer。
上一章说 String 不可变。这带来一个麻烦:在循环里反复用 + 拼接,每转一圈都生成新对象,旧的全成了垃圾。拼几千次,性能就明显塌下来。
为什么循环里别用加号拼接
看看它到底干了什么
String s = "";
for (int i = 0; i < 10000; i++) {
s = s + i;
}
```bash
第 3 行看着只是「往后加一个数字」,实际发生的是:先把当前 s 的全部字符复制一遍,再接上 i,产出一个全新 String,然后把引用赋回 s。上一轮那个串立刻变成垃圾。
问题在于复制的代价随长度增长。第 1 轮复制 0 个字符,第 5000 轮要复制约 1 万多个字符。总工作量大致和循环次数的平方成正比,这就是典型的性能陷阱。
### 改成可变字符串
```java
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
sb.append(i);
}
String result = sb.toString();
append 直接往内部字符数组尾部填,不复制历史内容,也不产生中间对象。整个循环只在容量不够时扩容几次。同样一万次拼接,实测通常快出两三个数量级。
Warning偶尔拼两三次用
+完全没问题,编译器会帮你优化。但「循环里反复累加」是必须换掉的反模式,日志拼装、SQL 片段拼装、CSV 行拼装都属于这类。
StringBuilder 的基本用法
StringBuilder 内部维护一个可伸缩的字符数组,拼接就是往里填字符。它最重要的特点是可变:同一个对象的内容会被就地改掉。
append 支持链式调用,因为它每次都把自己返回来:
StringBuilder sb = new StringBuilder();
sb.append("姓名:").append("小明").append(",年龄:").append(18);
System.out.println(sb.toString()); // 姓名:小明,年龄:18
```bash
第 2 行连点四次,每个 `append` 的返回值都是同一个 sb 对象,所以能一路点下去。这种写法叫链式调用,拼长字符串时特别顺。
`append` 还重载了几乎所有类型:int、double、char、boolean、对象、字符数组都能直接塞,不用先转字符串。传 null 时会拼出四个字符 "null",而不是抛异常。
## 除了 append 还能干什么
插入、替换、删除、反转都不在话下:
```java
StringBuilder sb = new StringBuilder("hello");
sb.insert(5, " world"); // 在下标 5 处插入 → "hello world"
sb.replace(0, 5, "HI"); // 替换下标 0~5(不含 5)→ "HI world"
sb.delete(0, 3); // 删除下标 0~3(不含 3)→ "world"
sb.reverse(); // 整体反转 → "dlrow"
System.out.println(sb); // dlrow
四行方法全部就地修改,不返回新对象。这和 String 完全相反——String 那边你必须接返回值,这边不接也照样生效。两套规则混在一起用,很容易出错,记牢区别。
区间参数统一遵循「含头不含尾」,和 substring 一个规矩。
最后想拿到普通字符串,调一次 toString()。平时把 StringBuilder 当成「施工中的工地」,toString() 是交房。
容量与性能细节
StringBuilder 里有两个概念要分清:
- 长度(length):当前实际存了几个字符。
- 容量(capacity):内部数组能装几个字符,默认 16。
容量够时 append 只是往后填;不够时会新建一个更大的数组(约为原容量的两倍加二)并把内容复制过去。扩容本身有成本,如果提前知道大概长度,直接预留:
StringBuilder sb = new StringBuilder(1024); // 预留 1024 字符空间
```bash
拼几万行日志时,这一行能省掉十几次数组复制。
其余就地操作的方法也顺带记一下:`setLength` 截断或补空,`ensureCapacity` 提前扩容,`charAt` 和 `setCharAt` 按位置读写单个字符,`indexOf` 找子串,`deleteCharAt` 删单个字符。这些能力 String 一个都没有。
> [!TIP]
> 想清空一个 StringBuilder 重复利用,用 `setLength(0)` 最快,不用重新 new。它只把长度归零,底层数组还留着,下次拼接直接复用。
## StringBuffer:线程安全的老版本
`StringBuffer` 和 `StringBuilder` 的方法名几乎一模一样,区别只有一个:StringBuffer 的方法都带 `synchronized`,同一时刻只允许一个线程进入。
```java
StringBuffer sbf = new StringBuffer();
sbf.append("多个线程同时拼也不会乱");
这份安全不是白来的。每次 append 都要加锁解锁,单线程下纯属白交税。Java 5 之前只有 StringBuffer,后来发现绝大多数拼接都发生在一个方法内部、根本不跨线程,才新增了不加锁的 StringBuilder。
Note主线写法:一律先用
StringBuilder。只有当同一个可变缓冲区确实要被多个线程共享时,才换StringBuffer。实践中这种场景极少,通常更好的做法是让每个线程各自持有一个 StringBuilder,最后再汇总。
三者速查对照
| 类型 | 可变性 | 线程安全 | 速度 | 什么时候用 |
|---|---|---|---|---|
String | 不可变 | 安全(因为改不了) | 少量拼接够快 | 常量、方法返回值、Map 的键 |
StringBuilder | 可变 | 不安全 | 最快 | 循环拼接、动态构造文本(默认选它) |
StringBuffer | 可变 | 安全(方法加锁) | 较慢 | 多线程共享同一缓冲区(罕见,历史兼容) |
顺带一提 StringJoiner
Java 8 提供了 StringJoiner,专门做「带分隔符、可加前后缀」的拼接,底层也是 StringBuilder:
import java.util.StringJoiner;
StringJoiner sj = new StringJoiner(", ", "[", "]");
sj.add("a").add("b").add("c");
System.out.println(sj); // [a, b, c]
```bash
好处是不用操心「最后一个元素后面别加逗号」这种边界判断。手写循环拼分隔符,十个人有八个会在这里多一个尾巴。
`String.join` 内部就是用它实现的。只需要分隔符、不要前后缀时,直接用 `String.join` 更短。
## 编译器其实偷偷帮了你
你可能会问:平时写 `"a" + "b" + c` 也没显式用 StringBuilder 啊?
因为编译器在背后处理了。Java 9 之前会生成 StringBuilder 的 append 链,Java 9 起改用更高效的 `invokedynamic` 字符串拼接,运行时按需生成最优代码。不管哪种,效果都是**一条语句里的拼接已经被优化过了**。
```java
// 你写的
String s = "a" + b + "c";
// 编译器生成的(Java 8 时代的等价形式)
String s = new StringBuilder().append("a").append(b).append("c").toString();
关键词是「一条语句」。一旦进了循环,每轮都是独立语句,编译器无法跨轮合并——每轮都会 new 一个新的 StringBuilder 再丢掉。所以循环拼接必须你自己把 StringBuilder 提到循环外面。
该怎么选
- 少量、固定的拼接:
+随便用,可读性最好。 - 循环里累加、动态拼很多段:
StringBuilder。 - 拼集合元素、要分隔符和前后缀:
StringJoiner或String.join。 - 多线程共享同一个可变串:才轮到
StringBuffer。
常见疑问
问:StringBuilder 能当 Map 的键吗?
不能。它没重写 equals 和 hashCode,两个内容相同的 StringBuilder 也算不同的键;而且它可变,放进去之后一改内容就再也取不出来了。要当键就先 toString()。
问:拼接时该关心线程安全吗?
方法内部创建、方法内部用完的 StringBuilder 是局部变量,根本不会被别的线程看见,天然安全。只有把它存成成员变量或静态变量共享出去时才需要考虑。
问:为什么有人说 StringBuffer 也不算真正线程安全?
单个方法调用是原子的,但连着两次调用之间可能被别的线程插队。比如先 length() 再按这个长度去 delete,中间长度可能已经变了。要保证一串操作整体原子,还得自己在外面加锁。
小结
String 不可变,循环里用 + 会大量复制和造垃圾。可变字符串 StringBuilder 用 append 链式拼接,方法就地修改、不返回新对象,预估长度时可以指定初始容量。StringBuffer 只是给方法加了锁的历史版本,速度更慢,非多线程共享不用。拼集合优先 StringJoiner 或 String.join。