String、StringBuilder、StringBuffer 深入对比
一句话结论(30s)
三者本质是同一套 AbstractStringBuilder 存储(JDK 9 后 byte[] + coder 紧凑字符串)上的三种取舍,因为 String 不可变所以线程安全、可入常量池、可缓存 hash,而 StringBuilder 可变但非线程安全、StringBuffer 给每个方法加 synchronized 换线程安全。权衡:不可变性换来安全与复用,可变性换来高性能拼接,同步换正确性但吞吐下降约 20%。
核心原理(2min)
主流程:JDK 9 起底层用 byte[] + coder,纯 Latin-1 字符每字符 1 字节(内存减半)、否则 UTF-16 2 字节;扩容规则是「旧容量 ×2 + 2」;javac 把 + 拼接编译成 StringBuilder.append,循环内拼接应复用外部实例,JDK 9+ 简单拼接走 StringConcatFactory/invokedynamic。StringBuffer 额外用 synchronized 保护每个方法,并用 toStringCache 缓存 toString 结果。选型:内容不变用 String,需要线程安全且拼多用 ThreadLocal
底层深入(5-10min)
一、底层数据结构演变
JDK 8:char[] 时代
JDK 8 及之前,String 内部使用 char[] 存储字符:
// JDK 8 String 核心字段
private final char value[];
private int hash; // 惰性计算,默认为 0
每个 char 在 JVM 中占 2 字节(UTF-16 编码),即使你只存储 ASCII 字符(如 “hello”),实际也占用 10 字节(5 x 2)。
思考:JDK 8 明知道 ASCII 只用 1 字节,为什么还用
char[]固定占 2 字节?因为 Java 从一开始就把char定义为 UTF-16 的代码单元(正好 2 字节),charAt(i)只需value[i]一条指令就能返回任意 BMP 字符(含中文),实现最直白;代价就是纯 ASCII 场景内存翻倍——这个「浪费 50%」的痛点,正是 JDK 9 引入 Compact Strings 的动机。
JDK 9:Compact Strings
JEP 254 引入了 Compact Strings,将 char[] 替换为 byte[],并新增一个 coder 字段标记编码方式:
String.java 中的真实定义(逐字摘自 OpenJDK 源码):
@Stable
private final byte[] value;
/**
* The identifier of the encoding used to encode the bytes in
* {@code value}. The supported values in this implementation are
*
* LATIN1
* UTF16
*/
private final byte coder;
/** Cache the hash code for the string */
@Stable
private int hash; // Default to 0
分析:value 用 byte[] 存实际字节内容,coder 标记编码(LATIN1=0、UTF16=1)。两个字段都标 @Stable,向 JVM 声明构造后内容不可变、可被信任做常量折叠;hash 同样是 @Stable 惰性缓存,hashCode() 首次计算后不再重算。
核心优化:对于纯 ASCII/Latin-1 字符(0x00-0xFF),使用 LATIN1 编码(1 字节/字符),内存占用直接减半。检测到非 Latin-1 字符时使用 UTF16 编码(2 字节/字符)。
思考:Compact Strings 为什么能「省一半内存」?关键不在
coder这个额外字段,而在存储单元从 2 字节的char换成了 1 字节的byte:纯 Latin-1 字符每字符只占 1 字节,coder只是多出的 1 字节元数据,摊薄到整个字符串上几乎可忽略。所以不是「白捡」,而是把「UTF-16 永远 2 字节」改成「Latin-1 时 1 字节、UTF-16 时 2 字节」的按需分配。
charAt 按 coder 分派的真实实现:
public char charAt(int index) {
if (isLatin1()) {
return StringLatin1.charAt(value, index);
} else {
return StringUTF16.charAt(value, index);
}
}
boolean isLatin1() {
return COMPACT_STRINGS && coder == LATIN1;
}
byte coder() {
return COMPACT_STRINGS ? coder : UTF16;
}
@Native static final byte LATIN1 = 0;
@Native static final byte UTF16 = 1;
分析:charAt 先用 isLatin1() 判断编码,Latin-1 走 StringLatin1.charAt(把 1 字节扩宽成 char),否则走 StringUTF16.charAt(读 2 字节)。COMPACT_STRINGS 是静态常量,关闭压缩时被 JIT 常量折叠成单支;coder 是 final 字段,运行中不存在「每个字符都判断一次 coder」的分支开销。
StringBuilder 和 StringBuffer 同样受益——它们底层也使用 byte[] 存储。
二、不可变 vs 可变:安全性的代价
String 不可变性的四重保障
public final class String // 1. final 类,禁止继承
implements java.io.Serializable, Comparable<String>, CharSequence {
private final byte[] value; // 2. private final 字段
// 3. 无 setter 方法,所有"修改"方法返回新 String
// 4. 构造函数做防御性拷贝
public String(String original) {
this.value = original.value; // 直接复用——因为 original 也是不可变的
this.coder = original.coder;
this.hash = original.hash;
}
// 从 char[]/byte[] 构造则进行拷贝
public String(char value[]) {
this(encode(value), UTF16); // 拷贝数组
}
}
不可变性的价值:
- 线程安全:天然安全,无需同步。这也是 HashMap 默认用 String 做 key 的原因。
- 字符串常量池:编译期
"abc"字面量被注入常量池,运行时intern()可以复用。 - hash 缓存:
hashCode()首次计算后缓存在hash字段,后续调用 O(1) 返回。
思考:String 为什么非不可变不可?反过来推——如果 String 可变,上述三条价值全部崩塌:可变就做不到线程安全(多线程改同一对象要加锁);可变对象无法安全进常量池复用(改一个会串改所有引用);
hash缓存会失效(值变了 hash 也变,缓存就是脏数据)。所以 HashMap 敢默认拿 String 当 key、intern()敢复用、hashCode()敢缓存,全都是「不可变」这一条前提换来的。
StringBuilder 的可变性
abstract class AbstractStringBuilder implements Appendable, CharSequence {
byte[] value; // 非 final,可扩容
byte coder;
int count; // 已使用的字节数
}
关键差异:value 字段不是 final,且提供 append/insert/delete/replace 等方法在原数组上修改。
思考:既然不可变有这么多好处,为什么 StringBuilder 还要可变?因为拼接是「高频小步修改」场景——每次
append都 new 一个 String 会退化成 O(n²) 的对象分配风暴。可变数组 + 原地写入 + 最后一次性toString(),才是拼接的正确姿势。权衡点:不可变换安全与复用,可变换拼接性能,二者不可兼得,所以 Java 同时保留了 String 和 StringBuilder。
三、StringBuffer 的同步锁设计
StringBuffer 与 StringBuilder 继承同一个抽象类 AbstractStringBuilder,唯一区别是 每个公开方法都加了 synchronized:
// StringBuffer.append 方法(简化)
@Override
public synchronized StringBuffer append(String str) {
toStringCache = null; // 注意这行
super.append(str);
return this;
}
重点关注 toStringCache:
// StringBuffer 特有字段
private transient char[] toStringCache; // JDK 8
private transient String toStringCache; // JDK 9+
toStringCache 的工作原理:StringBuffer 在调用 toString() 时缓存返回的 String 对象。后续任何修改操作(append/insert/delete 等)都会将该缓存置 null。这意味着:连续多次 toString() 只会在第一次时分配新的 String 对象,后续返回缓存。而 StringBuilder 没有这个缓存——每次 toString() 都 new 新 String。
思考:为什么 StringBuilder 不也加一个
toStringCache?因为 StringBuilder 没有 synchronized 保护,多线程下「读缓存 + 写缓存」不是原子操作,缓存会读到脏数据;而 StringBuffer 每个方法都加锁,toStringCache的读写天然被同一把锁保护,缓存才安全。这也解释了为什么这个字段只在 StringBuffer 里存在。
四、扩容策略详解
JDK 9 之前的源码里扩容方法叫 ensureCapacityInternal();当前 OpenJDK 主分支(JDK 28)已把它重构为按 coder 区分的 ensureCapacitySameCoder / ensureCapacityNewCoder 两个静态方法。append(String) 触发扩容的真实链路如下:
// AbstractStringBuilder 的存储字段(StringBuilder / StringBuffer 共用)
byte[] value;
byte coder;
boolean maybeLatin1;
int count;
// append(String):先判定是否需要换 buffer,再写入
public AbstractStringBuilder append(String str) {
if (str == null) {
return appendNull();
}
byte coder = this.coder;
int count = this.count;
byte[] value = this.value;
int len = str.length();
byte newCoder = (byte)(coder | str.coder());
if (needsNewBuffer(value, coder, count + len, newCoder)) {
this.value = value = ensureCapacityNewCoder(value, coder, count, count + len, newCoder);
this.coder = newCoder;
}
str.getBytes(value, count, newCoder);
this.count = count + len;
return this;
}
// 扩容核心(换 coder 或空间不足时调用)
private static byte[] ensureCapacityNewCoder(byte[] value, byte coder, int count,
int minimumCapacity, byte newCoder) {
assert coder == newCoder || newCoder == UTF16 : "bad new coder UTF16 -> LATIN1";
int oldCapacity = value.length >> coder;
int growth = minimumCapacity - oldCapacity;
int newCapacity = (growth <= 0)
? oldCapacity
: newCapacity(value, newCoder, minimumCapacity);
assert count <= newCapacity : "count exceeds new capacity";
if (coder == newCoder) {
if (newCapacity > oldCapacity) {
value = Arrays.copyOf(value, newCapacity << newCoder);
}
return value;
} else {
byte[] newValue = StringUTF16.newBytesFor(newCapacity);
StringLatin1.inflate(value, 0, newValue, 0, count);
return newValue;
}
}
// 容量计算:偏好增长「旧长度 + 2 字符」
private static int newCapacity(byte[] value, byte coder, int minCapacity) {
int oldLength = value.length;
int newLength = minCapacity << coder;
int growth = newLength - oldLength;
int length = ArraysSupport.newLength(oldLength, growth, oldLength + (2 << coder));
if (length == Integer.MAX_VALUE) {
throw new OutOfMemoryError("Required length exceeds implementation limit");
}
return length >> coder;
}
分析:append(String) 先算 newCoder = coder | str.coder()——任一方是 UTF16 就整体升级为 UTF16;needsNewBuffer 判定「编码变了 / 字节不够 / 溢出」才扩容,否则原地 str.getBytes(value, count, newCoder) 写入。扩容交给 ensureCapacityNewCoder:同 coder 时 Arrays.copyOf 复制到更大数组,换 coder 时用 StringLatin1.inflate 把 Latin-1 逐字节扩宽成 UTF16。最终由 newCapacity 调 ArraysSupport.newLength(oldLength, growth, oldLength + (2 << coder)),偏好增长量是「旧长度 + 2 字符」,字符口径仍等价于 新容量 = 旧容量 × 2 + 2(value.length >> coder 把字节长度换算回字符容量)。
扩容规则:新容量 = 旧容量 × 2 + 2,如果仍不够(growth 更大)则直接取所需最小容量。
举例:初始容量 16(默认),依次 append 导致扩容:
- 16 → 34(16×2+2)→ 70 → 142 → 286 → …
思考:为什么扩容公式是
×2 + 2而不是单纯的×2?那个+2是兜底用的——如果初始容量是 0,0×2还是 0,扩容就死循环了;+2保证无论旧容量多小都至少增长 2 个字符,同时让默认 16 起步的序列(16→34→70→142…)保持平滑的近似翻倍,把单次 append 摊薄成均摊 O(1)。
五、Javac 编译优化:+号拼接的魔法
常量折叠(编译期):
String s = "Hello" + " " + "World";
// 编译后等价于:
String s = "Hello World"; // javac 常量折叠
变量拼接(反编译验证):
String s = "Hello";
String result = s + " World" + "!";
编译后(javac -XD-printflat 或 javap -c 查看):
String s = "Hello";
String result = new StringBuilder()
.append(s)
.append(" World")
.append("!")
.toString();
循环中拼接的反模式:
// 反模式:每次循环创建新 StringBuilder
String result = "";
for (int i = 0; i < 1000; i++) {
result += i; // 编译为 new StringBuilder().append(result).append(i).toString()
}
正确做法是在循环外用 StringBuilder 复用同一个实例。
思考:循环里用
+=为什么是 O(n²)?因为每次迭代都 new 一个 StringBuilder、把已拼接的结果整段复制一遍,第 k 次复制要拷贝约 k 个字符,累计就是 1+2+…+n = O(n²)。把 StringBuilder 提到循环外复用同一个实例,每次 append 只在数组尾部原地写入(必要时才扩容),就降到了均摊 O(n)。
例外:JDK 9+ 引入了 StringConcatFactory 和 invokedynamic 机制。对于简单的两字符串拼接(s1 + s2),可能不走 StringBuilder 而直接调用 StringConcatHelper.simpleConcat(),减少一次临时对象分配。
思考:
invokedynamic拼接和 StringBuilder 方案差在哪?StringBuilder 每次拼接至少产生一个 StringBuilder + 一个 String 两个对象;StringConcatFactory直接算出目标长度、一次分配最终 String 再逐段写入,省掉中间临时对象。这也是为什么「简单拼接」能再省一次分配——但复杂/循环场景编译器仍会退回 StringBuilder。
六、JMH 性能对比
单线程 10 万次拼接:
| 方案 | 吞吐量 (ops/us) | 说明 |
|---|---|---|
| String + | ~0.02 | 每次创建新对象,O(n^2) |
| StringBuilder | ~250 | 单线程最优 |
| StringBuffer | ~200 | synchronized 开销约 20% |
多线程竞争场景(4 线程同时 append 10 万次):
| 方案 | 吞吐量 (ops/us) | 说明 |
|---|---|---|
| StringBuilder | ❌ 数据损坏 | 非线程安全,count 不一致 |
| StringBuffer | ~60 | 同步保证正确性 |
| ThreadLocal | ~240 | 每线程独立实例,最优 |
思考:StringBuffer 每个方法都加 synchronized,为什么吞吐只比 StringBuilder 低约 20%,而不是腰斩?因为现代 JVM 对无竞争锁做了「偏向锁/轻量级锁」优化,单线程反复加同一把锁几乎退化成一次 CAS 级别的开销;真正昂贵的是竞争——所以单线程只差 20%,多线程竞争时掉到 ~60 ops/us,那才是锁的真实代价。
七、选型决策树
需要线程安全?
├── 是 → 拼接次数多?
│ ├── 是 → ThreadLocal<StringBuilder>(高性能)
│ └── 否 → StringBuffer(代码简洁)
└── 否 → 内容会变?
├── 是 → StringBuilder(单线程最优)
└── 否 → String(不可变,可入池)
章末提问
Q1:String 为什么设计成不可变的?
结论:不可变是线程安全、常量池复用、hash 缓存三条收益的共同前提。因为 value 是 private final byte[]、类 final 不可继承、无任何 setter、所有”修改”方法都返回新对象,所以对象一旦创建内容永不变化——HashMap 才敢拿它当 key、JVM 才敢 intern() 复用、hashCode() 才敢缓存。
Q2:StringBuilder 和 StringBuffer 本质区别是什么?为什么性能只差约 20%? 结论:本质区别只在「是否同步」——StringBuffer 每个公开方法加 synchronized,StringBuilder 不加。因为单线程下无竞争锁被 JVM 优化成轻量级开销,所以只差约 20%;真正的差距在并发场景,StringBuffer 靠锁保证正确性但吞吐骤降,StringBuilder 则直接数据损坏。
Q3:JDK 9 的 Compact Strings 相比 JDK 8 做了什么?为什么能省内存?
结论:把存储从 char[](每字符固定 2 字节)换成 byte[] + coder 标志位。因为纯 Latin-1 字符每字符只需 1 字节,纯 ASCII 字符串内存直接减半;coder 是 final 字段被 JIT 折叠成单支,不带来运行时分支开销。
Q4:为什么循环里用 + 拼接是反模式?
结论:因为它每次都 new 一个 StringBuilder 并复制全部已拼内容,时间复杂度退化为 O(n²)。正确做法是循环外复用同一个 StringBuilder 实例,把写入摊薄成均摊 O(n)。
Q5:扩容公式为什么是 ×2 + 2?
结论:×2 实现近似翻倍的均摊 O(1) 扩容,+2 是兜底边界。因为容量为 0 时 0×2 仍为 0 会死循环,+2 保证任何容量下都至少增长,避免「零容量死循环」。