Skip to content
Go back

String、StringBuilder、StringBuffer 深入对比:从字节码到 JMH 性能

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,否则 StringBuilder。

底层深入(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

分析:valuebyte[] 存实际字节内容,coder 标记编码(LATIN1=0UTF16=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 字节」的按需分配。

charAtcoder 分派的真实实现:

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);  // 拷贝数组
    }
}

不可变性的价值:

思考: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。最终由 newCapacityArraysSupport.newLength(oldLength, growth, oldLength + (2 << coder)),偏好增长量是「旧长度 + 2 字符」,字符口径仍等价于 新容量 = 旧容量 × 2 + 2value.length >> coder 把字节长度换算回字符容量)。

扩容规则:新容量 = 旧容量 × 2 + 2,如果仍不够(growth 更大)则直接取所需最小容量。

举例:初始容量 16(默认),依次 append 导致扩容:

思考:为什么扩容公式是 ×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-printflatjavap -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+ 引入了 StringConcatFactoryinvokedynamic 机制。对于简单的两字符串拼接(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~200synchronized 开销约 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 保证任何容量下都至少增长,避免「零容量死循环」。


Share this post on:

Previous Post
hashCode与equals的契约——为什么重写equals必须重写hashCode?
Next Post
Java异常表的零开销机制