ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

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

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 的 keyStringBuilder 不适合编译器不是会自动优化字符串拼接吗为什么还要手动用 StringBuilder这些问题没有一个能靠背诵回答。与其临场发懵不如顺着后面的章节把底层机制完整过一遍。你会发现一旦理解透了这道送分题反而能变成你的加分题。2. 从 String 的 final 看起所有区别的源头是“不可变性”2.1 源码里的三个关键词在 JDK 8 及以前的版本中String 的核心源码简化后长这样public final class String implements java.io.Serializable, ComparableString, CharSequence { private final char value[]; // 真正保存字符的数组 private int hash; // 缓存 hashCode // 各种方法... }JDK 9 以后为了压缩字符串的内存占用把内部char[]改成了byte[] value又加了一个coder字段来区分纯拉丁字符和双字节字符。但类 final、字段 final、数组私有这套结构并没有变。这个结构里有三个关键点每一层都缺一不可final classString 不能被子类继承防止子类通过覆写方法改变行为。final char[] valuevalue 这个引用不能指向别的数组。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 iJVM 都要申请一块新内存把 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 转 StringtoString 是唯一出口别踩 equals 的坑StringBuffer 和 StringBuilder 的内容最终总要落回 String标准出口就是 toString()。默认实现下它会以当前缓冲区内容为快照创建一个新 Stringpublic String toString() { return new String(value, 0, count); }这里有两个值得注意的细节。第一返回的是“快照”。转换完成后如果继续修改 StringBuffer 或 StringBuilder之前拿到的 String 对象不会跟着受影响这正是 String 不可变性的延续。第二也是很多人踩过的坑StringBuffer 和 StringBuilder 都没有重写 equals() 和 hashCode()它们用的还是 Object 的“地址比较”。所以这段代码结果是 falseStringBuilder 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 万个中间 StringStringBuffer.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); // falsenew 出来的是堆上新对象 System.out.println(a c.intern()); // trueintern 返回常量池里那个引用String 不可变是常量池能安全复用对象的前提。如果它能被修改池化机制根本站不住脚。StringBuffer 和 StringBuilder 调用 toString() 出来的结果默认不进常量池需要手动 intern但 intern 本身有开销实际项目里很少主动调用。6.2 追问二StringBuffer 真的“绝对线程安全”吗如果面试官抛出这句话正确答案是否定的。准确说法是StringBuffer 提供方法级同步单个 append、insert、delete 内部是安全的但不保证多个方法组合后的整体安全。要答出“复合操作需要外部锁”这一层才算真正理解 synchronized 的粒度。太多八股文答案只写四个字“线程安全”一旦被追问就露馅。6.3 追问三为什么 String 适合做 HashMap 的 keyStringBuilder 不适合String 适合做 key至少有三层原因叠加不可变保证 hashCode 稳定hashCode 缓存减少重复计算equals 比较的是内容而不是地址。StringBuilder 这三个条件一个都不占内容可变hashCode 是 Object 的地址值equals 也不是内容比较。如果真有人把 StringBuilder 塞进 HashMap内容一变原来的映射就永远找不到了这几乎注定是一颗隐蔽炸弹。6.4 现场回答节奏结论、依据、落地缺一不可我既做过候选人也当过面试官观察到同一个规律这类问题答得好不好不看内容多少而看层次。比较好的表达节奏是三步走第一步给结论String 不可变、StringBuffer 可变且方法级同步、StringBuilder 可变但无同步。第二步给依据提一下源码里的 synchronized、AbstractStringBuilder 的字符缓冲区、默认容量与扩容机制。第三步给落地建议日常开发默认 StringBuilder多线程共享写才考虑 StringBuffer复合操作要手动加锁。这个顺序既直接回应了提问也顺便展示了“结论—原理—工程”的思维方式。我见过不少人在这个题目上栽跟头不是因为不会背而是因为只背了第一层结论。真正拉开差距的永远是你能不能在三个层次之间自如切换。
返回列表