标准答案是"StringBuffer 线程安全、StringBuilder 不安全,单线程用 StringBuilder"。这话不错,但它把两件事混在了一起:同步关键字的语义差异,和同步带来的性能开销。前者永远成立,后者在 JIT 介入后经常小到测不出来——很多"快 5 倍"的结论,其实是测量方法自己产生的偏差。
这篇把两者的实现差异、+ 的编译期展开、扩容公式、String 底层的紧凑字符串变化,以及怎么用 JMH 量这件事,按顺序拆一遍。
1. 实现差异:一台实现,两种外壳
JDK 9 之后两者都继承同一个 AbstractStringBuilder,区别只在外壳方法上要不要加锁。
// JDK 17 源码签名
public final class StringBuilder extends AbstractStringBuilder implements java.io.Serializable, Comparable<StringBuilder>, CharSequence {
public StringBuilder append(String str) { // 无 synchronized
super.append(str);
return this;
}
}
public final class StringBuffer extends AbstractStringBuilder implements java.io.Serializable, Comparable<StringBuffer>, CharSequence {
@Override
public synchronized StringBuffer append(String str) { // 有 synchronized
toStringCache = null;
super.append(str);
return this;
}
}
| 维度 | StringBuilder | StringBuffer |
|---|---|---|
| 继承 | AbstractStringBuilder |
同 |
append / insert / delete |
无锁 | synchronized 修饰 |
toString() |
直接构造新字符串 | 有 toStringCache,修改时置空 |
| 复合操作原子性 | 不保证 | 单次方法调用保证 |
length() / charAt |
无锁 | 也带 synchronized |
看最后两行:StringBuffer 连 length()、charAt() 都上锁。而"线程安全"在这里名不副实——两次 append 之间照样能被插入,复合原子性仍得自己加锁。它的价值在单次调用原子,不在业务级安全。
2. 同步开销:JIT 会替你消掉一部分
单线程里用 StringBuffer,最坏情况是每次调用都走一遍锁路径。但 -XX:+EliminateLocks(默认开启)会做锁消除:JVM 证明对象不逃逸出当前线程时,synchronized 块被直接删掉。
想确认锁相关开关的最终值,用 java -XX:+PrintFlagsFinal -version | grep -iE 'EliminateLocks|BiasedLocking'。
| 场景 | 锁能否被消除 | 结果 |
|---|---|---|
| 局部变量,不外传 | 能 | 与 StringBuilder 几乎无差 |
| 方法内构造、返回字符串 | 能 | 同上 |
| 作为参数传进别的方法 | 可能不能 | 开销出现 |
| 静态字段 / 成员字段 | 不能 | 每次都真上锁 |
| 多线程共享 | 不能 | 语义上也需要 |
所以"同步开销有多真实"取决于逃逸分析:方法内部 new StringBuffer() 拼完就 toString() 返回,两者差距落在噪声量级;真被拖慢的是把它当成员字段或共享的场景。网文里的对比数据互相打架,多半是测的不是同一件事。
结论不变:单线程用 StringBuilder,不是因为它快很多,而是没理由付额外代价,语义上也更诚实。
3. + 的展开:编译期折叠与循环里的差别
+ 不总是慢,也不是总走 StringBuilder。
String a = "ab" + "cd"; // 编译期直接折叠成 "abcd"
String b = prefix + suffix; // 运行期拼接
for (int i = 0; i < 1000; i++) { s += i; } // 每次迭代都产生新对象
$ javap -c Demo | head -20
# 典型输出形态(JDK 17):
# 常量折叠的表达式根本不出现在字节码里,只剩 ldc "abcd"
# 变量拼接出现 invokedynamic #7, 0 // InvokeDynamic #0:makeConcatWithConstants
JDK 9 起 javac 默认用 invokedynamic + StringConcatFactory 做字符串拼接,取代 JDK 8 的 StringBuilder 拼接链。这意味着两件事:
| 写法 | JDK 8 编译结果 | JDK 9+ 编译结果 | 是否仍需手写 StringBuilder |
|---|---|---|---|
"a" + "b" |
常量折叠 | 常量折叠 | 否 |
a + b + c(一次性) |
StringBuilder 链 |
invokedynamic |
一般不需要 |
循环内 s += i |
每轮 new StringBuilder |
每轮一次 invokedynamic 调用 |
需要 |
| 条件分支拼接 | 多个 builder | 一次 makeConcatWithConstants |
一般不需要 |
关键在循环那一行:invokedynamic 优化的是"一次拼接里的分配次数",不会把循环里的多次拼接合并。循环上万次时每轮都产生新 String,这才是 GC 压力的来源。
4. + / concat / join / StringBuilder 怎么选
| 方式 | 适用 | 注意点 |
|---|---|---|
+ |
少量、一次性、可读性优先 | 循环里禁用 |
String.concat |
两个字符串相加 | 参数为 "" 时返回 this |
String.join |
集合/数组按分隔符连接 | 内部用 StringJoiner(基于 StringBuilder) |
StringBuilder |
循环内、动态长度、性能敏感 | 预设容量 |
String.format / MessageFormat |
可读模板、日志 | 慢得多,不进热点路径 |
String.join 知道元素个数,StringJoiner 内部会预留容量;手写循环不预设容量就会走扩容。
5. 扩容策略:(oldCapacity << 1) + 2
AbstractStringBuilder 的扩容公式按 JDK 17 源码是:
private int newCapacity(int minCapacity) {
int oldCapacity = value.length >> coder; // 按字符数算,coder 为 UTF16 时减半
int newCapacity = (oldCapacity << 1) + 2; // 两倍加二
if (newCapacity - minCapacity < 0) newCapacity = minCapacity;
if (newCapacity - MAX_ARRAY_SIZE > 0) return hugeCapacity(minCapacity);
return newCapacity;
}
容量序列是 16 → 34 → 70 → 142,每次约两倍;接近 Integer.MAX_VALUE - 8 后走 hugeCapacity,可能直接抛 OutOfMemoryError。
过程是"复制到新数组",代价与已有长度成正比。默认容量 16,追加第 17 个字符就复制一次,之后在 34、70、142…… 处再复制;一段 2KB 的文本会经历约 8 次数组复制。
数字不大,但在每秒百万次调用下就不再是噪声。
6. 预设容量:唯一稳赚的优化
StringBuilder sb = new StringBuilder(1024); // 有把握:直接给足
StringBuilder sb2 = new StringBuilder(parts.size() * 32); // 长度可估:给略大的初值
StringBuilder sb3 = new StringBuilder(a.length() + b.length()); // 已知来源:按长度预留
ensureCapacity(int) 在容量已足够时不复制、直接返回;别在循环里反复调,那等于把判断成本乘上循环次数。
7. JDK 9 之后 String 的底层变了
紧凑字符串(Compact Strings,JEP 254)让 String 从 char[] 改成 byte[] value 加一个 byte coder:
public final class String {
private final byte[] value; // 不再是 char[]
private final byte coder; // LATIN1 = 0, UTF16 = 1
}
| 维度 | JDK 8 | JDK 9+ |
|---|---|---|
| 存储 | char[](每字符 2 字节) |
byte[] + coder,纯 ASCII 每字符 1 字节 |
length() |
数组长度 | value.length >> coder |
indexOf 等 |
按 char 扫描 | 按 coder 选择策略,Latin-1 更快 |
| 关闭方式 | — | -XX:-CompactStrings |
对 AbstractStringBuilder 的影响 |
无 coder 概念 | 扩容计算要 >> coder |
最大的影响是堆占用:纯 ASCII 文本直接省一半。快照里的 char[] 占大头说明跑的是 JDK 8;JDK 17 上对应 byte[],别按老文章找 char[]。
8. 怎么量才算量过
System.currentTimeMillis() 前后包一段代码,得到的数字基本不可用。JIT 会把死代码删掉、会把常量折叠、会把循环优化掉,测试代码测的是编译器的心情。
@State(Scope.Thread)
public class ConcatBench {
private String a, b;
@Setup
public void setup() { a = "x".repeat(64); b = "y".repeat(64); }
@Benchmark
@BenchmarkMode(Mode.AverageTime)
@OutputTimeUnit(TimeUnit.NANOSECONDS)
@Fork(2)
@Warmup(iterations = 3, time = 1)
@Measurement(iterations = 5, time = 1)
public String builder(Blackhole bh) {
StringBuilder sb = new StringBuilder(a.length() + b.length());
sb.append(a).append(b);
return sb.toString();
}
}
$ mvn -q clean package && java -jar target/benchmarks.jar -prof gc
# 输出给出 Score(平均耗时)与 ± 误差,以及 gc.alloc.rate.norm(每次操作分配字节数)
偏差来自四处:没有预热(测的是解释执行)、结果没被使用(JIT 删掉整段逻辑)、单次运行(受 GC 与 CPU 频率影响)、只看时间不看分配。对应用 @Warmup、Blackhole.consume、@Fork 加多轮 @Measurement、-prof gc。
最后一行常被忽略:两者的时间差可能落在误差里,但分配字节数的差异确定且可复现,做判断时 alloc.rate.norm 往往比 Score 更有说服力。
9. 排错:五种常见误用
| 现象 | 原因 | 处理 |
|---|---|---|
| 循环拼接后 CPU 高、YGC 频繁 | 每轮产生新 String |
提出循环,用 StringBuilder |
大字符串拼接时 OutOfMemoryError |
扩容时新旧数组同时存在 | 预设容量,或改为流式写出 |
日志里出现 StringBuffer 且热点靠前 |
作为静态成员被共享 | 改局部变量,或换日志框架的懒拼接 |
| 基准测试数字每次差几倍 | 无预热、无 fork | 改用 JMH |
toString() 频繁调用 |
每轮都复制字符数组 | 改为只在边界调用一次 |
第三条很典型:static final StringBuffer 当共享缓冲区,每次 append 都抢锁,jstack 里大量线程停在 StringBuffer.append 的 BLOCKED 上。它不报错,只会慢。
10. 边界:什么时候这些都不重要
| 情况 | 是否值得优化 | 理由 |
|---|---|---|
| 每次请求拼接几次短串 | 不值得 | 现代 JVM 下加起来是微秒级 |
单个 String 只有几个字符 |
不值得 | 分配成本主要看对象头 |
| 每秒百万次调用的热点 | 值得 | 差异会被放大 |
| 拼接超大文本(MB 级) | 值得,但改方案 | 用流、Writer,别在内存里堆 |
| 只是为可读性 | 不值得 | + 的可读性更好 |
判断标准只有一条:这段代码在不在热点路径上。采样几分钟,热点表里没有它,任何微优化都是浪费。
11. 自测
StringBuffer的length()也带synchronized,这对性能意味着什么?- 什么情况下 JIT 会把
StringBuffer的锁开销消掉? - JDK 9 起
a + b + c编译成什么?为什么循环里的+=仍要改? AbstractStringBuilder的扩容公式是什么?默认容量下第二次扩容到多少?- 紧凑字符串让
String的底层字段变成了什么?怎么关闭? - 为什么说
alloc.rate.norm比 Score 更值得看?