news 2026/9/28 18:06:30

String、StringBuffer、StringBuilder 三者的区别(面试必问 + 源码级解析)

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
String、StringBuffer、StringBuilder 三者的区别(面试必问 + 源码级解析)

String、StringBuffer、StringBuilder 三者的区别(面试必问 + 源码级解析)

这是一道 Java 面试的“开胃菜”,几乎每一场初级/中级面试都会问到。但很多人只能背出“String 不可变、StringBuilder 不安全但快、StringBuffer 安全但慢”这一句,稍微往深里问一层(底层数组怎么扩容?+拼接到底发生了什么?循环里拼接为什么慢?)就答不上来了。这篇文章从源码 + 字节码 + 实战三个层面把这道题彻底讲透。


一、一句话总结

对比项StringStringBufferStringBuilder
可变性不可变(immutable)可变可变
线程安全安全(因为不可变)安全(方法加synchronized)不安全
执行速度最慢(每次操作产生新对象)较慢(锁开销)最快
出现版本JDK 1.0JDK 1.0JDK 1.5
继承结构final class Stringextends AbstractStringBuilderextends AbstractStringBuilder
适用场景常量、少量拼接、作为 Map 的 key多线程下大量字符串操作单线程下大量字符串操作

一句口诀:String不可变,StringBuffer安全,StringBuilder快。


二、String 为什么是不可变的?

2.1 源码层面

// JDK 8publicfinalclassStringimplementsjava.io.Serializable,Comparable<String>,CharSequence{privatefinalchar[]value;// 用 final 修饰的字符数组privateinthash;// 缓存 hash 值,默认 0// ...}// JDK 9 及以后(Compact Strings 优化,JEP 254)publicfinalclassStringimplementsjava.io.Serializable,Comparable<String>,CharSequence{@Stableprivatefinalbyte[]value;// char[] 换成了 byte[]privatefinalbytecoder;// 编码标识:LATIN1 = 0,UTF16 = 1// ...}

不可变体现在三个层次:

  1. 类被final修饰—— 不能被继承,杜绝子类覆盖方法破坏不可变性。
  2. 数据被final修饰——value引用一旦指向某个数组就不能再指向别的数组。
  3. 数组内部没有对外暴露修改入口—— 注意final只保证引用不变,数组里的元素理论上还是能改的。所以 String 内部对所有涉及返回数组的方法(如toCharArray())都做了防御性拷贝,绝不把内部数组直接返回出去。

JDK 9 的优化:char[](每字符 2 字节)改成byte[] + coder。如果字符串全是 Latin-1(ASCII 范围内的字符),就用 1 字节存一个字符,内存直接省一半;有中文等字符则用 UTF-16 存 2 字节。这也是为什么String的length()不能简单返回value.length,而要看coder。

2.2 平时怎么“改”String?

所有看起来像修改的方法,实际上都返回了新对象:

Strings="hello";s.concat(" world");// ❌ 白写,返回值被丢弃System.out.println(s);// hellos=s.concat(" world");// ✅ 重新赋值System.out.println(s);// hello world

s本来指向常量池里的"hello",concat之后堆上多了一个"hello world",s改指向它 —— 原来的"hello"依然在池里躺着。

2.3 不可变的好处(面试加分项)

  • 线程安全:多线程共享也不会被改,天然安全,无需同步。
  • 可以安全地缓存 hash:hash字段只算一次,这也是String适合做HashMapkey 的原因。
  • 支持字符串常量池:多个引用可以共享同一个字面量,节省内存。
  • 可以安全地作为参数传递:方法内部无法篡改调用方持有的字符串。

三、StringBuffer / StringBuilder 的底层原理

3.1 继承关系

AbstractStringBuilder (真正干活的地方) / \ StringBuffer StringBuilder (方法加锁) (没加锁)

两个类的绝大多数方法都是在AbstractStringBuilder里实现的,区别只有一个:

// StringBuffer —— 每个公共方法都加了 synchronized@OverridepublicsynchronizedStringBufferappend(Stringstr){toStringCache=null;super.append(str);returnthis;}@OverridepublicsynchronizedStringtoString(){if(toStringCache==null){toStringCache=Arrays.copyOfRange(value,0,count);}returnnewString(toStringCache,true);}// StringBuilder —— 没有 synchronized@OverridepublicStringBuilderappend(Stringstr){super.append(str);returnthis;}

StringBuffer的锁对象是 this,也就是对象本身。这意味着:类是线程安全的,但你的“复合操作”不一定 —— 加锁只能保证单个append原子,两个append之间仍可能被其他线程插队。这一点经常被面试官拿来追问。

