news 2026/10/1 22:27:46

String、StringBuffer、StringBuilder 区别详解:从源码到面试

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
String、StringBuffer、StringBuilder 区别详解:从源码到面试

几乎每一场 Java 技术面试,都会抛出一个绕不开的问题:String、StringBuffer、StringBuilder 三者的区别是什么。我经历过的那些面试、代码评审里,这道题也最容易暴露一个人的真实功底。有人把标准答案背得滚瓜烂熟,结果被追问到源码层面就卡壳;有人每天都在写字符串拼接,却连默认容量和扩容机制都说不清。这篇文章从底层原理到实测性能、再到面试现场的答题节奏,一次性把这个经典考点拆透。

1. 先背下这套“标准答案”:三句话的区别怎么说才准确

1.1 面试官真正想考察的,不只是“背结论”

这道题表面上考的是概念记忆,实际上有更深的用意:看看你能不能从内存模型、API 设计、并发安全、性能取舍这几个角度,把一个日常用到的东西讲出体系。很多候选人的回答能覆盖“不可变、可变、线程安全、性能高低”几个关键词,但当我追问“String 为什么不可变”“StringBuffer 的锁加在哪里”“线程不安全到底会出什么事”时,场面就冷下来了。

你可以把这道题当作一面镜子。它照出的不光是知识储备,还有你在真实项目中做技术选型时的判断逻辑。一个只知道背结论的人,遇到“日志拼接用哪个”“缓存 key 用什么”这类实际问题时,往往只能凭感觉挑一个,而不是基于原理去做决定。

1.2 可以现场直接复述的版本

如果你现在就要去面试,我建议先记住下面这段话作为基本盘:

  • String 是不可变类,字符内容一旦创建就不能修改,任何修改操作都会生成一个新对象,所以它的线程安全是天然成立的,但大量拼接时性能很差。
  • StringBuffer 是可变类,字符内容保存在内部缓冲区里,主要方法都加了 synchronized,多线程环境下修改同一个实例能保证方法级安全。
  • StringBuilder 和 StringBuffer 的 API 几乎一致,但方法没有 synchronized,单线程场景下性能最好。
  • 日常开发默认优先使用 StringBuilder;只有明确需要多线程共享并修改同一个字符串缓冲区时,才考虑 StringBuffer。

这段话能应付绝大多数“简述三者区别”的问题。但你要清醒地意识到,它只是骨架,离“真正理解”还有距离。

1.3 为什么“标准答案”只是及格线

因为面试官听完这段,大概率会笑着追问一句:“那你解释一下……”后面的问题往往是这几个方向:

  • String 为什么能做到不可变?final 修饰到底锁住了什么?
  • StringBuilder 和 StringBuffer 内部是怎么存放字符的?扩容是怎么回事?
  • StringBuffer 加了 synchronized 就绝对安全了吗?
  • 为什么 String 适合做 HashMap 的 key,StringBuilder 不适合?
  • 编译器不是会自动优化字符串拼接吗?为什么还要手动用 StringBuilder?

这些问题没有一个能靠背诵回答。与其临场发懵,不如顺着后面的章节,把底层机制完整过一遍。你会发现,一旦理解透了,这道送分题反而能变成你的加分题。

2. 从 String 的 final 看起:所有区别的源头是“不可变性”

2.1 源码里的三个关键词

在 JDK 8 及以前的版本中,String 的核心源码简化后长这样:

public final class String implements java.io.Serializable, Comparable<String>, CharSequence { private final char value[]; // 真正保存字符的数组 private int hash; // 缓存 hashCode // 各种方法... }

JDK 9 以后为了压缩字符串的内存占用,把内部char[]改成了byte[] value,又加了一个coder字段来区分纯拉丁字符和双字节字符。但类 final、字段 final、数组私有这套结构并没有变。

这个结构里有三个关键点,每一层都缺一不可:

