2553 字
约 8 分钟
19
S05-X06 · StringBuffer 与 StringBuilder:同步开销的真实差距
2024-12-31
2026-11-08

标准答案是"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. 自测

  1. StringBuffer 的 length() 也带 synchronized,这对性能意味着什么?
  2. 什么情况下 JIT 会把 StringBuffer 的锁开销消掉?
  3. JDK 9 起 a + b + c 编译成什么?为什么循环里的 += 仍要改?
  4. AbstractStringBuilder 的扩容公式是什么?默认容量下第二次扩容到多少?
  5. 紧凑字符串让 String 的底层字段变成了什么?怎么关闭?
  6. 为什么说 alloc.rate.norm 比 Score 更值得看?
S05-X06 · StringBuffer 与 StringBuilder:同步开销的真实差距
https://www.clxhxhhr.top/posts/5659/
作者
clxstart
发布于
2024-12-31
许可协议
CC BY-NC-SA 4.0
评论
0 条
还没有评论,先写一条吧。