☰
String、StringBuffer、StringBuilder与Arrays:底层原理、选型逻辑与避坑指南
2026/10/8 16:07:47 网站建设 项目流程

上周帮团队做Java岗位的技术面试,题单里放了一道经典的送分题:说说String、StringBuffer、StringBuilder的区别。结果十个候选人里,有五个能秒答三句话——String不可变、StringBuffer线程安全、StringBuilder性能高;但再追问一句"StringBuilder到底比StringBuffer快在哪,你的依据是什么",能说清楚的人直接少了一半。这个Java基础里最经典的组合,配上Arrays这个每天都在用却经常用错的工具类,其实非常值得掰开揉碎重新讲一遍。

这篇内容不搞面经式背诵,而是把三个字符串类和一个数组工具类的底层机制、选型逻辑、常见坑位全部过一遍。适合刚入门Java的人建立正确认知,也适合工作两三年想补基础的人查漏补缺。我会尽量用源码、基准测试和真实踩坑经历来说话,而不是给你一张"背完就忘"的对比表。

1. 从一道经典面试题说起:三个字符串类为什么同时存在

1.1 最朴素的疑问:已经有String了,为什么还要另外两个

JDK刚诞生那会儿,Java里只有String和StringBuffer。String负责描述不可变的字符串常量,StringBuffer负责在内存中"可编辑"的字符串缓冲。后来到了JDK 1.5,官方才补上了StringBuilder,理由写得很直白:StringBuffer的方法都加了synchronized,单线程环境下加锁是纯浪费,所以提供一个不加锁的版本。

换句话说,这三个类不是三个并列的"选美冠军",而是两代人:String是第一代产物,StringBuffer是它的补充,StringBuilder是针对性能做的"激进版"。很多教程把它们放在一起对比,导致新人误以为它们是三个功能等价、只是性能不同的类,这是第一个需要纠正的认知偏差。

String一旦创建,它的值就不能变了。你写String s = "abc"; s = s + "d";,并不是把原来的"abc"改成了"abcd",而是创建了一个全新的"abcd"对象,然后把s的引用重新指向它。StringBuffer和StringBuilder则像是"可变容器",你往里面append,是在原有的字符数组上操作,不会每次都产生新对象。

1.2 三者关系的一句话总结与选型口诀

我在实际给团队做Code Review时,经常用一句话总结选型逻辑:值是固定的,用String;值要变且可能并发访问,用StringBuffer;值要变且只在单线程内使用,用StringBuilder。

这句话背后对应一个很实际的问题:你的字符串值到底要不要变。比如拼SQL、拼日志、拼JSON、按条件拼接HTTP参数,这类场景值都是动态的,如果硬用String做+拼接,每拼一次就生成一堆中间对象,GC压力肉眼可见。而只要不涉及多线程共享同一个可变字符串,StringBuilder就是最优解。

提示:判断需不需要StringBuffer,不是看你的程序里有没有"多线程"三个字,而是看同一个StringBuilder/StringBuffer对象会不会被多个线程同时写。局部变量每次都新建,线程之间互不可见,那用StringBuilder准没错。

2. String不可变性的价值:设计哲学与内存机制

2.1 final类+私有字符数组带来的连锁效应

String的不可变,靠三层设计保证:类本身是final的,不能被继承;内部存储字符的数组是private final的,外部拿不到引用;所有修改操作(concat、replace、substring等)都返回新对象,绝不改动原值。

有人会问:private final char[] value,这个数组只是引用不可变,数组里的元素不还是能变吗?对,所以String类压根不对外暴露这个数组,你只能通过它提供的方法读写。数组元素在这个类内部不会再被修改,外部又拿不到引用,双重保险之下才算真正不可变。

JDK 9之后,内部的char[] value换成了byte[] value,同时多了一个byte coder字段。这是JEP 254的改动,叫Compact Strings:如果是纯拉丁字符,用1字节存一个字符,能省一半内存;如果包含中文等需要UTF-16编码的字符,就用2字节。别小看这个改动,它让大量字符串应用的内存占用直接砍半,算是JDK层面的一次"隐形优化"。

2.2 字符串常量池与intern()的真实行为

不可变带来的第一个红利,就是可以安全地做缓存。JVM里有一块叫字符串常量池(String Constant Pool)的区域,专门存字面量字符串。你写String a = "hello"; String b = "hello";,编译器会优化成:两个引用指向常量池里的同一个对象,所以a == b是true。

