图 1:本文封面,三个罐子暗喻三者的核心差异:不可变、加锁、无锁。
先说明属性:这是一篇经典题解析,题目出自本地历史面试资料,与任何公司的当前招聘无关。String 三兄弟是 Java 面试的出场率之王,题面一句话,但它串联着不可变性、内存分配、线程安全三条主线,答出层次和背出定义是两回事。
想象面试现场:「String、StringBuffer、StringBuilder 有什么区别?什么时候用哪个?」
第一步,从 String 的不可变性说起。
String 的内容一旦创建就不能改。你写的每一次「修改」——拼接、替换、截取——本质上都是创建了一个新对象,旧对象原封不动地留在堆里等垃圾回收。不可变带来两个好处:天然线程安全,任何线程看到的都是同一个值,不用同步;以及可以安全地做缓存和共享,比如字符串常量池。代价同样明显:频繁拼接会产生大量中间垃圾对象。
String s = "a";
s = s + "b"; // 产生新对象 "ab","a" 成为垃圾
s = s + "c"; // 又产生新对象 "abc","ab" 成为垃圾
循环里这么写一万次,就制造了一万个中间字符串。这正是另外两兄弟存在的理由。
第二步,StringBuffer 与 StringBuilder:可变缓冲区。
这两个类的思路一致:内部维护一个可扩容的字符数组,append 直接在数组尾部追加,不创造新对象,容量不够才扩容一次(默认初始容量 16,扩容为新容量的两倍加 2)。把一万次拼接放进同一个缓冲区,中间垃圾对象从一万个降到几乎为零。
两者的区别只有一条:StringBuffer 的方法加了 synchronized,多个线程同时操作同一个缓冲区是安全的;StringBuilder 没有任何同步,官方文档的定性很明确——它是为单线程场景设计的替代品,API 与 StringBuffer 完全兼容,去掉了同步开销,所以更快,「在大多数实现下应优先使用」。
第三步,选型决策。
内容确定不变:用 String。常量、配置项、字典 key,不可变性是优点不是缺点。多线程共享同一个缓冲区:用 StringBuffer,比如多个线程往同一个日志缓冲里追加。其余一切拼接场景:用 StringBuilder——方法内局部变量本来就是单线程的,同步纯属浪费。经验法则一句话:默认 StringBuilder,证明需要线程安全才换 StringBuffer,证明内容不变才用 String。
第四步,两个容易讲错的点。
一是「加号拼接一定会产生大量对象吗?」现代编译器会把一行内的常量拼接折叠成常量、把简单拼接优化成 StringBuilder 链,单行拼接不必有心理负担;真正的坑在循环里用加号——每次循环都新建一个 StringBuilder 再 toString,优化救不了你。
二是「StringBuffer 加锁了就绝对线程安全吗?」方法级同步只保证单个调用不撕裂,「先检查长度再 append」这类组合操作依然需要外部同步。文档还有一句细节:向缓冲区追加一个被多线程共享的源序列时,调用方要保证源序列在操作期间不变——锁只锁缓冲区,不锁数据源。
可能的追问。
「StringBuilder 拼完 toString 之后呢?」toString 会新建一个 String 对象装当前内容,之后再改缓冲区不影响已经生成的 String——从这一刻起,可变的世界又回到不可变。「缓冲区的扩容规则呢?」默认容量 16,不够时扩容为旧容量的两倍加 2,还能用 ensureCapacity 主动预留——能预估最终长度时一次性给足容量,连扩容拷贝都省掉。「为什么 String 不可变还敢到处传?」正因为不可变,才能放心共享:常量池里的同一个 "abc" 可以被全程序引用而互不干扰,这是可变对象做不到的。
图 2:不可变的代价、三者对比与选型决策树。
易错点清单。
一,只会背「StringBuffer 线程安全、StringBuilder 快」,讲不出 synchronized 加在哪、快在哪。二,在循环里用加号拼字符串,还以为编译器会兜底。三,多线程场景图省事全程用 StringBuffer,为不存在的并发付同步税。四,以为 StringBuffer 可以替代一切外部同步,组合操作照样出并发 bug。
回到面试现场,建议的答题节奏:先讲不可变性及其代价,再讲可变缓冲区如何消除中间对象,最后落到「同步开销换线程安全」这一条唯一分界线上,顺手给出默认 StringBuilder 的选型法则。这套思维在工程里每天都在兑现:prompt 模板拼接、流式 token 累积、日志组装都是高频字符串操作,选错类型,GC 压力会直接体现在推理网关的延迟曲线上。
你在项目里见过最离谱的字符串拼接写法是什么?欢迎分享。