面试官问:String、StringBuilder、StringBuffer有什么区别?(附图解+性能对比+避坑指南)
面试官问:String、StringBuilder、StringBuffer有什么区别?(附图解+性能对比+避坑指南)
📝摘要:String 不可变,每次修改创建新对象;StringBuilder 可变、线程不安全、性能最高;StringBuffer 可变、线程安全(synchronized)、性能次之。本文用“三种写字工具”比喻 + 性能实测数据 + JVM 底层分析(逃逸分析/GC)+ 2026 现代 Java 写法(Text Blocks/Records)+ 工具库推荐,彻底讲透这道面试必考题。
📚 系列导航
- 上一篇:面试官问:==和equals()有什么区别?为什么重写equals必须重写hashCode?
- 下一篇预告:面试官问:final、finally、finalize有什么区别?
- 全部85题目录:点击查看
💬 面试还原
面试官:String、StringBuilder、StringBuffer 有什么区别?什么场景下用哪个?
这是 Java 面试中出场率最高的基础题之一。看似简单,但面试官可以一路追问到“字符串常量池”、“编译期优化”、“JVM 逃逸分析”、“JDK 9 底层实现变化”。今天用一张图 + 三种写字工具比喻 + 性能实测数据 + JVM 底层原理,让你彻底掌握并应对追问。
金句记忆:
String 不变,Builder 快,Buffer 安全。单线程拼装用 Builder,多线程安全用 Buffer,少量操作用 String。
🧠 String、StringBuilder、StringBuffer一图看懂
🍵 生活比喻:三种写字工具
想象你需要写一份很长的作业:
String = 钢笔
写错了不能擦,只能换一张新纸从头写。每修改一次就换一张新纸(产生新对象)。
→ 适合不常变化的字符串,如配置信息、常量、方法返回值。StringBuilder = 铅笔 + 橡皮
写错了就擦掉重写,速度飞快。但旁边的人可能抢你的笔(线程不安全)。
→ 适合单线程下大量拼接(如循环内拼接 JSON、SQL、日志)。StringBuffer = 铅笔 + 橡皮 + 保险柜
每次用笔都要开锁,用完锁上,安全但慢(方法加 synchronized)。
→ 适合多线程环境下的字符串操作(如缓存构建、多线程日志聚合)。
📊 关键对比表
| 维度 | String | StringBuilder | StringBuffer |
|---|---|---|---|
| 可变性 | 不可变(final char[]/byte[]) | 可变 | 可变 |
| 线程安全 | 安全(天然不可变) | 不安全 | 安全(synchronized 方法级锁,粒度较粗) |
| 性能 | 最慢(每次操作 new 对象) | 最快 | 次快(有锁开销) |
| 适用场景 | 少量操作或常量 | 单线程大量拼接 | 多线程大量拼接 |
| 存储位置 | 常量池(字面量)或堆(new) | 堆 | 堆 |
| 继承关系 | Object 子类 | AbstractStringBuilder 子类 | AbstractStringBuilder 子类 |
| 默认容量 | —(字面量直接存储) | 16(无参构造) | 16(无参构造) |
| 扩容机制 | — | 旧容量 * 2 + 2 | 旧容量 * 2 + 2 |
记忆口诀:
常变用 Builder,多线用 Buffer,不变用 String。
🔍 面试官追问(重点!)
追问1:String 为什么设计成不可变?(四点核心)
答:
- 字符串常量池:如果 String 可变,一个引用修改会影响其他引用,破坏常量池设计。
- 哈希码缓存:String 的
hashCode只计算一次并缓存,可变会导致哈希值变化,影响 HashMap 等集合。- 线程安全:天然不可变,无需同步。
- 类加载器安全:类名通常用字符串表示,可变会导致安全漏洞。
追问2:String s = new String("abc")创建了几个对象?
答:
- 如果常量池中没有
"abc",则创建2 个对象(常量池一个 + 堆一个)。- 如果常量池中已有,则只创建1 个堆对象。
- 可通过
javap -c查看字节码验证。
追问3:循环内拼接用+和StringBuilder.append()哪个好?
答:
- 单行拼接:编译器自动优化成
StringBuilder,两者性能相同。- 循环内拼接:用
+每次循环会创建新的StringBuilder对象,效率低且 GC 压力大。务必用StringBuilder。
// ❌ 差:每次循环 new StringBuilderStringresult="";for(inti=0;i<10000;i++){result+=i;// 等价于 result = new StringBuilder(result).append(i).toString()}// ✅ 好:一个 StringBuilder 复用StringBuildersb=newStringBuilder();for(inti=0;i<10000;i++){sb.append(i);}追问4:JVM 的“逃逸分析”能优化字符串拼接吗?
答:
- 逃逸分析:JVM 的 JIT 编译器会分析对象是否在方法外可见。
- 标量替换:如果对象没有逃逸,JIT 会将其拆解为基本类型(标量),直接在栈上分配,避免堆分配和 GC 压力。
- 局限性:对于循环内的字符串拼接(如
result += i),逃逸分析通常无法优化,因为每次迭代都创建新对象并逃逸出当前作用域(赋值给循环外变量)。- 结论:不要把性能优化完全寄托于 JVM,代码层面仍需使用
StringBuilder。
追问5:JDK 9 之后 String 底层有什么变化?
答:
- JDK 8 及以前:
private final char[] value;(每个字符占 2 字节)。- JDK 9+:
private final byte[] value;+coder字段(Latin-1 占 1 字节,UTF-16 占 2 字节)。- 目的:节省内存。大部分字符串是 Latin-1(英文字符),用
byte[]可节省约 50% 内存。- 这项优化被称作Compact Strings(紧凑字符串)。
🚀 2026 年的 String 新写法
随着 Java 17/21 LTS 的普及,以下特性已成为开发标配:
1. Text Blocks(JDK 15+ 正式)
当需要拼接大段 SQL、JSON 或 HTML 时,Text Blocks 是首选,无需StringBuilder的append链:
// ❌ 旧写法:难以阅读Stringjson="{\"name\":\"张三\",\"age\":25,\"city\":\"北京\"}";// ✅ Text Blocks:清晰易读Stringjson=""" { "name": "张三", "age": 25, "city": "北京" } """;适用场景:SQL 拼接、JSON 构造、模板生成等。Text Blocks 能极大提升代码可读性。
2. Records(JDK 14+ 正式,JDK 16 完善)
在 2026 年,Records 已是数据载体类的首选。它天然支持 String 的不可变性理念:
// ❌ 旧写法classUser{privatefinalStringname;privatefinalintage;// 构造器 + getter + equals + hashCode + toString}// ✅ Records:自动生成一切,天然不可变recordUser(Stringname,intage){}Records 的字段是final的,与 String 的不可变性一脉相承。
📦 工具库推荐
除了原生StringBuilder,还有更优雅的选择:
1. StringJoiner(JDK 8+)
当需要带分隔符的拼接时(如 CSV),StringJoiner比StringBuilder更语义化:
StringJoinerjoiner=newStringJoiner(", ","[","]");joiner.add("甲").add("乙").add("丙");System.out.println(joiner);// "[甲, 乙, 丙]"优点:自动处理首尾分隔符,无需手动判断isFirst。
2. Guava Joiner(Google Guava)
处理集合拼接时更加优雅:
importcom.google.common.base.Joiner;List<String>list=Arrays.asList("甲","乙","丙");Stringresult=Joiner.on(", ").join(list);// "甲, 乙, 丙"// 处理 nullJoiner.on(", ").skipNulls().join(list);优点:支持skipNulls、useForNull,对 null 友好。
💣 常见坑点
坑1:字符串拼接 + 的编译期优化不等于运行时优化
// 编译期常量折叠 → 直接变成 "hello world"Strings1="hello"+" "+"world";// 优化为 "hello world"// 运行时拼接 → 使用 StringBuilderStrings2="hello";Strings3=s2+" world";// 底层 new StringBuilder().append(s2).append("world").toString()注意:只有编译期可知的常量表达式才会被折叠,变量拼接不会。
坑2:StringBuilder 的初始容量设置不当导致频繁扩容
StringBuildersb=newStringBuilder();// 默认容量 16for(inti=0;i<100000;i++){sb.append(i);}// 频繁扩容(旧容量 * 2 + 2),每次扩容需要复制原数组,影响性能正确:如果能预估最终长度,指定初始容量:
StringBuildersb=newStringBuilder(100000);// 预分配足够空间坑3:多线程环境下误用 StringBuilder
// 多线程环境 ❌StringBuildersb=newStringBuilder();// 多个线程同时 sb.append() → 数据错乱、丢失正确:使用StringBuffer或显式加锁。但StringBuffer的锁粒度是方法级,竞争激烈。高并发下可考虑ThreadLocal或ConcurrentLinkedQueue等无锁方案。
坑4:认为 StringBuilder 的toString()会复制数组
StringBuildersb=newStringBuilder("hello");Strings=sb.toString();// JDK 8:复制新数组;JDK 9+:共享 value(不可变引用)JDK 8 及以前:toString()会new String(value, 0, count)复制数组,保护性复制。
JDK 9+(Compact Strings):String构造器接收byte[]时,会先检查coder并可能共享数组引用,进一步优化性能。
💻 可运行验证代码
importjava.util.StringJoiner;importjava.util.Arrays;publicclassStringVsBuilderVsBuffer{publicstaticvoidmain(String[]args){// 1. 性能对比intiterations=100000;// String 循环拼接longstart1=System.currentTimeMillis();Strings="";for(inti=0;i<iterations;i++){s+=i;}longend1=System.currentTimeMillis();System.out.println("String 拼接耗时: "+(end1-start1)+"ms");// StringBuilderlongstart2=System.currentTimeMillis();StringBuildersb=newStringBuilder(iterations*5);for(inti=0;i<iterations;i++){sb.append(i);}longend2=System.currentTimeMillis();System.out.println("StringBuilder 耗时: "+(end2-start2)+"ms");// StringBufferlongstart3=System.currentTimeMillis();StringBufferbuffer=newStringBuffer(iterations*5);for(inti=0;i<iterations;i++){buffer.append(i);}longend3=System.currentTimeMillis();System.out.println("StringBuffer 耗时: "+(end3-start3)+"ms");// 2. 验证不可变性Stringstr="hello";Stringstr2=str+" world";System.out.println("str 是否被修改? "+str);// 还是 "hello"// 3. 编译期常量折叠验证Stringa="hello"+" "+"world";Stringb="hello world";System.out.println("常量折叠: "+(a==b));// true// 4. StringJoiner 示例StringJoinerjoiner=newStringJoiner(", ","[","]");joiner.add("甲").add("乙").add("丙");System.out.println("StringJoiner: "+joiner);// "[甲, 乙, 丙]"}}典型输出(100000 次):
String 拼接耗时: 15423ms StringBuilder 耗时: 7ms StringBuffer 耗时: 9ms str 是否被修改? hello 常量折叠: true StringJoiner: [甲, 乙, 丙]❓ 评论区挑战
问题:下面代码共创建了几个对象?(不考虑常量池已有的情况)
Strings="a"+"b"+"c";A. 1 个
B. 2 个
C. 3 个
D. 5 个
面试官问:==和equals()有什么区别?为什么重写equals必须重写hashCode? 评论区挑战
问题:下面代码的输出是什么?为什么?
Strings1=newString("java");Strings2=newString("java");System.out.println(s1==s2);System.out.println(s1.equals(s2));A. true / false
B. false / true
C. false / false
D. true / true
✅ 答案公布
正确答案:B. false / true
解析:
s1 == s2:两个对象都是new创建的,在堆中不同地址 → false。s1.equals(s2):String 重写了equals(),比较字符序列 → true。
📚 系列导航
- 上一篇:面试官问:==和equals()有什么区别?为什么重写equals必须重写hashCode?
- 下一篇预告:面试官问:final、finally、finalize有什么区别?
- 全部85题目录:点击查看
💬你在实际开发中遇到过因为字符串拼接性能问题导致的线上事故吗?或者见过哪些“优雅”的字符串拼接写法?欢迎评论区分享你的故事。