  • final class:String 不能被子类继承,防止子类通过覆写方法改变行为。
  • final char[] value:value 这个引用不能指向别的数组。
  • private:外部拿不到 value 数组的引用,也就无法直接修改数组元素。

但只谈 final 还不够。String 不可变的本质,是它的所有修改型方法都不会改动 value 数组里的元素,而是创建一个全新的数组和全新的 String 对象。concat、substring、replace 这些方法,走的全是“新建对象再返回”这条路,原来那个对象从头到尾纹丝不动。

2.2 不可变性带来的四重红利

不可变设计从来不是刻意刁难,它背后有非常实际的好处。

红利一:字符串常量池可以放心复用对象。JVM 会把“abc”这种字面量放进常量池,后面凡是出现相同字面量的地方,都可以直接指向同一个实例。如果 String 可变,任意一处改动都会波及其他引用者,池化复用就失去意义了。

红利二:hashCode 可以被缓存。String 内部有private int hash字段,第一次调用 hashCode() 时计算结果,以后直接返回缓存值,不用每次重新算。正是因为字符串内容永远不变,缓存才不会失效。这也是 String 能成为 HashMap 首选 key 的原因之一。

红利三:线程安全零成本。不可变对象天然没有数据竞争,任意线程读到的都是同一个稳定状态,不需要加锁也不需要 volatile。

红利四:安全防御。类名、文件路径、URL、用户名这类字符串,如果在运行中被意外篡改,很多安全机制都会出大问题。String 的不可变相当于一份“防篡改承诺”,让这些关键信息在传递过程中不会被外部修改。

2.3 不可变的代价:为什么循环拼接会慢成 O(n²)

有得必有失,String 不可变带来的代价就是“每次修改都要新建对象”。最典型的反模式是循环拼接:

String s = ""; for (int i = 0; i < 100_000; i++) { s += i; }

这段代码看起来人畜无害,实际执行时是另一个画面:每次执行s += i,JVM 都要申请一块新内存,把 s 之前已有的所有字符整体复制过去,再把 i 追加到末尾,最后让 s 指向新对象。字符串越长,单次复制的成本就越高;100000 次累积下来,总成本非常接近 O(n²)。

我习惯用一句话解释这种消耗:从 100 个字符拼到 200 个字符,要整体拷贝 100 个;从 99999 个字符拼到 100000 个,要整体拷贝 99999 个。越往后单次越贵,整体自然慢得离谱。

这里还有个不少人会迷的地方:Java 5 到 Java 8 之间,编译器会把字符串的“+”自动包装成 StringBuilder 操作。上面那段循环,内部大致会被改写成:

s = new StringBuilder().append(s).append(i).toString();

问题在于,这种改写发生在每一次迭代里。循环体内部照样会 new 出成千上万个 StringBuilder 临时对象,垃圾回收压力一点没少。JDK 9 以后字符串拼接改用 invokedynamic,运行时依然会按类似策略生成拼接结果,“每次迭代产生新字符串结构”这件事并没有本质改变。换句话说,编译器优化能解决“单行相加”的合理性,却解决不了“循环累积”的资源浪费。

注意:不要在大量循环拼接里依赖编译器替你优化。正确做法是显式创建 StringBuilder,把追加操作放进循环体。

3. 动态缓冲区机制:StringBuffer 和 StringBuilder 为什么更高效

3.1 父类 AbstractStringBuilder 做了什么

StringBuffer 和 StringBuilder 都继承自 AbstractStringBuilder,这个父类才是可变字符串的核心实现。简化后的结构大概是这样的:

abstract class AbstractStringBuilder implements Appendable, CharSequence { byte[] value; // JDK 8 以前是 char[] value int count; // 已经使用的字符数量 AbstractStringBuilder(int capacity) { value = new char[capacity]; } public AbstractStringBuilder append(String str) { if (str == null) { str = "null"; } int len = str.length(); ensureCapacityInternal(count + len); str.getChars(0, len, value, count); count += len; return this; } }

value 是真正存放字符的底层数组,count 记录当前实际用到了多少个字符。执行 append 时,新内容被直接写到 value 数组的指定位置,然后只更新一下 count。整个过程不会创建新对象,只是数组内容的写入和计数变更,和 String 那种“复制全部旧内容再建新对象”的方式相比,成本天差地别。

StringBuffer 和 StringBuilder 的全部区别,本质上只在方法上有没有 synchronized,底层的缓冲区原理是完全一样的。

3.2 默认容量 16 与扩容机制

new StringBuffer()或new StringBuilder()不传参数时,默认只会申请容量为 16 的字符数组。一旦追加的内容超过容量,就需要扩容。传统实现里的扩容策略大致是:

private int newCapacity(int minCapacity) { int newCapacity = (oldCapacity << 1) + 2; // 旧容量翻倍再加 2 if (newCapacity < minCapacity) { newCapacity = minCapacity; } // 上限检查,然后做整数组复制 }

和 ArrayList 扩容到 1.5 倍不同,这里走的是“翻倍 + 2”,核心目的都一样:尽量减少扩容次数。因为扩容势必伴随底层数组的全量复制,而全量复制是实打实的性能损耗。

举个例子,你要拼接 1000 个字符到空 StringBuilder 里。如果不指定容量,底层数组要从 16 开始一次次翻倍,中间大概要经过 6 次扩容,每一次都要把已有内容整体复制到新数组。如果你一开始就写成:

StringBuilder sb = new StringBuilder(1024);

扩容次数就能压到 1 次甚至 0 次。这也是很多性能规范里都有的建议:能预估字符串最终大小,就尽量在构造时把容量传进去。

3.3 StringBuffer 转 String:toString 是唯一出口,别踩 equals 的坑

StringBuffer 和 StringBuilder 的内容最终总要落回 String,标准出口就是 toString()。默认实现下,它会以当前缓冲区内容为快照创建一个新 String:

public String toString() { return new String(value, 0, count); }

这里有两个值得注意的细节。

第一,返回的是“快照”。转换完成后,如果继续修改 StringBuffer 或 StringBuilder,之前拿到的 String 对象不会跟着受影响,这正是 String 不可变性的延续。

第二,也是很多人踩过的坑:StringBuffer 和 StringBuilder 都没有重写 equals() 和 hashCode(),它们用的还是 Object 的“地址比较”。所以这段代码结果是 false:

StringBuilder sb = new StringBuilder("abc"); System.out.println(sb.equals("abc")); // false System.out.println(sb.toString().equals("abc")); // true,必须先转 String

实际开发里,如果你直接把 StringBuilder 放进 Set,或者拿它和常量字符串比较,都会因为“不能正确 equals 和 hashCode”而出现各种诡异结果。正确的姿势是:需要比较内容时先 sb.toString(),需要放进容器时也先转成 String 再放。

顺带提一个内部细节:早年 OpenJDK 给 StringBuffer 加过 toStringCache 缓存,让缓冲区没变动的情况下重复调用 toString() 能复用底层数组,减少复制。JDK 9 引入紧凑字符串后这个缓存被移除,实现又做了调整。这类版本细节面试中知道算锦上添花,不知道也不影响主线理解。

3.4 编译器会自动优化字符串相加?什么情况下可以放心写

String 用“+”并不是完全不能碰。像这种一条语句内的拼接:

String full = firstName + " " + lastName;

编译阶段会被改造成类似new StringBuilder(firstName).append(" ").append(lastName).toString()的形式,性能上没有大问题。JDK 9 以后甚至改成了 invokedynamic 拼接,运行时按策略生成结果,更灵活。

真正需要手动写 StringBuilder 的场景是:循环拼接、动态累积、多步骤构建同一份长文本。判断标准很简单:如果一行代码能表达完,直接用“+”可读性更好;如果是在循环或高频路径里反复追加内容,就老老实实自己创建 StringBuilder。为了“优化”而把所有加号改成 append,既难看又收益甚微,属于反向优化。

4. synchronized 锁在哪里:线程安全这句话,三个字都容易被误解

4.1 StringBuffer 的源码证据:方法级同步

直接说“StringBuffer 线程安全、StringBuilder 线程不安全”没有说服力,看源码才有实感。StringBuffer 里典型的方法长这样:

public synchronized int length() { return count; } public synchronized StringBuffer append(String str) { super.append(str); return this; }

StringBuilder 里同样签名的方法长这样:

public StringBuilder append(String str) { super.append(str); return this; }

唯一的实质差异,就是 synchronized 关键字的存不存在。StringBuffer 的 append、insert、delete、replace、reverse、toString 等方法基本都加了同步修饰,StringBuilder 则完全没有。这就是两者线程安全差别的直接来源。

4.2 StringBuilder 并发写入的典型后果:结果悄悄变坏

很多面试者提到“线程不安全”,下意识会说“会抛异常”“程序会崩”。这在 StringBuilder 场景里并不准确。它并发写入的典型表现是:最终字符串比预期短、内容乱序、部分追加丢失,但 JVM 不一定抛任何异常。

原因很好理解。count 的更新和数组内容的写入不是原子操作。两个线程同时执行 append 时,可能写出同一个数组位置,也可能互相覆盖 count 的更新结果。多个线程反复写入,最终拿到的就是一个状态不一致的字符串。

从工程心态上讲,这种“悄悄变坏”比直接报错更麻烦。如果线上日志或协议报文因为共享同一个 StringBuilder 出现内容异常,第一反应要检查的就是:这个对象是不是被多个线程同时写入了。

4.3 方法级安全不等于复合操作安全:一个经典翻车现场

StringBuffer 加了 synchronized,但它的粒度和范围必须说清楚:只保证单个方法内部的原子性,不保证多个方法组合起来的原子性。举个例子:

if (sb.length() == 0) { sb.append("default"); }

两个线程可能同时判断 length() 为 0,然后双双执行 append,最终缓冲区里出现两个“default”。length 检查和 append 之间并没有一把整体锁。

如果要保证这种复合逻辑的原子性,要么手动加锁:

synchronized (sb) { if (sb.length() == 0) { sb.append("default"); } }

要么干脆避免共享这个对象,让每个线程持有自己的 StringBuilder,最后再合并结果。

4.4 工程选型:什么时候用 StringBuffer,什么时候用 StringBuilder

结合我的实际开发经验,给一套简单可靠的选型标准:

  • 方法内创建的临时变量,只会被当前线程使用:一律用 StringBuilder,没有锁开销,代码最直白。
  • 实例字段或静态字段需要跨线程共享,且确实存在并发写入:用 StringBuffer,但复合操作还需要外部锁。
  • 只是单纯拼接日志、SQL、URL 参数,且拼接过程发生在单一线程内:永远是 StringBuilder 优先。
  • 如果共享字符串缓冲区的读取和写入都很频繁,还要考虑更高层的锁设计或换用不可变方案。StringBuffer 不是万能安全气囊。

一个纪律性原则是:默认 StringBuilder,只有明确碰到“跨线程共享写”时才考虑 StringBuffer,并且最好在注释里写清并发背景,避免后人看不懂为什么这里不用性能更好的那个。

5. 实测一次 10 万次拼接:性能差距到底有多大

5.1 测试代码:快速验证量级差距

先声明,下面不是一份严谨的 JMH 基准,而是我平时快速验证量级差距的小程序。想要严谨结论,还是应该用 JMH 做好预热和多次迭代:

int times = 100_000; long t1 = System.nanoTime(); String s = ""; for (int i = 0; i < times; i++) { s += i; } long t2 = System.nanoTime(); StringBuffer buffer = new StringBuffer(); for (int i = 0; i < times; i++) { buffer.append(i); } long t3 = System.nanoTime(); StringBuilder builder = new StringBuilder(); for (int i = 0; i < times; i++) { builder.append(i); } long t4 = System.nanoTime(); System.out.println("String: " + (t2 - t1) / 1_000_000 + " ms"); System.out.println("Buffer: " + (t3 - t2) / 1_000_000 + " ms"); System.out.println("Builder: " + (t4 - t3) / 1_000_000 + " ms");

这段代码的价值在于把三者放进同一个 JVM 进程里交替执行,能快速排出量级关系。真正要出报告,必须要预热、多轮循环、控制 GC,否则 JIT 编译和垃圾回收会把数据搅乱。

5.2 典型结果和解读

平时在我自己的测试机器上,结果大致是这种量级(不同 JDK、不同机器上数值浮动很大,但相对关系稳定):

拼接方式10 万次耗时量级对象产生情况
String +=数千毫秒量级创建约 10 万个中间 StringBuilder 和 10 万个中间 String
StringBuffer.append几十毫秒量级少数几次扩容时的数组复制
StringBuilder.append几十毫秒量级,通常略快与 StringBuffer 几乎一致,没有锁争用

String 慢的根源不在于“代码执行”,而在于对象分配和反复复制。它每拼接一次就产生一两个新对象,循环里海量的垃圾对象会持续压榨 GC。StringBuffer 和 StringBuilder 平时都在原数组上写入,只有容量不足时才复制一次,差距自然拉开几个数量级。

StringBuilder 比 StringBuffer 快多少,常见结果是快百分之几到百分之二三十,取决于 JVM 对无竞争锁的优化程度。结论是:别因为“StringBuffer 线程安全”就觉得它性能差到不可用,其实两者之间的差距,远没有 String 与它们之间的差距夸张。

5.3 预分配容量:进一步减少扩容复制

同一个测试里,如果预先知道最终字符串会很大,用带容量构造器还能再提速:

StringBuilder builder = new StringBuilder(2_000_000);

对接近百万字符的场景,这样可以省掉多次整数组复制;对几十个字符的小字符串,这种优化的收益可以忽略不计。我的做法是:只有在高频路径、大文本拼接、日志组装这类明显会大量扩容的地方才做预分配,普通代码优先保持可读性。

5.4 别把一次测试当神话

这类性能测试最容易误导人的地方,是它只验证了“循环累积”这一种场景。有些人看到数据后,恨不得把代码里所有的“+”全部替换成 StringBuilder,这是矫枉过正。单行拼接的优化收益非常小,还白白牺牲代码可读性。另一个误区是拿 StringBuilder 去替换 String.format,格式化的本质是模板编排,该用 formatter 就继续用 formatter,不要为了数据好看而牺牲代码意图。

6. 把这道面试题答得更高级:准备三个追问方向

6.1 追问一:String 常量池、new String 和 intern

面试官如果问你“String 除了不可变还有什么特别之处”,大概率是想聊字符串常量池。最经典的一段例子:

String a = "abc"; String b = "abc"; System.out.println(a == b); // 通常 true,字面量复用常量池对象 String c = new String("abc"); System.out.println(a == c); // false,new 出来的是堆上新对象 System.out.println(a == c.intern()); // true,intern 返回常量池里那个引用

String 不可变是常量池能安全复用对象的前提。如果它能被修改,池化机制根本站不住脚。StringBuffer 和 StringBuilder 调用 toString() 出来的结果默认不进常量池,需要手动 intern,但 intern 本身有开销,实际项目里很少主动调用。

6.2 追问二:StringBuffer 真的“绝对线程安全”吗

如果面试官抛出这句话,正确答案是否定的。准确说法是:StringBuffer 提供方法级同步,单个 append、insert、delete 内部是安全的,但不保证多个方法组合后的整体安全。要答出“复合操作需要外部锁”这一层,才算真正理解 synchronized 的粒度。太多八股文答案只写四个字“线程安全”,一旦被追问就露馅。

6.3 追问三:为什么 String 适合做 HashMap 的 key,StringBuilder 不适合

String 适合做 key,至少有三层原因叠加:不可变保证 hashCode 稳定;hashCode 缓存减少重复计算;equals 比较的是内容而不是地址。StringBuilder 这三个条件一个都不占:内容可变,hashCode 是 Object 的地址值,equals 也不是内容比较。如果真有人把 StringBuilder 塞进 HashMap,内容一变,原来的映射就永远找不到了,这几乎注定是一颗隐蔽炸弹。

6.4 现场回答节奏:结论、依据、落地缺一不可

我既做过候选人,也当过面试官,观察到同一个规律:这类问题答得好不好,不看内容多少,而看层次。比较好的表达节奏是三步走:

  • 第一步给结论:String 不可变、StringBuffer 可变且方法级同步、StringBuilder 可变但无同步。
  • 第二步给依据:提一下源码里的 synchronized、AbstractStringBuilder 的字符缓冲区、默认容量与扩容机制。
  • 第三步给落地建议:日常开发默认 StringBuilder,多线程共享写才考虑 StringBuffer,复合操作要手动加锁。

这个顺序既直接回应了提问,也顺便展示了“结论—原理—工程”的思维方式。我见过不少人在这个题目上栽跟头,不是因为不会背,而是因为只背了第一层结论。真正拉开差距的,永远是你能不能在三个层次之间自如切换。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/1 22:25:03

基于SOE蛇优化算法的多时段随机配电网重构策略

配电网重构这事儿&#xff0c;在电力系统优化里属于那种看着简单、做着头疼的组合优化难题。说它简单&#xff0c;是因为物理概念人人都懂——把联络开关合上、把分段开关断开&#xff0c;网络拓扑一变&#xff0c;潮流重新分配&#xff0c;网损和电压就跟着变了&#xff1b;说…

作者头像 李华
网站建设 2026/10/1 22:24:26

Windows系统安装Redis 7的三种可靠方案与排坑指南

先说点实际的。Redis 官方其实一直不提供 Windows 原生安装包&#xff0c;所以“redis7 for windows的安装教程”这个需求&#xff0c;十个人里有九个会先卡在“去哪下载”“下了哪个版本”“怎么配置才能跑起来”这三件事上。这篇文章我不打算只丢给你一个压缩包链接就完事&am…

作者头像 李华
网站建设 2026/10/1 22:24:20

Python+OpenCV+深度学习实现人脸情绪识别:从FER2013训练到摄像头实时推理

简介&#xff1a;这是一套面向高校期末大作业场景的 Python 人脸情绪识别项目代码&#xff0c;基于 OpenCV 与深度学习模型完成人脸检测、表情特征提取与情绪分类&#xff0c;整体难度适中&#xff0c;适合计算机视觉或人工智能相关课程的学生参考和二次开发。压缩包为 zip 格式…

作者头像 李华
网站建设 2026/10/1 22:22:52

JSP+MVC+MySQL图书购物系统:JavaWeb毕设实战与核心链路解析

简介&#xff1a;这是一套基于JSP与MVC设计模式、以MySQL为数据库的网上图书购物系统源码&#xff0c;面向Java Web初学者与进阶学习者&#xff0c;可作为毕业设计、课程设计、大作业或工程实训的参考项目。压缩包共76个文件&#xff0c;约47.8MB&#xff0c;包含14个jsp页面文…

作者头像 李华
网站建设 2026/10/1 22:22:21

ECG心电信号分类实战:从.mat读取到随机森林评估全流程

简介&#xff1a;面向医学数据分析与生物医学信号处理学习者&#xff0c;这份资源提供了一套基于Python和MATLAB的心电图&#xff08;ECG&#xff09;分类实现&#xff0c;覆盖数据预处理&#xff08;如去噪、滤波、基线漂移校正&#xff09;、特征提取&#xff08;如RR间隔、Q…

作者头像 李华
网站建设 2026/10/1 22:21:54

校医院管理平台毕设实战:SpringBoot+Vue全栈开发与答辩指南

每年到了毕业设计选题季&#xff0c;总有一批学弟学妹来找我问同一个问题&#xff1a;到底选什么题目能既顺利通过答辩&#xff0c;又不至于把自己折腾到崩溃。见多了那些一上来就冲电商秒杀、社交聊天、甚至AI算法的题目&#xff0c;最后却在开题阶段就卡住的情况&#xff0c;…

作者头像 李华