String / StringBuilder / StringBuffer|别再乱用字符串拼接,底层原理一次讲透

基础不牢,地动山摇

大家好,这里是后端基础拾光集。

前面有一篇《实战复盘: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;
}

不可变的关键点:

  1. String 是 final class,不能被继承,防止子类破坏不可变语义。
  2. value 是 private final,引用不能指向新数组。
  3. String 不提供任何修改 value 元素的方法。
  4. 所有看似修改的方法,如 substring、replace、concat,都返回新 String。
  5. 内部数组不对外暴露,正常 API 拿不到 value。
  6. 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 快的原因:

  1. 可变数组:原地追加,不每次创建新 String。
  2. 无锁 :没有 synchronized,没有锁竞争和同步开销。
  3. 减少临时对象:循环拼接时只用一个 StringBuilder。
  4. 扩容摊还成本低:按倍数扩容,均摊下来 append 接近 O(1)。
  5. 支持链式调用 :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,数组扩容和写入位置会互相覆盖,导致数据丢失。

✅核心总结

  1. String:封存档案,不可变,修改生成新对象。安全、可共享、hash 稳定,但频繁拼接慢;适合少量拼接和常量定义。
  2. StringBuilder:私人活页夹,可变、无锁 ;单线程大量拼接首选。
  3. StringBuffer:公共活页夹,可变、加 synchronized 锁;多线程并发拼接场景使用。
  4. 循环内拼接禁止+=,必须用 StringBuilder。
  5. 少量固定拼接直接+没问题,编译器会优化,不必过度强制替换。

📝本文属于专栏「后端基础拾光集」系列。 这个专栏结合生活化案例、线上踩坑实例讲解 Java 基础。 同系列已经更新:ArrayList & LinkedList 源码、HashMap、红黑树、Java 泛型、异常体系、Integer 缓存池、equals & hashCode、static、final、接口 vs 抽象类、Lambda 表达式、String 的 6 大误区。 专栏持续更新 Java 后端基础、源码、面试踩坑干货,欢迎关注一起交流后端学习、面试踩坑经验。

相关推荐
傲世仙尊1 小时前
类和对象中-构造函数与析构函数把初始化清理自动化
java·运维·自动化
她的男孩1 小时前
权限通了、列表有数,一点分页就炸Forge Admin 用 COUNT(1) 扛住复杂待办
java·spring boot·mybatis
大勇前进1 小时前
CSS 选择器优先级详解,一次搞懂权重计算规则
后端
骑着蜗牛撵大象3271 小时前
豆包 Seed 驱动的记账智能体:从拍票抽取到月报生成的全链路落地记录
java·前端·javascript·结构化输出·豆包seed·多模态识别·记账系统
姜鱼问生1 小时前
fail2ban 实战:从默认配置到生产可用
后端·算法·架构
憧憬成为java架构高手的小白1 小时前
langchain4j手敲理解
java·langchain
10mAh2 小时前
【Java】ArrayList 遍历删除为什么报错?三种写法与漏删复现
java·开发语言·windows
JavaGuide2 小时前
DeepSeek Harness 重磅更新,Codex、Claude Code 子智能体进官方插件列表了,还有 Git 工作树和思考翻译!
前端·后端
thefool1122662 小时前
多线程 4
java