常量池的位置也值得一提。JDK 6及以前在方法区(PermGen),JDK 7之后挪到了堆内存。原因很简单:PermGen空间有限,字符串多了容易OOM,挪到堆里就可以被正常GC回收。这也是面试官很爱追问的细节,后面我会专门讲追问链路。

真正能体现"不可变+缓存"价值的是intern()方法。它能把一个运行期动态创建的字符串放到常量池里,并返回池中的引用。举个例子:

String s1 = new String("java") + new String("面试"); // s1的值为"java面试",但它在堆上,不在常量池 String s2 = s1.intern(); // intern之后,池里有了"java面试",s2指向池中的那个 String s3 = "java面试"; System.out.println(s2 == s3); // true,经典考题

不过我要提醒一句:intern()在JDK 6时代是危险操作,因为字符串多了会撑爆PermGen;JDK 7之后虽然挪到堆里安全一些,但大量intern仍然会消耗堆内存并增加GC负担。生产环境里不要为了"省内存"滥用intern,收益远小于风险。

2.3 哈希缓存与线程安全:不可变带来的免费午餐

String里有一个private int hash字段,默认是0。第一次调用hashCode()时计算一次,之后直接返回缓存值,不需要每个线程每次调用都重新算。HashMap的key大量使用String,这个缓存在大规模数据下能省下海量重复计算。

线程安全也是白送的。因为对象状态从头到尾不变,天然满足"不可变对象线程安全"的结论,不需要加锁,也不需要做防御性拷贝。你在多线程环境里放心共享同一个String对象,不会出现数据竞争。

2.4 一个常见的误解:final修饰的引用不等于内容不可变

很多初学者会把"final String"和"String不可变"搞混。final String s = "abc";里的final只是说这个引用不能重新赋值,并不是说String类型本身不可变。就算没有final,String的内容也不会变,这是类设计决定的。区分这两件事,能帮你读源码时少绕很多弯。

3. StringBuffer与StringBuilder:安全与性能的权衡实操

3.1 synchronized到底加在哪里:源码级对比

打开JDK源码,StringBuffer和StringBuilder几乎是一对孪生兄弟,方法名、逻辑、继承关系全都一样,唯一的核心差异是StringBuffer的公开方法上加了synchronized关键字。比如append方法:

// StringBuffer public synchronized StringBuffer append(String str) { toStringCache = null; super.append(str); return this; } // StringBuilder public StringBuilder append(String str) { super.append(str); return this; }

一个synchronized之差,决定了StringBuffer在多线程下是安全的,但也决定了所有写操作都要经过锁的竞争。如果是多个线程同时写同一个StringBuffer,JVM会保证串行化执行,不会出现数据错乱;代价就是吞吐量下降。

这里还有个容易被忽视的细节:StringBuffer和StringBuilder都继承自AbstractStringBuilder,真正干活的append逻辑在父类里。StringBuffer靠synchronized把整个方法锁住,所以父类方法不需要再考虑并发问题。如果你想扩展它们,直接在子类方法上加锁,而不要试图去改父类共享状态,否则锁就白加了。

3.2 实际性能差距有多大:一组基准测试

很多人对"性能高"没有体感,我跑过一组简单的Benchmark,循环100万次append,结果大致是这样:

场景耗时(相对值)
String直接+拼接(循环内)最高,且产生大量垃圾对象
StringBuffer.append中等,有锁开销
StringBuilder.append最低,通常比StringBuffer快30%~50%

注意,这个差距在单线程下更明显,多线程竞争激烈时StringBuffer会因为锁等待变得更慢。而且现代JVM对锁做了偏向锁、轻量级锁等优化,竞争不激烈时StringBuffer的开销没那么可怕。选型仍然要回到"是否共享可变字符串"这个根本问题上。

3.3 容量机制与扩容策略:为什么建议预分配

StringBuilder的底层是一个byte[] value,默认初始容量是16。你每次append前都会检查容量,不够了就扩容。扩容逻辑一句话:newCapacity = (oldCapacity << 1) + 2,也就是大约1.5倍到2倍之间,然后把旧数组复制到新数组。

这就意味着:如果你知道最终字符串大概有多长,最好在创建时就指定容量。

// 预估最终会有2000个字符,直接给足容量,避免多次扩容 StringBuilder sb = new StringBuilder(2048); for (int i = 0; i < 2000; i++) { sb.append("x"); }