另外StringBuffer有个toStringCache字段:如果期间没有发生修改,多次调用toString()会复用同一份拷贝。这是它在“读多写少”时的一个小优化。

3.2 底层存储与扩容(重点)

// AbstractStringBuilderchar[]value;// 注意:不是 final 的,所以能替换intcount;// 实际使用的字符数(注意不是 value.length)

StringBuilder的容量(capacity())和长度(length())是两码事:

StringBuildersb=newStringBuilder();System.out.println(sb.capacity());// 16 ← 默认容量System.out.println(sb.length());// 0sb.append("abcdefghijklmnopq");// 17 个字符,超过 16System.out.println(sb.capacity());// 34 ← 扩容后System.out.println(sb.length());// 17

扩容逻辑(JDK 8):

privatevoidensureCapacityInternal(intminimumCapacity){// overflow-conscious codeif(minimumCapacity-value.length>0){value=Arrays.copyOf(value,newCapacity(minimumCapacity));}}privateintnewCapacity(intminCapacity){// 核心公式:新容量 = 旧容量 * 2 + 2intnewCapacity=(value.length<<1)+2;// 如果翻倍后还不够,直接用需要的容量if(newCapacity-minCapacity<0){newCapacity=minCapacity;}return(newCapacity<=0||MAX_ARRAY_SIZE-newCapacity<0)?hugeCapacity(minCapacity)// 处理 Integer.MAX_VALUE 附近的边界:newCapacity;}

扩容公式:newCapacity = oldCapacity * 2 + 2,然后用Arrays.copyOf做一次全量数组拷贝。所以扩容是有成本的(时间 + 内存),如果事先知道大概长度,用new StringBuilder(1024)指定容量可以避免反复扩容。

MAX_ARRAY_SIZE = Integer.MAX_VALUE - 8。减 8 是因为部分 JVM 在数组对象头里要存长度信息,留点余量。

3.3 构造函数差异

newStringBuilder();// 容量 16newStringBuilder(100);// 容量 100newStringBuilder("hello");// 容量 = 5 + 16 = 21,长度 = 5

注意第三个:传入字符串时,容量是str.length() + 16,新字符串会和原字符串共用底层数组。面试里偶尔会问这个。

3.4 常用 API

StringBuildersb=newStringBuilder();sb.append("a").append(1).append(true);// 链式调用,返回 thissb.insert(0,"x");// 插入sb.delete(0,1);// 删除 [0,1)sb.deleteCharAt(0);sb.reverse();// 反转(回文串判断常用)sb.replace(0,1,"y");sb.setLength(0);// 清空但保留容量 —— 循环里复用对象的利器sb.charAt(0);sb.indexOf("a");sb.substring(0,2);// 返回 String(新对象)sb.toString();// 注意:会 new 一个 Stringsb.ensureCapacity(1000);// 手动预扩容

四、+拼接到底发生了什么?(高频考点)

4.1 编译期优化:常量折叠

如果参与拼接的全是编译期常量(字面量、final基本类型/字符串常量),编译器会直接在编译期算好:

Stringa="a"+"b"+"c";// 编译后等价于 String a = "abc";

字节码里只有一次ldc "abc",没有任何拼接动作。

Stringa="abc";Stringb="ab"+"c";System.out.println(a==b);// true —— 都是常量池里的同一个对象finalStringP="ab";Stringc=P+"c";System.out.println(a==c);// true —— final 变量也是编译期常量Stringx="ab";Stringd=x+"c";System.out.println(a==d);// false —— x 是变量,运行期才拼接,结果在堆上

记住:==比的是引用地址,new String()和变量参与的拼接都不会进常量池。

4.2 运行期拼接

JDK 8 及之前:+会被 javac 编译成StringBuilder的append链。

Strings=a+b+c;

等价于:

Strings=newStringBuilder().append(a).append(b).append(c).toString();

JDK 9 及之后:改为invokedynamic指令 +StringConcatFactory,运行期由 JIT 决定最优策略(可能是不经过中间对象直接生成字节数组的MethodHandle)。这是 JEP 280 的优化,性能更好。

4.3 循环拼接为什么慢?—— 经典陷阱

// ❌ 反例:每次循环都 new 一个 StringBuilderStringresult="";for(inti=0;i<10000;i++){result+=i;}

字节码层面大致等价于:

Stringresult="";for(inti=0;i<10000;i++){result=newStringBuilder().append(result).append(i).toString();// 每次都创建新对象}

new StringBuilder和toString()都在循环体内部,每轮都要创建 2 个对象 + 拷贝一次已有内容,时间复杂度是O(n²),10000 次循环就是上亿次字符拷贝,还制造了大量垃圾。

// ✅ 正例:StringBuilder 提到循环外StringBuildersb=newStringBuilder(10000);for(inti=0;i<10000;i++){sb.append(i);}Stringresult=sb.toString();

复杂度降到 O(n)。

补充:javac只做语法层面的改写,不会把对象“提出循环”。虽然 JIT 的逃逸分析 + 标量替换有可能优化掉部分临时对象,但那是运行期的事、不稳定,绝对不能依赖。该用 StringBuilder 就得用。

4.4 循环里复用对象

如果循环次数很多、又要反复构建字符串,可以复用同一个StringBuilder:

StringBuildersb=newStringBuilder(256);for(Itemitem:list){sb.setLength(0);// 清空内容,但保留底层数组容量sb.append(item.getName()).append(":").append(item.getCount());process(sb.toString());}

setLength(0)只把count置 0,不释放数组,避免反复分配内存。


五、性能实测

longstart=System.currentTimeMillis();Strings="";for(inti=0;i<100_000;i++){s+="a";}System.out.println("String: "+(System.currentTimeMillis()-start)+" ms");start=System.currentTimeMillis();StringBuildersb=newStringBuilder();for(inti=0;i<100_000;i++){sb.append("a");}System.out.println("StringBuilder: "+(System.currentTimeMillis()-start)+" ms");start=System.currentTimeMillis();StringBuffersbf=newStringBuffer();for(inti=0;i<100_000;i++){sbf.append("a");}System.out.println("StringBuffer: "+(System.currentTimeMillis()-start)+" ms");

典型的跑分结果(量级参考,不同 JDK/机器会有差异):

String: 3000+ ms StringBuilder: 3 ms StringBuffer: 5 ms

差距是三个数量级,这就是为什么“循环里不要用+拼字符串”会被写进阿里的《Java 开发手册》。

想测得更准,请用JMH。手写System.currentTimeMillis()会被 JIT 预热、死代码消除等因素干扰。


六、如何选择?(实战建议)

场景选择
字符串内容不变,作为常量 / Map key / 枚举值String
单线程下频繁修改、拼接StringBuilder
多线程共享同一个可变字符串缓冲区StringBuffer(或ThreadLocal<StringBuilder>)
拼接 SQL、JSON、日志StringBuilder,并预估容量
简单的少量拼接(如"a" + b)直接+(JDK 会优化,代码更可读)

关于多线程还有一条实用建议:优先用StringBuilder+ 局部变量,因为局部变量天然不共享,比StringBuffer的全局加锁效率高得多。真的需要共享,考虑ThreadLocal<StringBuilder>或干脆用锁把整个逻辑包起来。

另外几条经验:

  • append(int)优于append(String.valueOf(int))—— 后者会先造一个临时 String。
  • 别在循环里调sb.toString()做拼接 —— 每次都 new。
  • 别用String.format/ 字符串+拼日志—— 很多日志框架提供了占位符,且能延迟拼接(if (log.isDebugEnabled()))。
  • StringBuilder不是线程安全的,别在多线程下共享。

七、面试真题 & 参考回答

Q1:String 为什么是不可变的?不可变有什么好处?

String类被final修饰不可继承;内部存储字符的数组value被final修饰,引用不可变;且不对外暴露修改数组的入口(需要返回数组时做防御性拷贝)。好处:线程安全、可缓存 hash、支持常量池共享、可安全传参。

Q2:JDK 9 之后 String 底层为什么从char[]改成byte[]?

为了节省内存(Compact Strings,JEP 254)。统计发现大多数字符串只包含 Latin-1 字符,用 1 字节存一个字符即可。改后配合coder字段标识编码(LATIN1/UTF16),纯英文场景内存占用减半。

Q3:String s = new String("abc")创建了几个对象?

1 个或 2 个。如果常量池中已有"abc",只在堆上创建 1 个 String 对象;如果池中没有,则先在常量池创建"abc",再在堆上创建 1 个对象,共 2 个。

(注意区分String s = "abc":只在常量池中创建,且懒加载—— 池里已有就不创建。)

Q4:String s = "a" + "b" + "c"创建了几个对象?

1 个。编译期常量折叠,直接变成"abc",只创建一个常量池对象。

但如果是String a = "a"; String s = a + "b" + "c";——a是变量,运行期拼接,会创建"ab"、"abc"等多个中间对象(JDK 8 下由 StringBuilder 实现)。

Q5:StringBuilder和StringBuffer有什么区别?

两者都继承自AbstractStringBuilder,底层都是可变的字符数组,扩容策略相同(旧容量 * 2 + 2)。区别在于StringBuffer的所有 public 方法都加了synchronized,是线程安全的但性能略低;StringBuilder没有加锁,单线程下更快。StringBuilder是 JDK 1.5 引入的,目的是在单线程场景替代StringBuffer。

Q6:StringBuilder的扩容机制是什么?默认容量是多少?

默认容量 16。当追加内容导致count + 新增长度 > value.length时触发扩容,新容量为旧容量 * 2 + 2;若仍不够,则直接取所需的最小容量。扩容通过Arrays.copyOf完成,是一次全量拷贝,有性能开销,因此建议预估长度并用new StringBuilder(capacity)指定初始容量。

Q7:为什么循环里用+拼字符串慢?

因为+会被编译成在循环体内部new StringBuilder(),每轮循环都要新建对象并拷贝已有内容,时间复杂度 O(n²)。正确做法是把StringBuilder定义在循环外。

Q8:String、StringBuilder、StringBuffer谁实现了Comparable?

String实现了Comparable<String>,可以直接排序、可作为有序集合的元素。StringBuilder和StringBuffer不实现Comparable,所以不能直接用Collections.sort()排序,需要先toString()。

Q9:StringBuffer是线程安全的,那就一定能在多线程下放心用吗?

不一定。它只保证单个方法调用的原子性,多个方法组合起来的复合操作仍不是原子的。例如:

// 两个线程同时执行,结果仍可能出错if(sb.length()>0){sb.deleteCharAt(sb.length()-1);}

length()和deleteCharAt()之间存在竞态窗口,需要外部再加锁。

Q10:JDK 8 和 JDK 9+ 的字符串拼接实现有什么区别?

JDK 8:javac 把+编译成StringBuilder的append链 +toString。
JDK 9+:改为invokedynamic+StringConcatFactory,运行期由 JIT 生成最优的拼接策略,避免了中间StringBuilder对象的创建,通常更快。


八、总结

  1. String不可变:final类 +final char[](JDK 9+ 为byte[]),所有“修改”都返回新对象。
  2. StringBuffer线程安全:方法上有synchronized,适合多线程;但复合操作仍需自行加锁。
  3. StringBuilder单线程最快:JDK 1.5 引入,无锁开销,是单线程字符串拼接的首选。
  4. 两者底层都是AbstractStringBuilder,默认容量 16,扩容×2 + 2,扩容即全量拷贝,能预估长度就指定容量。
  5. 循环里禁止用+拼字符串,会退化成 O(n²)。
  6. 编译期常量拼接会被折叠并进常量池;只要有一个变量参与,结果就在堆上。
版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/28 18:05:55

威胁情报驱动的恶意软件检测:从情报采集到证据链闭环

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华
网站建设 2026/9/28 18:05:27

2026年企业级AI大模型API聚合平台深度测评:自建网关还是托管服务

2026 年企业想同时用上 GPT、Claude、Gemini、DeepSeek、Qwen、Llama 等主流模型&#xff0c;还要保障企业级网络连通性、统一结算与合规管控&#xff0c;难度不小&#xff0c;国内 API 聚合平台因此进入爆发期。本文梳理主流聚合服务在企业级场景下的能力特色与性能数据&#…

作者头像 李华
网站建设 2026/9/28 18:05:26

Vibe Coding 开发工作流:自然语言驱动软件开发的实践方法论

Vibe Coding 开发工作流&#xff1a;自然语言驱动软件开发的实践方法论 “Vibe Coding”&#xff08;氛围编程&#xff09;从 Andrej Karpathy 提出这个概念到现在&#xff0c;已经从圈内热词变成了实打实的生产力形态。它描述的工作方式很具体&#xff1a;不逐行手写代码&…

作者头像 李华
网站建设 2026/9/28 18:05:12

生产黑箱与质量追溯:大客户验厂的痛

生产黑箱与质量追溯&#xff1a;大客户验厂的痛"你们的质量追溯体系是怎么做的&#xff1f;"——当大客户的SQE&#xff08;供应商质量工程师&#xff09;在验厂时问出这个问题&#xff0c;很多线束企业管理者的心里都会咯噔一下。不是因为没做准备&#xff0c;而是因…

作者头像 李华
网站建设 2026/9/28 18:04:27

VT-D关闭原理与实操:DMA测速避坑指南

/* MD / 富文本中的 .toc(含博客园搬家等嵌套结构);.toc-box 在侧栏,不受影响 */#content_views .toc,/* 编辑器常在目录前后插入空 p(:empty 仍占 20px),一并去掉避免顶空隙 */#content_views.markdown_views > p:empty:has(+ .toc),#content_views.markdown_views …

作者头像 李华