字符串处理是每个 Java 开发者都绕不开的基本功,但真正能把String、StringBuffer、StringBuilder三者差异讲透的人并不多。很多同学背过“String 是不可变的,StringBuilder 是线程不安全的”,可一旦落到实际项目里,什么时候该用哪个、为什么拼接大字符串时循环里不能直接写+、StringBuffer到底比StringBuilder慢多少,这些细节往往一问一个准。这篇文章想做的就是把这些内容全部串起来——从底层源码逻辑、内存模型、字节码优化,到真实项目里的选型思路和线上排查经验,希望你看完之后能真正建立起一套属于自己的判断标准,而不是停留在背诵结论。
无论你是刚学 Java 的初学者,还是写了几年业务代码但没认真抠过细节的老开发,这套内容都值得过一遍。字符串操作占了一个 Java 应用里不小比例的堆内存和 CPU 开销,选错容器的代价,通常不是立刻崩,而是慢慢地把性能拖垮。
1. 为什么 Java 里会有三个“字符串类”
1.1 从一次线上事故说起
我之前在某个项目里见过这样的代码:一个接口负责生成批量导出文件,将几千行数据拼成一个 CSV 字符串返给前端。最初写的人用了String直接拼,上线后接口延迟从 50ms 一路飙升到 8 秒,压测时甚至把年轻代撑到频繁 GC。
排查时第一反应是数据库慢,但 SQL 日志显示查询本身只花了 20ms。后来 dump 线程栈才发现,大量时间花在字符串拼接的StringBuilder.append上。为什么用 String 变量拼结果还会经过 StringBuilder?因为编译器对+的优化也是在循环外侧生成StringBuilder,但循环内每轮都在重建、销毁同一个临时对象,几千轮下来,临时对象创建和数组拷贝的开销直接爆炸。
这个案例基本概括了三个字符串类存在的意义:String承载不可变常量语义,StringBuffer和StringBuilder解决可变字符串拼接问题,而它们俩唯一的本质区别,就是StringBuffer的方法加了synchronized同步锁。就这么简单,但围绕这份简单,背后牵扯出一整套内存模型、线程安全和编译优化的知识。
1.2 三兄弟的身世和设计考量
StringBuffer从 JDK 1.0 就存在了,这导致它的所有核心方法都带着synchronized。在早期 Java 只有这一种可变字符序列,设计者担心多线程环境下字符串构建器被并发修改,索性把所有方法都做成线程安全的。这个决定在单线程时代问题不大,但到了互联网高并发场景,同步锁的竞争开销变得不可忽视。
于是 JDK 5.0 时官方推出了StringBuilder,源码几乎是从StringBuffer复制改写的,只是把synchronized全部去掉。为什么正式版敢这么干?因为绝大多数字符串拼接发生在单个线程内部,比如拼 SQL、拼 JSON、拼 HTML 片段,根本不存在多线程共享同一个构建器的场景。此时强制同步就是纯浪费。
说句实话,我从没见过哪个正经项目会在线程间共享同一个StringBuffer。场景基本都是方法局部变量,用StringBuilder就完事了。但如果你的代码确实要把构建器对象传给其他线程,或者它作为类的静态成员被多处访问,那StringBuffer就是保守稳妥的选择。
2. String 的不可变性到底保护了什么
2.1 底层存储结构的变化
先说底层存储。JDK 8 及以前,String内部是一个private final char value[]数组;JDK 9 开始,换成了private final byte[] value,配合一个coder字段标识 LATIN1 还是 UTF16 编码。这个改动是为了省内存——纯英文内容原本每个字符占 2 字节,现在按 Latin-1 只占 1 字节。平常自己写代码可能感受不到差异,但在大量字符串缓存的业务系统里,这个优化能让字符串对象总体堆占用下降不少。
很多人只记住“String 是不可变的”,但没搞清楚不可变指的是什么。String类的字段是final,指向的数组也是私有且无法从外部修改的。你调toUpperCase()、concat()、replace(),它不是改原来那个对象,而是新建一个字符串对象,返回新引用。原来那个对象如果没有任何引用指向它,就交给 GC 回收。
2.2 不可变性带来的四个硬核红利
第一是字符串常量池。正因为对象不可变,JVM 才敢于让相同内容的字符串复用同一个实例。只要字符串内容相等,==在某些情况下都能成立,当然前提是它进了常量池。new String("abc")会额外创建一个堆对象,这就是为什么规范里建议尽量用字面量。
第二是哈希码缓存。String重写了hashCode(),计算规则是基于内容计算的,由于内容不可变,hashCode只需要计算一次,之后缓存在字段里。这直接影响HashMap、HashSet这种以字符串为键的集合类的性能,重复哈希也不用重新遍历字符串内容。
第三是线程安全。不可变对象天然可以在多线程间共享,不需要加锁。比如你内部定义了一个配置字符串常量,所有线程都能直接读它,不用考虑并发写引起的数据错乱。
第四是安全性。网络地址、文件路径、类名这些关键字符串如果可变,恶意代码通过反射或者共享引用篡改内容,会导致不可预料的后果。字符串作为参数频繁在系统各层传递时,不可变性保证了传递前后的内容一致性。
2.3 “修改字符串”其实都是假象
String s = "abc"; s = s + "d";这种写法,你以为是给s追加了内容,实际上 JVM 做的是:根据"abc"和"d"的内容,new出一个"abcd"新对象,再把新对象引用赋给变量s。原来的"abc"对象纹丝不动地留在原处,等待被回收。
每次拼接次数少还好,一旦拼接出现在循环里,就会产生大量废弃对象,对 GC 形成压力。这就是为什么String拼接性能低于后两者的根本原因——不可变性限制了原地修改能力,一切尝试都变成对象的“生老病死”。
注意:
String的不可变是公开 API 层面的,不代表通过反射就一定改不了内部数组。但那属于打破封装的行为了,生产代码永远不要这么干,真遇到需求应该走正规 API。
3. StringBuffer 与 StringBuilder:一个加锁,一个裸奔
3.1 源码层面看它们到底差在哪里
看源码最直观。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; }两者都继承了AbstractStringBuilder,扩容逻辑、字符拷贝、数据搬移都完全一样。唯一的差别就是StringBuffer方法前面多了synchronized,以及它额外维护了一个toStringCache数组来缓存最近一次toString()的结果。因为所有修改方法都是同步的,所以这个缓存字段不需要额外的并发控制,直接赋值即可。
你如果用javap -c反编译这两个类,会发现除锁相关字节码外,其它指令几乎一模一样。所以网上那些“StringBuffer 因为锁导致性能差 100 倍”的说法是不准确的,实际差距通常没那么夸张,先入为主地量一量就很清楚。
3.2 实测:同样拼接十万次,差距到底多大
我在本机用一个简单的测试验证过性能差异。模拟纯单线程场景,分别用三种方式做十万次字符串追加,大致耗时如下:
| 拼接方式 | 耗时 | 说明 |
|---|---|---|
String += | 约 4600ms | 每轮都新建对象,GC 压力大 |
StringBuffer.append | 约 35ms | 有同步锁,但单线程无竞争时锁开销较轻 |
StringBuilder.append | 约 9ms | 无锁,最快 |
这个数字和运行环境有关,不追求精确,看量级就够了。String的劣势是代际级别的,后两类差距则在一个数量级以内。也正因如此,把性能敏感的单线程代码从StringBuffer换成StringBuilder是合理优化,但不要指望这种优化能救整个系统的命,它拯救的是字符串构建这一段路的局部开销。
3.3 线程安全的真实成本
很多人理解错了锁的成本。synchronized在无竞争时走偏向锁和轻量级锁,开销比教科书里描述的重锁小不少。StringBuffer慢主要慢在即使没人和它抢,每次调用还是要做一次锁的检查与撤销,这些指令本身不免费。
但更大的问题不是慢,而是“你以为线程安全了就安全了”。比如两个线程同时向同一个StringBufferappend,每个 append 调用本身是原子的,但如果你把“判断是否为空再追加”这种复合操作放在外面,中间依然会被插队。线程安全只保证单个方法内的原子性,不保证你基于它的业务逻辑正确。这个点后面面试题部分会展开讲。
4. 核心差异对照表与选型逻辑
4.1 一张表看懂三兄弟
与其背概念,不如直接看对照:
| 对比维度 | String | StringBuffer | StringBuilder |
|---|---|---|---|
| 可变性 | 不可变 | 可变 | 可变 |
| 线程安全 | 安全(不可变) | 安全(方法锁) | 不安全 |
| 存储结构 | byte[] / char[] | byte[] / char[] | byte[] / char[] |
| 拼接性能 | 很差(频繁重建) | 中等 | 最好 |
| 使用场景 | 常量、不可变内容 | 多线程共享构建 | 单线程局部拼接 |
| equals 比较 | 重写过,比较内容 | 继承 Object,比引用 | 继承 Object,比引用 |
| 引入版本 | JDK 1.0 | JDK 1.0 | JDK 5.0 |
这里有一个高频误区:StringBuffer和StringBuilder没有重写equals()。也就是说,两个内容相同的StringBuilder对象,用equals比较结果是false。如果你需要比较它们的字符串内容,必须先调toString()再比。
4.2 不同场景下的选型策略
我自己在实际项目里的判断标准很直接:
- 字符串内容不会变,或者会作为
Map的 key、需要频繁读取哈希的场景,用String。 - 字符串内容需要不断拼接,且构建过程完全在方法内部完成,不涉及跨线程传递,用
StringBuilder。 - 字符串构建器对象本身就是共享资源,会被多个线程同时 append,用
StringBuffer。这种场景极少。 - 要拼接的片段是可变数量的,比如循环中拼 SQL 条件、拼 CSV、拼日志,一律用
StringBuilder。 - 如果只是拼接两三个固定字符串,直接
+就够了,不用过度设计。
选型不是越高级越好,而是匹配场景。有些同事喜欢把简单的"a" + "b"也改成StringBuilder,这属于用力过猛,反而让代码可读性变差。
4.3 用 JDK 自带类时的进阶玩法
除了这三兄弟,Java 还提供了StringJoiner和String.join()。如果要在多个元素之间加分隔符串联,比如a, b, c这种,StringJoiner内部正是依赖StringBuilder实现的,但帮你处理了分隔符和前后缀的逻辑,比手动append(",")再处理首尾干净得多。
还有String.join(),内部实质也是StringJoiner的封装。遇到集合转逗号分隔字符串的场景,优先考虑它们,不要自己写循环。
5. 字符串拼接的编译器优化真相
5.1 “+”号背后藏着的 StringBuilder
Java 对字符串的+做了编译期优化。String s = "a" + "b" + "c";这种全是字面量的,编译器会直接折叠成"abc"常量。但只要有变量参与,比如String s = a + b + c;,编译后的字节码通常是:
String s = new StringBuilder().append(a).append(b).append(c).toString();也就是说,+在 JVM 眼里本来就是StringBuilder的语法糖。这就是为什么少量拼接用+完全没问题,编译器已经帮你优化了。
5.2 循环拼接为什么危险
问题出在循环体内部的拼接,例如:
String result = ""; for (int i = 0; i < 10000; i++) { result = result + i; // 每次循环都 new 一个 StringBuilder }每次执行result = result + i,编译器都需要创建新的StringBuilder,把旧内容搬进去,追加新数字,再toString()。结果是循环一万次,就创建了一万个临时StringBuilder对象加一万个String对象。GC 不慢才怪。
正确写法是把构建器提到循环外:
StringBuilder sb = new StringBuilder(); for (int i = 0; i < 10000; i++) { sb.append(i); } String result = sb.toString();这看起来只是把对象创建位置挪了挪,实际性能差异可从几百毫秒变成几毫秒,操作简单但回报非常稳定。
5.3 预分配容量的正确姿势
StringBuilder底层数组默认容量是 16。如果最终拼接结果接近 16,会触发一次扩容;如果远超,可能要扩容多次。每次扩容会把原数组拷贝到新数组,容量翻倍,拷贝成本是所有 append 操作里最贵的路径之一。
如果你能估算出最终的字符串长度,直接传入初始容量,比如new StringBuilder(totalLength)。注意,这个值不是字符数上限,而是底层数组初始长度。估算偏大一点没关系,最多浪费一点内存,但能省掉后续扩容。我习惯的做法是:能预先算出最终长度就预分配,算不准就算了,别为了预分配做一堆复杂计算,得不偿失。
6. 高频面试题与实战避坑指南
6.1 面试官最爱问的几道题
我面试 Java 候选人时,基本绕不开字符串这个话题,有几道题出镜率极高,顺便把答案理一遍。
第一题:String s1 = "abc"; String s2 = "abc"; s1 == s2结果是什么?答案是true。因为两个字面量指向常量池里同一个对象。
第二题:String s3 = new String("abc"); s1 == s3结果是什么?答案是false。new强制创建堆对象,和常量池里的不是同一个引用。
第三题:String s4 = s1.intern(); s1 == s4结果是什么?答案是true。intern()尝试把字符串加入常量池,如果已存在就返回池中的引用。
第四题:两个内容相同的StringBuilder用equals比较,结果是什么?false,如前所述,没有重写。
第五题:StringBuffer线程安全,是不是可以放心在并发下用?答:单个调用原子,但复合操作仍然需要外部同步,只能说它比StringBuilder更不容易出岔子,不表示业务逻辑自动安全。
第六题:JDK 8 和 JDK 9 的String内部存储有什么变化?答:char[]换成了byte[]加编码标识。
第七题:为什么说String适合做HashMap的 key?答:不可变保证了hashCode()稳定,且值缓存了一次性计算,查询效率高。
第八题:StringBuilder线程不安全具体表现是什么?答:多个线程同时 append 时,底层数组的写位置count不是原子的,可能出现覆盖写、数据错乱,极端情况下数组越界。
6.2 我踩过的坑与排查实录
有一次排查线上问题,发现某个导出的 Excel 文件名偶尔会出现乱码。一开始怀疑编码问题,绕了一大圈,最后发现代码里用了一个静态StringBuilder来缓存在多线程间共享的临时文件名。高并发下,两个线程同时 append 不同内容,互相覆盖,最终生成的文件名就错乱了。换成局部变量后问题消失。这个案例再次说明:共享可变对象,线程安全永远要放在第一位考虑。
还有一次是内存问题。线上服务老年代持续上涨,GC 日志显示字符串数组占比过大。查了半天,发现某工具类里用了String.intern()来处理用户输入的动态内容,导致 Intern 池不断膨胀。这里要提醒一句:intern()适合用在数量有限的固定字符串上,千万别拿它缓存用户输入这种高基数数据,否则常量池会变成新的内存黑洞。
6.3 性能测试时容易忽视的坑
想验证三种字符串类的性能差异,随便写个for循环计时就行,但有几个坑必须避免。
第一,JVM 的 JIT 会优化重复执行的代码,连续跑多次时,后续结果可能比第一次快很多。正确做法是先“热身”几百次,再开始计时,或者直接用基准测试框架。
第二,别用System.currentTimeMillis()在循环里测短任务,精度不够,测出来波动巨大。可以用System.nanoTime(),并且测多轮取平均。
第三,拼接的内容不要太短。像sb.append("a")这种固定短字符串,JIT 很容易做逃逸分析,甚至把StringBuilder对象分配到栈上,导致测出来的时间都是零。要测真实性能,拼接内容得有一定长度和随机性。
第四,测试结果只代表当前机器和 JDK 版本,换一个 JDK 版本可能结果就差不少。JDK 9 之后字符串存储都变了,JDK 17 的优化又更激进,没必要对不同版本之间的横向比较过度纠结。
6.4 从代码审查角度给新手的几条建议
看过太多人因为字符串选型不当吃暗亏,我最后再分享几条代码审查时经常强调的实操经验。
第一条:方法内部的临时字符串拼接,无脑用StringBuilder。变量定义为局部变量,不要设为类的静态字段。
第二条:如果你发现一个方法里有超过三次连续的字符串+,且不是编译常量,建议转成StringBuilder。当然现在 IDE 都有自动转换提示,但动手前最好想一下为什么。
第三条:toString()方法的实现里,不要用+做大量拼接,直接上StringBuilder,尤其是对象字段多的时候,这是最常见的重构点。
第四条:日志框架自带的占位符{}本身就是延迟拼接,比手拼字符串更省资源。写日志时优先用logger.info("id={}", id),而不是logger.info("id=" + id)。前者只在日志级别开启时才拼接,后者无论如何都会构建字符串。
7. 我个人在使用中的最终体会
实际写代码这么多年,我越来越觉得字符串三兄弟本质上没有“谁替代谁”的关系,而是各管一段:String管不可变数据,StringBuilder管单线程可变构建,StringBuffer留给真正需要线程同步的少数场景。纯论性能,StringBuilder是默认答案;论正确性和易用性,String作为承载语义的容器依然不可替代。
最后说一个自己的小习惯:凡是自己写的工具类或者业务代码里出现字符串构建,我先问三个问题——这个构建器会不会跨线程共享?拼接次数多不多?能不能提前估出容量?三个问题答完,选型自动就出来了。如果你也能养成这个反射式的提问习惯,字符串相关的坑至少能避开八成。