扩容涉及数组拷贝,是O(n)操作,频繁扩容会让整体复杂度从O(n)退化到接近O(n^2)的边缘。预分配容量是我在代码评审里最常给的优化建议之一,成本几乎为零,收益立竿见影。

3.4 StringBuffer转String的正确姿势

热搜里有个词是"stringbuffer转换为string",这里也顺带说清楚。转换只有一种标准姿势:调用toString()。

StringBuffer buffer = new StringBuffer("hello"); String result = buffer.toString();

需要注意,StringBuffer的toString()方法自带synchronized,而且JDK 8之后还引入了toStringCache缓存,保证多次调用toString不会每次重新拷贝字符数组,这也算是StringBuffer的一个专属优化。相比之下StringBuilder没有这个缓存,每次toString都会新建一个String。

生产环境里最常见的一个错误是:为了拿字符串,反复调用buffer.toString()去参与下一步拼接。正确做法是先把结果存到局部变量里,需要用几次都用这个变量,既减少对象创建,也让代码意图更清晰。

4. 字符串拼接实战:从+号到编译器优化的进化史

4.1 +号拼接在JDK 8和JDK 9后的编译差异

很多入门教程告诉你"不要用+号拼字符串,要用StringBuilder",这个结论在JDK 8之前是基本正确的,但现在已经过时了一半。因为编译器本身就会帮你优化。

在JDK 8里,你写:

String s = "a" + "b" + "c";

javac编译时,如果所有操作数都是编译期常量,会直接折叠成String s = "abc";,这事叫常量折叠。如果里面有变量,比如String s = a + b + c;,javac会生成一段等价于new StringBuilder().append(a).append(b).append(c).toString()的字节码。

从JDK 9开始,String拼接又被改了一次,引入了invokedynamic和StringConcatFactory。简单说,编译器不再生成StringBuilder的字节码,而是生成一个动态调用点,JVM在运行时决定用哪种方式拼接,未来还可能引入更高效的策略。这个细节说明什么?说明"StringBuilder一定比+号快"这个结论已经不再绝对,至少在小规模拼接场景下,+号的简洁性完全不输性能。

4.2 循环内拼接为什么会成为性能杀手

但有一个场景例外:循环内的拼接。为什么?因为每一次迭代都是一次独立的拼接表达式,编译器和JVM很难把多次迭代的优化合并起来。比如:

