在 Java 开发中,字符串处理是最高频的操作之一。String、StringBuffer 和 StringBuilder 构成了 Java 字符串处理的“铁三角”。虽然它们都用于处理文本,但在可变性、线程安全和性能上有着本质的区别。选错类,轻则导致性能下降几个数量级,重则引发并发 Bug。
今天,我们将从底层原理、性能对比、应用场景三个维度,彻底拆解这三者的区别,并揭示 JDK 编译器优化的秘密。
一、核心区别一览表
这是最直观的对比,建议收藏:
| 特性 | String | StringBuffer | StringBuilder |
|---|---|---|---|
| 可变性 | 不可变 (Immutable) | 可变 (Mutable) | 可变 (Mutable) |
| 线程安全 | ✅ 是 (天然安全) | ✅ 是 (同步锁 synchronized) |
❌ 否 (无锁,非线程安全) |
| 性能 | 🐢 低 (频繁修改时极慢) | 🐇 中 (有锁开销) | 🚀 高 (无锁,最快) |
| 内存占用 | 高 (每次修改创建新对象) | 低 (原地修改,复用缓冲区) | 低 (原地修改,复用缓冲区) |
| 底层存储 | byte[] (JDK9+) / char[] |
char[] (继承自 AbstractStringBuilder) |
char[] (继承自 AbstractStringBuilder) |
| 主要用途 | 常量、少量拼接、Map Key | 多线程环境下的字符串构建 | 单线程环境下的字符串构建 |
| 出现版本 | JDK 1.0 | JDK 1.0 | JDK 1.5 (Java 5) |
二、深度解析:三者本质区别
1. String:不可变的“永恒者”
- 核心机制:
String类被声明为final,其内部字符数组(JDK 9 后为byte[])也是final的。一旦创建,内容永远无法改变。 - 修改真相:当你执行
s = s + "a"时,并不是修改了原来的s,而是:- 创建一个新的
StringBuilder。 - 将原内容和新内容 append 进去。
- 调用
toString()生成一个全新的 String 对象。 - 原对象变成垃圾,等待 GC 回收。
- 创建一个新的
- 优点:
- 线程安全:不可变对象天然是线程安全的,可自由共享。
- 缓存友好:支持字符串常量池(String Pool),相同字面量只存一份,节省内存。
- 哈希稳定:
hashCode在创建时计算并缓存,非常适合做HashMap的 Key。
- 缺点:在循环中频繁拼接字符串会产生海量临时对象,导致 GC 压力巨大,性能极差。
2. StringBuffer:线程安全的“老大哥”
- 核心机制:继承自
AbstractStringBuilder,内部维护一个可扩容的字符数组。 - 线程安全:它的核心方法(如
append,insert,delete)都加了synchronized关键字。public synchronized StringBuffer append(String str) { super.append(str); return this; } - 优点:多线程环境下安全,无需额外同步。
- 缺点:由于每次操作都要获取锁,即使在单线程下,性能也比
StringBuilder慢约 10%-20%。 - 现状:在 JDK 1.5 引入
StringBuilder后,StringBuffer的使用场景大幅减少,仅保留在必须线程安全的遗留代码或特定并发场景中。
3. StringBuilder:高性能的“新贵”
- 核心机制:与
StringBuffer几乎完全相同,同样继承自AbstractStringBuilder,内部也是可变字符数组。 - 关键差异:去掉了所有
synchronized锁。 - 优点:单线程下性能极致,没有锁竞争开销。
- 缺点:多线程共用同一个实例时会导致数据错乱(线程不安全)。
- 地位:自 JDK 1.5 发布以来,已成为单线程字符串拼接的首选。
三、性能基准测试(2026年数据)
为了直观展示性能差异,我们模拟在循环中拼接 10 万次字符串的场景:
public class StringPerformanceTest {
private static final int COUNT = 100000;
public static void main(String[] args) {
// 1. String 拼接
long start = System.currentTimeMillis();
String s = "";
for (int i = 0; i < COUNT; i++) {
s += i; // 实际创建了 10 万个临时对象
}
System.out.println("String: " + (System.currentTimeMillis() - start) + " ms");
// 2. StringBuffer 拼接
start = System.currentTimeMillis();
StringBuffer sb = new StringBuffer();
for (int i = 0; i < COUNT; i++) {
sb.append(i);
}
System.out.println("StringBuffer: " + (System.currentTimeMillis() - start) + " ms");
// 3. StringBuilder 拼接
start = System.currentTimeMillis();
StringBuilder sbd = new StringBuilder();
for (int i = 0; i < COUNT; i++) {
sbd.append(i);
}
System.out.println("StringBuilder: " + (System.currentTimeMillis() - start) + " ms");
}
}
典型运行结果(JDK 21/25 环境):
- String: ~3000ms – 5000ms (甚至更慢,取决于 GC 频率)
- StringBuffer: ~15ms – 25ms
- StringBuilder: ~8ms – 12ms
结论:
String在循环拼接中性能最差,比StringBuilder慢数百倍。StringBuilder比StringBuffer快约 2 倍(因为省去了锁的开销)。- 注意:如果是少量的字符串拼接(如
String s = "A" + "B" + "C";),Javac 编译器会自动优化为StringBuilder,此时三者性能差异可忽略不计。
四、常见面试题与陷阱
Q1: 为什么 String 设计成不可变的?
答:
- 安全性:防止参数被篡改(如数据库连接 URL、文件路径)。
- 线程安全:天然支持多线程共享,无需同步。
- 缓存优化:支持字符串常量池,减少内存重复消耗。
- 哈希缓存:
hashCode只需计算一次,提升 HashMap 等集合的性能。
Q2: + 操作符到底用的是哪个?
答:
- 编译期常量拼接(如
"A" + "B"):编译器直接合并为"AB",不涉及任何类。 - 代码中的变量拼接(如
s + i):- JDK 8 及以前:编译器自动生成
StringBuilder进行 append。 - JDK 9+:引入了
StringConcatFactory和invokedynamic指令,优化策略更灵活,但本质上仍是类似StringBuilder的可变机制,绝不是直接用String循环创建对象。
- JDK 8 及以前:编译器自动生成
- 循环中的
+:如果在循环内写s += i,每次循环都会创建新的StringBuilder和新的String,性能极差。必须在循环外显式使用StringBuilder。
Q3: 什么时候该用 StringBuffer?
答:只有在多线程共享同一个字符串缓冲区实例,且需要进行修改操作时才需要。
- 场景示例:多个线程向同一个日志缓冲区追加内容。
- 注意:大多数情况下,我们会使用
ThreadLocal<StringBuilder>或者在每个线程内部使用局部的StringBuilder(线程封闭),从而避免使用StringBuffer的锁开销。因此,StringBuffer在现代高并发代码中已较少见。
Q4: 初始化容量对性能有影响吗?
答:非常大。StringBuilder 和 StringBuffer 默认初始容量为 16。如果预计拼接内容很长,建议在构造时指定容量(如 new StringBuilder(2048))。否则,随着内容增加,内部数组会不断扩容(通常是 2 倍 + 2),涉及数组复制,影响性能。
五、2026年最佳实践指南
1. 选型原则
| 场景 | 推荐选择 | 理由 |
|---|---|---|
| 字符串常量 | String |
利用常量池,语义清晰 |
| 少量拼接 (一行代码内) | String (+) |
编译器自动优化,代码简洁 |
| 单线程循环拼接 | StringBuilder |
性能最优,无锁开销 |
| 多线程共享修改 | StringBuffer |
保证线程安全 |
| SQL 语句/JSON 构建 | StringBuilder |
通常在线程内构建,需高性能 |
| HashMap Key | String |
不可变,hashCode 稳定 |
2. 避坑指南
- ❌ 严禁在循环中使用
String +=:// ❌ 错误示范 String res = ""; for (int i = 0; i < 1000; i++) { res += i; } // ✅ 正确示范 StringBuilder res = new StringBuilder(1000 * 4); // 预估容量 for (int i = 0; i < 1000; i++) { res.append(i); } String finalStr = res.toString(); - ✅ 预分配容量:如果知道大概长度,构造时传入
capacity,减少数组扩容次数。 - ✅ 优先使用局部变量:尽量让
StringBuilder作为方法内的局部变量,天然线程安全,无需StringBuffer。
3. 新特性补充(Java 15+)
对于简单的列表拼接,Java 8 引入了 String.join(),Java 11+ 增强了 Collectors.joining(),这些方法底层也使用了 StringBuilder,但语义更清晰,推荐在拼接集合时使用:
List<String> list = Arrays.asList("A", "B", "C");
String result = String.join(", ", list); // 推荐
// 或者 Stream
result = list.stream().collect(Collectors.joining(", "));
六、总结
- String:不可变,线程安全,适合常量和 Key,严禁在循环中频繁修改。
- StringBuffer:可变,线程安全(有锁),适合多线程共享修改,但性能略低。
- StringBuilder:可变,非线程安全(无锁),单线程修改的首选,性能最强。
一句话口诀:
常量 String 最稳妥,循环拼接 Builder 快,多线程共享 Buffer 安。
掌握这三者的区别,是写出高效 Java 代码的基本功。在任何涉及大量字符串操作的场景中,请务必审视你的选择,避免让 String 成为性能瓶颈。