基础不牢,地动山摇
大家好,这里是后端基础拾光集。
前面有一篇《实战复盘:Java String 新手最全避坑手册|常量池、相等判断、拼接原理》我们通过 6 个误区,把 String 的使用讲透了。今天这篇我们一起学习一下String、StringBuilder、StringBuffer三个类底层到底差在哪? StringBuilder 和 StringBuffer 到底是干嘛的?什么时候用哪个?为什么面试总把这三个放一起考?
一、 生活化类比
- String:封存装订的正式档案 档案一旦打印完成,内容不能涂改。想改文字,不能直接在原纸上改,只能重新打印一份全新档案,旧档案被丢弃等待回收。 这就是 String 的不可变性。
- StringBuilder:活页笔记本 本子预留了多余空白页,能直接追加文字、原地修改,不用每次都换新本子。非线程安全,单线程拼接首选。
- StringBuffer:带锁的活页笔记本 同样能原地追加文字,但本子加了一把锁。多人同时写不会写乱;可上锁带来额外开销,性能略低。
| 类型 | 类比 | 能不能改 | 线程安全 | 速度 | 适用场景 |
|---|---|---|---|---|---|
| String | 装订、盖章、封存的正式档案 | 不能改,改就是重抄一份新档案 | 安全,因为不可变 | 频繁拼接慢 | 常量、key、配置、少量拼接 |
| StringBuilder | 私人活页夹,一人写 | 能改,原地往后写 | 不安全 | 单线程最快 | 单线程循环拼接 |
| StringBuffer | 公共活页夹,每次操作先锁门登记 | 能改 | 安全,方法加锁 | 比 StringBuilder 慢 | 多线程共享拼接 |
一句话记忆:String 不可变,修改必新建;StringBuilder 单线程快;StringBuffer 加锁,多线程安全。
二、 底层原理
1. String:不可变,底层字节数组
Java 8 及以前:
arduino
public final class String {
private final char[] value;
private int hash;
}
Java 9+ 为了节省内存,引入紧凑字符串,String 底层由char[]改为byte[]:
arduino
public final class String {
private final byte[] value;
private final byte coder; // LATIN1 / UTF16
private int hash;
}
不可变的关键点:
String是final class,不能被继承,防止子类破坏不可变语义。value是private final,引用不能指向新数组。String不提供任何修改value元素的方法。- 所有看似修改的方法,如
substring、replace、concat,都返回新 String。 - 内部数组不对外暴露,正常 API 拿不到
value。 hash可以缓存,因为内容不变,hashCode()算一次就能复用。
所以 String 改一个字,不是擦掉重写,而是重新抄一份新档案。
好处:
- 线程安全,可自由共享。
- 适合做
HashMap的 key,因为 hash 稳定。 - 字符串常量池可以安全复用。
- 安全,比如类加载、路径、URL 不会被偷偷改。
代价:
- 频繁拼接会产生大量新对象。
- 循环里
result += x可能变成 O(n²)。
常量池:双引号直接声明的字符串,优先放入字符串常量池复用,节省内存(先前讲过,这里不展开)。
2. StringBuilder:可变,无锁,单线程快
- 底层也是可变数组,自带容量,追加内容直接写入数组尾部。
- 空间不够时自动扩容(约 1.5 倍增长)。
- 没有
synchronized锁,非线程安全,但单线程下最快。
StringBuilder 和 StringBuffer 都继承 AbstractStringBuilder。
Java 8:
csharp
abstract class AbstractStringBuilder {
char[] value;
int count;
}
Java 9+:
csharp
abstract class AbstractStringBuilder {
byte[] value;
byte coder;
int count;
}
StringBuilder 的核心:
scala
public final class StringBuilder extends AbstractStringBuilder {
// 基本没有 synchronized
}
go
StringBuilder sb = new StringBuilder();
sb.append("Java").append("后端"); // 原地追加,不新建对象
append 时,如果容量够,直接把新内容写到 value[count] 后面,然后 count++。容量不够才扩容,通常是:
ini
newCapacity = oldCapacity * 2 + 2;
然后用 Arrays.copyOf 复制到新数组。
StringBuilder 快的原因:
- 可变数组:原地追加,不每次创建新 String。
- 无锁 :没有
synchronized,没有锁竞争和同步开销。 - 减少临时对象:循环拼接时只用一个 StringBuilder。
- 扩容摊还成本低:按倍数扩容,均摊下来 append 接近 O(1)。
- 支持链式调用 :
append返回this。
但它不是线程安全的。多线程同时 append,可能丢数据、长度错乱,甚至数组越界。
3. StringBuffer:可变,加锁,多线程安全
- 逻辑和 StringBuilder 几乎一样,但绝大多数方法加了
synchronized同步锁。 - 多线程并发拼接能保证安全;锁带来性能损耗,单线程没必要用。
scala
public final class StringBuffer extends AbstractStringBuilder {
@Override
public synchronized StringBuffer append(String str) {
super.append(str);
return this;
}
@Override
public synchronized StringBuffer insert(int offset, String str) {
super.insert(offset, str);
return this;
}
// 其他公共方法也大多加 synchronized
}
它安全是因为:
- 关键方法基本都加了
synchronized。 - 锁对象是
this。 - 同一时刻只有一个线程能进入方法修改内部数组和
count。 - 因此多线程共享时,不会出现两个线程同时写同一页导致内容错乱。
代价:
- 每次操作都要加锁、释放锁。
- 单线程用它比 StringBuilder 慢。
- 高并发下锁竞争会成为瓶颈。
- 方法级线程安全不等于复合操作原子,比如:
erlang
if (!sb.isEmpty()) {
sb.append("x");
}
中间仍可能被其他线程插入操作,需要外部同步。
三、 最爱踩的坑
坑 1:循环里直接用+拼接
ini
// ❌ 糟糕写法,循环里不断新建String对象,GC压力暴增
String str = "";
for(int i=0;i<10000;i++){
str += i;
}
循环内拼接必须用 StringBuilder 。注意:不是所有
+都差 ------ 编译器会优化单行连续+,但循环内部的+=不会优化。
坑 2:无脑选 StringBuffer
单线程场景用 StringBuffer,白白被锁拖慢。单线程用 StringBuilder,多线程才考虑 StringBuffer。
坑 3:误以为 String 完全不能 "变"
String 对象本身不可变,但变量引用可以重新指向新字符串。要区分「对象不可变」和「变量换引用」(上一篇 6 误区已详述)。
高频面试辨析
Q1:String 为什么设计成不可变? A:①常量池可复用;②hashCode 只需算一次可缓存;③作 HashMap 的 key 更稳定;④天然线程安全。
Q2:三者性能怎么排? A:单线程大量拼接:StringBuilder > StringBuffer > String。少量固定拼接,+被编译器优化后差距不大。
Q3:StringBuilder 为什么线程不安全? A:多线程同时 append,数组扩容和写入位置会互相覆盖,导致数据丢失。
✅核心总结
- String:封存档案,不可变,修改生成新对象。安全、可共享、hash 稳定,但频繁拼接慢;适合少量拼接和常量定义。
- StringBuilder:私人活页夹,可变、无锁 ;单线程大量拼接首选。
- StringBuffer:公共活页夹,可变、加 synchronized 锁;多线程并发拼接场景使用。
- 循环内拼接禁止
+=,必须用 StringBuilder。 - 少量固定拼接直接
+没问题,编译器会优化,不必过度强制替换。
📝本文属于专栏「后端基础拾光集」系列。 这个专栏结合生活化案例、线上踩坑实例讲解 Java 基础。 同系列已经更新:ArrayList & LinkedList 源码、HashMap、红黑树、Java 泛型、异常体系、Integer 缓存池、equals & hashCode、static、final、接口 vs 抽象类、Lambda 表达式、String 的 6 大误区。 专栏持续更新 Java 后端基础、源码、面试踩坑干货,欢迎关注一起交流后端学习、面试踩坑经验。