String s = ""; for (int i = 0; i < 10000; i++) { s = s + i; // 每次循环创建一个新String,还可能创建一个新的StringBuilder }

这段代码在JDK 8的字节码层面,等于每轮循环都new一个StringBuilder、append两次、toString一次,再赋给s。10000次循环就是10000个中间String对象和10000个中间StringBuilder对象。换成StringBuilder后:

StringBuilder sb = new StringBuilder(50000); for (int i = 0; i < 10000; i++) { sb.append(i); } String s = sb.toString();

只在最后创建一次String。这个对比就是我在面试时最想让候选人说出来的关键点:"+号适合少量拼接,循环和复杂动态拼接必须用StringBuilder或StringBuffer。"

4.3 工程化方案:join、Collectors与格式化

如果拼接的不是简单的变量,而是集合里的元素,还有更声明式的写法。JDK 8提供了String.join:

List<String> list = Arrays.asList("a", "b", "c"); String joined = String.join(",", list); // a,b,c

配合Stream可以玩出很多花样:

String result = list.stream() .map(String::toUpperCase) .collect(Collectors.joining(" | ", "[", "]")); // 输出:[A | B | C]

Collectors.joining还支持前缀和后缀,拼接报表、拼接SQL的IN条件都非常好用。我在实际项目中看到一个很典型的反模式:有人用for循环+StringBuilder拼接几百个ID的IN子句,其实一行Collectors.joining(",")就解决了,代码量少一半,可读性高两档。

还有一个冷门但好用的类:java.util.StringJoiner,它是Collectors.joining的底层实现。如果只有两三个分隔符需求,直接用StringJoiner也是个不错的选择。总的来说,现在写Java拼接字符串,优先顺序应该是:少量用+号,集合用join/Collectors.joining,复杂动态拼接用StringBuilder,需要线程安全共享才考虑StringBuffer。

5. Arrays工具类:被低估的算法百宝箱

5.1 sort()的两种算法与排序稳定性问题

java.util.Arrays是Java里最实用的工具类之一,但很多人的使用水平停留在Arrays.sort(int[])和Arrays.toString()上。先说排序。

对基本类型数组,Arrays.sort用的是双轴快速排序(Dual-Pivot Quicksort),时间复杂度平均O(n log n),但不是稳定排序。对对象数组,比如Arrays.sort(String[]),用的是TimSort(归并排序的优化版),是稳定排序。为什么做区分?因为基本类型排序不需要保持相同元素的相对顺序,而对象排序经常要按多个字段排序,稳定性很重要。

这里有个很反直觉的细节:如果你把基本类型数组转成包装类型数组再排序,比如Integer[],走的是TimSort,稳定但性能略低;直接用int[]排序,走的是双轴快排,快但可能交换相同值的相对位置。生产环境里,如果对相同值的顺序有要求,就要用包装类型数组或对象数组;如果纯追求速度,用基本类型数组。

JDK 8还加了parallelSort(),数组足够大时(阈值大概在8192左右)会利用ForkJoinPool并行排序。我在处理百万级int数组时实测过,多核机器上parallelSort确实能快不少,但小数组反而有启动线程池的开销,建议数组小就别用。

5.2 binarySearch()的返回值陷阱

Arrays.binarySearch()是个好方法,但有一个特别容易踩的坑:它要求数组必须先排好序,否则结果完全不可信。第二个坑是返回值。

int[] arr = {1, 3, 5, 7, 9}; int index = Arrays.binarySearch(arr, 7); // 返回3 int missing = Arrays.binarySearch(arr, 6); // 返回-4,而不是-1

没找到时返回值不是简单的-1,而是:-(插入点) - 1。插入点指的是"如果要保持有序,这个值应该插入的位置"。6应该插在5和7之间,也就是索引3的位置,所以返回值是-(3) - 1 = -4。

很多新手写判断逻辑时,直接判断返回值是否等于-1,结果永远不对。正确写法是:返回值大于等于0才是找到了。如果你想用这个结果反推插入点,可以用insertionPoint = -returnValue - 1。这是LeetCode和手写代码里最常见的边界陷阱之一。

5.3 asList()返回的到底是什么:三个隐藏坑

Arrays.asList()恐怕是除了sort之外使用频率最高的方法了,但它返回的不是java.util.ArrayList,而是Arrays内部的一个私有静态类java.util.Arrays$ArrayList。这个类继承自AbstractList,所以看起来有List的样子,实际上有三个大坑:

第一,长度固定。你不能对它add或remove,会直接抛UnsupportedOperationException。它只是把数组包装成了List视图,底层还是那个数组。

第二,与原数组共享数据。改装数组、改List,两边会互相影响。

String[] arr = {"a", "b", "c"}; List<String> list = Arrays.asList(arr); arr[0] = "x"; System.out.println(list.get(0)); // 输出 x

第三,泛型陷阱。基本类型数组用asList不会装箱。你写Arrays.asList(1, 2, 3),编译器会把三个int变成三个Integer,没问题;但你写int[] arr = {1,2,3}; Arrays.asList(arr),得到的List里只有一个元素,那就是整个int数组的引用。

如果你需要一个真正独立的、可增删的ArrayList,标准写法是:

List<String> list = new ArrayList<>(Arrays.asList(arr));

或者用JDK 9之后的List.of(arr)创建不可变列表。面试时被问到"Arrays.asList和new ArrayList有什么区别",上面这三个坑就是完整的考点。

5.4 打印、拷贝、比较与填充:深度方法汇总

Arrays里还有一批高频方法,我按场景列个清单,方便查漏补缺:

场景推荐方法备注
打印一维数组Arrays.toString(arr)直接打印数组对象会输出[I@1b6d3586这种地址
打印多维数组Arrays.deepToString(arr)普通toString对二维数组只打印一层引用
数组拷贝Arrays.copyOf(arr, newLen)会创建新数组,可扩容或截断
指定区间拷贝Arrays.copyOfRange(arr, 1, 3)左闭右开
数组比较(一维)Arrays.equals(a, b)比较内容而非引用
数组比较(多维)Arrays.deepEquals(a, b)逐层比较
填充Arrays.fill(arr, 0)常用于初始化
生成器填充Arrays.setAll(arr, i -> i * 2)JDK 8提供,按索引生成
并行前缀计算Arrays.parallelPrefix(arr, Integer::sum)冷门但很强大

我特别提一下copyOf,它的经典用途是模拟数组扩容。ArrayList的扩容机制底层也是数组拷贝,你手写一个"动态数组"时,Arrays.copyOf比手动System.arraycopy更安全、更简洁。

还有一个生产环境里的实用技巧:比较两个对象数组是否相等,直接用Arrays.equals;比较两个List是否相等,用list1.equals(list2);但不要用Arrays.asList(a).equals(b)这种花活,语义容易乱。保持代码直白,是降低bug率最有效的方式。

6. 面试追问链路与常见翻车点

6.1 从"为什么String不可变"到常量池位置的连环追问

我把最近面试中围绕这个主题的真实追问链整理一下,你会发现它几乎可以覆盖JVM内存、并发、集合三个大方向:

  1. String为什么设计成不可变?——答:线程安全、哈希缓存、常量池复用、安全(防止参数被篡改)。
  2. 不可变怎么保证的?——答:final类、private final byte[]/char[]、不暴露内部数组、修改返回新对象。
  3. 常量池在哪?——答:JDK 6在方法区(PermGen),JDK 7之后在堆中。
  4. new String("abc")一共创建了几个对象?——答:如果池里已有"abc"字面量,只创建1个堆对象;如果池里没有,会先创建池中对象再创建堆中对象,共2个。这个问题要分情况回答,不要一上来就喊"两个"。
  5. 既然有String,为什么还要StringBuilder?——答:String不可变导致频繁拼接产生大量中间对象,StringBuilder提供可变字符序列。
  6. StringBuilder和StringBuffer怎么选?——答:单线程StringBuilder,多线程共享StringBuffer,并解释synchronized的开销。
  7. 你刚才说hash缓存,HashMap为什么用String做key很高效?——答:hashCode只算一次,equals先比较引用再比较内容,不可变保证key不会被意外修改。

这条链问下来,基本功扎不扎实基本就清楚了。我面试时特别看重第4和第6题的回答方式,因为这两题能区分"背概念"和"理解原理"。

6.2 真实项目里的典型翻车案例

光说面试没意思,我再分享几个我在真实代码里见过的翻车现场。

第一个是日志系统的错误示范:有人在循环里用logger.info("user:" + user.getId() + ", name:" + user.getName())。如果日志级别是DEBUG,这行代码根本不会输出,但拼接还是会执行,等于白白创建了无用对象。正确做法是消息模板加参数:logger.info("user: {}, name: {}", user.getId(), user.getName()),让日志框架只在真正需要输出时才拼接。这就是为什么SLF4J一直建议用占位符的原因。

第二个是死循环里的String s = s + item。我在一个报表导出功能里见过,循环10万次拼CSV,结果内存直接飙升到几百MB,导出一次卡死一次。改成StringBuilder预分配容量后,内存占用降了一个数量级。

第三个是Arrays.asList当普通List用。有人在Spring配置里返回一个由Arrays.asList创建的"可读列表",结果其他模块往里面add,线上直接抛UnsupportedOperationException,凌晨被叫起来排查。翻车原因就是没搞懂这个List的固定长度属性。

6.3 扩展学习建议:从这几个类看到更广的Java生态

学完String、StringBuffer、StringBuilder、Arrays,其实可以顺势把知识面展开。比如String的不可变设计,和Integer、BigDecimal这些不可变类的设计一一对应;字符串常量池,和Integer的IntegerCache缓存机制是同一类思想;Arrays的排序算法,可以和Collections.sort、Stream.sorted的稳定性对比;Arrays.asList的包装类思路,和Collections.unmodifiableList的只读视图是同一套装饰器模式。

再说一个冷门但实用的点:热搜词里有个"冒泡排序java",很多初学者还在手写冒泡排序。实际开发中,手动排序的场景几乎都应该交给Arrays.sort或List.sort,因为JDK内置算法经过大量优化,手写版本在数据量上来后完全不是对手。手写排序的意义在于理解算法思想,而不在于在生产环境里替换标准库。

我个人在实际项目里最深的体会是:这些"基础类"题目之所以常驻面试题榜,恰恰因为它们最能反映一个人的代码品味。能用好StringBuilder、能避开Arrays.asList的坑、能在合适场景选对拼接方案的人,写出来的代码通常也更有章法。平时写代码时多留个心眼,遇到字符串操作和数组操作就问自己一句"我用的这个API,底层真的是我以为的那个行为吗",这个习惯养成了,比背十道面试题都管用。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询