☰
Java字符串三兄弟深度剖析:String、StringBuffer、StringBuilder源码级解读
2026/10/17 10:39:09 网站建设 项目流程

javaAPI 这个系列写到第二篇,我选了 String、StringBuffer、StringBuilder 这三个类。原因很直接:它们是 Java 开发里使用频率最高的 API 之一,但大多数人写了几年代码,对它们的理解还停留在一句口诀上——"String 不可变,StringBuilder 拼字符串快,StringBuffer 线程安全"。口诀本身没错,可一旦被追问"为什么不可变""快到底快在哪""所谓线程安全到底保到什么程度",很多人都答不上来。这篇文章就从源码和字节码两层把它们拆开,讲清楚不可变性换来了什么、字符串拼接的真正开销在哪、StringBuffer 为什么慢、扩容机制怎么影响性能,最后给出一套可以直接用在项目和面试里的选型标准与避坑清单。不管是准备面试,还是想在项目里把字符串处理写得更体面,都值得花二十分钟看完。

1. String 的不可变性:一个 final 类为什么能撑起 Java 世界的基石

1.1 从 char[] 到 byte[]:String 内部存储的两代变迁

先看源码。JDK 8 里 String 的核心字段就两个:private final char value[]和private int hash。char 是 16 位的,不管存中文还是英文,一个字符占两个字节。这个设计简单是简单,但对纯英文内容来说浪费了一半空间。JDK 9 开始引入了紧凑字符串(Compact Strings),value 从char[]换成了byte[],结构大致是private final byte[] value加一个private final byte coder。当字符串里所有字符都能用单个字节表示时,coder 标记为 LATIN1,内部按 8 位存;一旦出现中文这类需要两个字节的字符,coder 就切到 UTF16。

对大多数业务系统里动辄几十万的英文日志、报文、序列化结果来说,这一改能省接近一半内存,而且对使用方完全透明。你可能感觉不到这个变化,但那些动辄吃几个 G 堆内存的应用,瘦身功劳里有它一份。以后面试官问起 JDK 9 对 String 的优化,别只答"字符串常量池移了堆",紧凑字符串才是正解。

String 本身是final的,这意味着你不能写子类去继承、覆盖它的行为。内部的 value 数组也是 final 且不对外暴露,除了反射这种绕开语言限制的手段,没有任何途径在对象构造之后修改字符内容。这就是"不可变"的完整含义:不是不爱改,是物理上改不了。

1.2 不可变性换来的四个红利

为什么设计者执意要把 String 做成不可变?网上答案很多,但把底层逻辑讲全的少。我的理解是四个红利,缺一个都不行。

第一个红利是常量池缓存。JVM 维护一个字符串常量池,同一个字面量在编译期就会被合并,两个都用"abc"字面量的变量,运行期指向同一个对象。如果 String 可变,一处修改会导致所有引用这个字面量对象的地方全部跟着变,缓存机制根本不可能存在。字符串池的这段历史也很曲折:JDK 7 之前放在永久代,JDK 7 之后移到了堆里,这个迁移直接影响 intern() 的语义,后面专门讲。

第二个红利是 hashCode 惰性缓存。String 的int hash字段默认是 0,第一次调用 hashCode() 时遍历字符算一次,之后直接返回缓存值。因为内容不可变,这个缓存永远有效。这也是 String 能安全地当 HashMap 键、而且作为键的检索性能非常稳定的根本原因。换个可变对象当键,hashCode 变了,整个哈希表就废了。

第三个红利是线程安全。不可变对象天然线程安全,不需要任何同步手段就可以在多个线程之间共享。你可以在一个线程里构建一次 String,然后放心地扔给其他线程读,不用加锁、不用 copy-on-write,这些机制在不可变对象面前统统不需要。

第四个红利是安全。类加载器要读类名,网络请求要传协议和路径,文件系统要拼接路径,这些场景对字符串内容的安全性非常敏感。如果 String 可变,攻击者完全可以在某个环节篡改已经校验过的内容,产生连锁问题。正因为不可变,字符串在系统边界上传递时才不需要担心被第三方修改。

1.3 不可变的代价:为什么循环里不能直接改

红利拿够了,代价同样实在。所有"看起来在修改"的方法——substring、replace、toUpperCase、trim——本质上都是先创建新数组,把需要保留的部分拷过去,再 new 一个新 String。单次操作没什么感觉,一旦放进循环反复折腾,对象数量和拷贝量都会爆炸。

还有一个历史名坑值得知道:JDK 7 Update 6 之前,substring 为了省一次拷贝,新 String 会直接共享原字符串内部的 char[],只在对象里记下 offset 和 count。如果你从一个几十 MB 的大字符串里截取 10 个字符,这个小串会一直强引用着那个几十 MB 的数组,大对象怎么都回收不掉,内存就白白挂在那里。这个问题在 JDK 7u6 修掉了,改为复制内容。如果你还在维护低版本 JDK 的老系统,看到内存异常上涨,可以先查查是不是有 substring 留下的手尾。

2. 字符串拼接的三条路:加号、concat 和 StringBuilder 分别把钱花在哪

2.1 反编译看加号拼接的真相

先看最常见的加号。很多人以为+是编译期把结果算出来,其实不是。从 JDK 5 到 JDK 8,s1 + s2 + s3的编译逻辑是翻译成 StringBuilder 的 append 调用链,最后 toString。用 javap 反编译一个类就能看到,字节码里清清楚楚躺着new StringBuilder、invokespecial、append、toString这些指令:

javap -c StringConcatDemo.class

JDK 9 之后又改了一次,+的编译目标变成了invokedynamic,具体拼接策略由运行时决定。这项改造让单次拼接在一些场景下做得更聪明,可以减少临时对象。但请注意,它优化的是单次拼接场景,循环里反复把历史结果拿来再接的那种代码,该有的复制成本一点没少。

所以结论先给出来:写代码时能确定内容的几次拼接,直接用+,可读性最好,性能也完全够。别为了显得专业,把"order:" + orderId + ",user:" + userId硬改成 StringBuilder,那是画蛇添足。

2.2 循环拼接的坑:每次循环都在重建整个历史

真正的坑在循环里:

String s = ""; for (int i = 0; i < 100_000; i++) { s = s + i; }

这段代码每转一圈,都要 new 一个 StringBuilder,把 s 全部历史字符复制进去,再 append 数字,最后 toString 生成新 String 赋回给 s。前 1000 次还好,到第 5 万次,单次复制就是 5 万个字符,累计下来是 O(n²) 的复制量,产生的中间对象数以万计。这已经不只是慢的问题,而是时间和 GC 压力双双失控的问题。

我调试这类问题有个习惯:直接 javap -c 反编译看字节码,如果看到循环体里出现new StringBuilder,那这代码基本就是在用性能换写法。现在很多 IDE 也会对这种写法给出警告,但原理还是要自己懂,不然改完了也不知道为什么要改。

2.3 concat 方法的实现局限

String.concat(String)的历史比 StringBuilder 还早,实现也很朴素:新建一个len + otherLen的数组,把 this 内容和新内容依次复制过去:

public String concat(String str) { int otherLen = str.length(); if (otherLen == 0) { return this; } int len = value.length; char buf[] = Arrays.copyOf(value, len + otherLen); str.getChars(buf, len); return new String(buf, true); }

问题就在这——它每次都要复制完整内容,循环里照样是 O(n²)。而且它只收 String 参数,想拼数字、对象还得先 toString,写起来比+更啰嗦。所以除非是在非常老的代码里做局部小改动,否则我基本不推荐主动用 concat。

2.4 本机实测:100000 次拼接的数据对比

给你一组可以自己复现的粗测数据,方法写成一个简单的 main 方法:

public class StringJoinCompare { private static final int TIMES = 100_000; public static void main(String[] args) { long start = System.nanoTime(); String s = ""; for (int i = 0; i < TIMES; i++) { s += "java"; } System.out.println("String + : " + cost(start) + " ms, len=" + s.length()); start = System.nanoTime(); String t = ""; for (int i = 0; i < TIMES; i++) { t = t.concat("java"); } System.out.println("String.concat : " + cost(start) + " ms, len=" + t.length()); start = System.nanoTime(); StringBuilder sb = new StringBuilder(); for (int i = 0; i < TIMES; i++) { sb.append("java"); } System.out.println("StringBuilder : " + cost(start) + " ms, len=" + sb.length()); start = System.nanoTime(); StringBuffer buf = new StringBuffer(); for (int i = 0; i < TIMES; i++) { buf.append("java"); } System.out.println("StringBuffer : " + cost(start) + " ms, len=" + buf.length()); } private static long cost(long start) { return (System.nanoTime() - start) / 1_000_000; } }

在我这台老笔记本上,JDK 8 环境跑出来的量级大概是:

拼接方式100000 次耗时(约)
String +3200 ms
String.concat1800 ms
StringBuilder4 ms
StringBuffer9 ms

免责声明:这个数字跟机器、JDK 版本、是否经过 JIT 预热都有关系,别拿绝对值到处说,重点是量级差异。想认真对比的话,建议用专业的微基准测试框架,至少做几轮预热再统计。但无论怎么测,"循环内拼字符串必须用 StringBuilder"这个结论都不会变。

3. StringBuffer 的 synchronized 与 toStringCache:它是怎样慢下来的

3.1 继承体系里的唯一差别

StringBuffer 和 StringBuilder 是亲兄弟,同一个爹——都继承自AbstractStringBuilder。append、insert、reverse、扩容这些核心逻辑全在父类里实现好了,两个子类基本只是换了一层壳。

看代码最直观。StringBuilder 的 append 长这样:

@Override public StringBuilder append(String str) { super.append(str); return this; }

StringBuffer 的 append 长这样:

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

差别就两个:一个synchronized,一个toStringCache = null。前者决定了多线程下的安全性,后者是一个很多人不知道的缓存优化。整个 StringBuffer 比 StringBuilder 慢的部分,几乎都可以归因到这把锁上。

3.2 方法级线程安全的边界在哪里

synchronized 的开销分两种:无竞争时的偏向锁和轻量锁代价很小,单线程下 StringBuffer 相比 StringBuilder 通常只慢几个百分点;但一旦多线程高并发写同一个 StringBuffer,竞争会让性能断崖式下跌,大量线程阻塞在锁上。

更关键的是,很多人误解了"线程安全"的粒度。StringBuffer 的安全是方法级的,不是操作级的。看这段代码:

if (buffer.length() == 0) { buffer.append(data); }

length()和append()各自是线程安全的,但"先判断再追加"这个组合不是原子的。两个线程可能同时通过判断,然后都执行 append,结果跟预期不一致。要保证这类复合操作的原子性,你得自己在外面再加锁。既然如此,StringBuffer 的防护级别在实际业务里就很尴尬:要么你单线程直接上 StringBuilder,要么多线程复合操作还得自己加锁,那还不如干脆用 StringBuilder 配外部锁,少一层 synchronized 的重复开销。

3.3 toStringCache:被很多人忽略的缓存优化

StringBuffer 有一个 StringBuilder 没有的字段:private transient char[] toStringCache。它的 toString 实现长这样:

@Override public synchronized String toString() { if (toStringCache == null) { toStringCache = Arrays.copyOfRange(value, 0, count); } return new String(toStringCache, true); }

注意最后那个new String(toStringCache, true),这是个包私有构造函数,直接共享参数数组、不复制内容。第一次调用 toString() 时把当前内容复制到 toStringCache,之后只要内容没改过,再来多少次 toString() 都直接复用缓存,不再复制。任何修改操作(append、insert、delete、reverse)都会把 toStringCache 置回 null。

也就是说,StringBuffer 在"多次读取 toString 结果但内容不变"的场景下,比 StringBuilder 更有优势。StringBuilder 每次 toString() 都会老老实实复制一份新数组,因为它的结果可能被外部继续使用——万一外部把 String 的共享数组改了怎么办?String 是不可变的,共享数组理论上安全,但 JDK 为了省心选择了每次复制。这是个很有意思的设计取舍,也从侧面说明了 StringBuffer 从诞生起就更偏向"可以在多个线程里反复读取快照"的定位。

3.4 现实中什么场景才值得用 StringBuffer

说实话,新代码里我很少看到非用 StringBuffer 不可的场景。一个相对合理的用法是:某个共享的字符串构建器会被多个线程各自 append,而且你只想用最简单的方式保证每次 append 内部数组结构不坏——这种情况下 StringBuffer 一行到位,比手写 lock 省事。但如果你需要做任何跨步骤的判断、非 append 的修改,它就顶不住了。

提示:判断一个变量是否需要 StringBuffer,先问自己一个问题——这个构建器实例有没有被多个线程同时访问?如果答案是没有,哪怕你身处多线程环境,只要 StringBuilder 是方法内的局部变量,它就是安全的。局部变量天然属于当前线程栈,谁也抢不走。

4. 扩容机制与初始容量:被大多数人忽略的性能开关

4.1 翻倍扩容的均摊成本

要理解 StringBuilder 为什么快,光知道"内部是可扩容数组"还不够,得看它怎么扩容。以 JDK 8 的源码为例:

private int newCapacity(int minCapacity) { int oldCapacity = value.length; int newCapacity = (oldCapacity << 1) + 2; if (newCapacity - minCapacity < 0) { newCapacity = minCapacity; } return (newCapacity <= 0 || MAX_ARRAY_SIZE - newCapacity < 0) ? hugeCapacity(minCapacity) : newCapacity; }

默认容量是 16,不够了就把旧容量翻倍再加 2。复制数组是 O(n) 操作,但基于翻倍的策略,从一个 16 容量的数组涨到 100 万,总复制成本均摊下来是 O(n),也就是每次 append 平均是常数时间。这就是 StringBuilder 能在循环场景吊打+的根本原因。后续 JDK 版本对扩容细节做过调整,内部数组也从 char[] 变成了 byte[],但"翻倍扩容、按需申请"的思路一脉相承。

4.2 初始容量怎么估算才不浪费

既然扩容要复制,那最省的做法自然是一次到位。用构造器指定初始容量,规则很简单:预估字符串最终长度,留点余量就行。

// 预知最终内容大约 5 万字符 StringBuilder sb = new StringBuilder(50_000);

注意别设过头。capacity 是数组一次性分配的空间,设了 10 万但最后只用了 100,就是白占内存。估不准就不要硬猜,宁可默认容量让它自己扩容,也不要为了"万一"去申请一个巨大的数组。容量估算的核心是"够用就好,别为极端情况买单"。

4.3 ensureCapacity 的主动扩容与容量释放的替代方案

如果你确实估不准,但在某个时刻知道后面要追加一大段内容,可以调用ensureCapacity(int)主动预留:

sb.ensureCapacity(sb.capacity() + 4096);

这个方法会判断当前容量是否够用,不够才扩容。比被动触发少一次中间拷贝,适合"先拼了一小段,突然要接一个大块"的场景。

反过来的问题:构建完一个超大 buffer,但最终 toString 出来的字符串很小,前面申请的大数组就浪费了。这里有个冷知识——Java 的 StringBuilder 并没有像一些其他语言那样提供 trimToSize 方法,容量一旦涨上去就降不下来。如果这个 buffer 还要长期存活,你可以手动重建:

String result = sb.toString(); sb = new StringBuilder(result.length() + 16);

如果只是临时对象,拼完就丢,那就无所谓,交给 GC 处理。记住这个 trade-off:setLength(0)可以清空内容但保留容量,适合要反复复用的场景;真正想释放容量只能重建对象,没有别的办法。

4.4 一个 JSON 批量拼接的容量估算实例

举个实际例子。假设要拼一个包含 1 万条记录的 JSON 数组,每条记录序列化后平均约 200 字符,整体长度大约是 200 万字符:

int rows = 10_000; int avgLength = 200; StringBuilder sb = new StringBuilder(rows * avgLength + 1024);

为什么要这么算?如果不设容量,从 16 起步翻倍到 200 万,要经历大约 17 次扩容,每次扩容都要把已有内容全部复制一遍。虽然均摊后总复制量还是 O(n),但多出的 17 次大数组复制,在高频场景下是肉眼可见的耗时。预先估算容量,省掉的不是一次两次,而是一连串的复制操作。我见过不少性能排查案例,最后定位到的根因就是把大 JSON 拼到默认容量 StringBuilder 里,前面 90% 的时间都花在扩容复制上。

5. 三兄弟的性能对照与选型逻辑:别再凭感觉写代码

5.1 一个可直接运行的对照测试

前面已经给过一组数据,这里把对照维度拉全面一点。把 String、StringBuffer、StringBuilder 放在同一个场景里跑:

维度StringStringBufferStringBuilder
可变性不可变可变可变
线程安全天然安全方法级 synchronized不安全
单线程循环拼接差,O(n²)中等最优
多线程高竞争无此场景可能严重退化需外部加锁
toString 行为每次新建有缓存可复用每次复制
出现版本JDK 1.0JDK 1.0JDK 1.5

这张表基本把三个类的定位讲完了。String 是"创建后不再变的共享数据",StringBuilder 是"单线程下的构建工具",StringBuffer 是"退而求其次的线程安全版构建工具"。

5.2 从需求到选型的判断链

我自己的选型逻辑就三步,写代码时在脑子里过一遍:

第一步,内容在写代码时就能确定,而且只有几次拼接——直接用+。可读性最高,性能毫无问题。JDK 9 之后底层有 invokedynamic 优化,更不需要手动改。

第二步,内容要在循环或方法里逐步构建,且构建器实例不会跨线程共享——用 StringBuilder。这是绝对的主流场景,动态 SQL、JSON 组装、toString 方法、协议编解码,一律这么干。

第三步,同一个构建器实例会被多个线程并发 append,而且你接受方法级安全这种粗粒度保护——可以用 StringBuffer。但只要涉及先读后写、先判断再操作的复合逻辑,立刻放弃 StringBuffer,要么自己在外面加锁,要么换并发安全的数据结构。

提示:还有一个很容易被忽略的事实——方法内的局部 StringBuilder 天然线程安全。哪怕这个方法被一千个线程并发调用,每个线程拿到的都是自己那份实例,完全不共享。所以"多线程项目里不能用 StringBuilder"这个说法是错的,准确说法是"多线程共享的 StringBuilder 不能用"。

5.3 真实项目里的三个典型场景

第一个是动态 SQL 拼接。根据条件拼 WHERE 子句时,用 StringBuilder 配合条件判断逐段 append,最后 toString。这里记得先估算一下表结构大小,设个合理初始容量。

第二个是对象 toString() 方法。重写 toString 时用 StringBuilder 拼属性,比手工写一长串+效率高,可读性也不差。如果想进一步简化,Java 8 之后还有 StringJoiner 和Collectors.joining()可以用,那些属于 API 系列后续要聊的内容,这里先放个钩子。

第三个是日志拼接。很多人喜欢在日志里写logger.debug("user info: " + userDetail),注意函数参数是调用前就求值的,不管日志级别开没开,这行代码都做了一次完整拼接。正确做法是用日志框架的参数化写法:logger.debug("user info: {}", userDetail),这样在禁用 debug 级别时完全不会执行拼接。

6. 高频面试题与易错点:这些坑我见过太多人掉进去

6.1 new String("abc") 到底创建了几个对象

经典题。看代码:

String a = "abc"; String b = new String("abc"); System.out.println(a == b); // false

"abc"字面量在编译期进入常量池,a 指向池里那个对象;new String("abc")额外在堆上创建一个新对象,b 指向它。所以严格说,如果执行这行代码之前池里还没有 "abc",new String("abc")会创建两个对象——常量池一个、堆上一个;如果池里已经有了,就只多创建一个堆对象。

6.2 编译期常量 vs 运行期拼接的判定

这个点面试出现频率极高,关键在 final 修饰符:

String a = "a"; final String b = "b"; String c = "ab"; String d = a + "b"; // 运行期拼接 String e = b + "b"; // 编译期常量折叠 System.out.println(d == c); // false System.out.println(e == c); // true

普通变量的拼接是动态拼接,走 StringBuilder 或 invokedynamic,产物是堆上的新对象;被 final 修饰且初始化的字符串常量,参与拼接时会在编译期直接折叠成字面量,运行时复用常量池对象。很多人背过这个结论,但没理解背后的编译期常量折叠机制,换个问法就懵了。记住一句话:看拼接表达式里有没有"非 final 变量",有就是动态拼接。

6.3 intern() 在 JDK 6 和 JDK 7+ 的差异

这个是老面试题里的王者。先看这段代码:

String s = new StringBuilder("java").append("API").toString(); s.intern(); String s2 = "javaAPI"; System.out.println(s == s2);

"javaAPI" 这个字符串在整个源码里从未以字面量形式出现过,所以常量池里一开始没有它。JDK 7 之后字符串常量池移到了堆里,intern 的行为从"复制一份进常量池"变成了"把当前对象引用放入池中"。于是s.intern()把 s 这个堆对象本身放进了池里,后面字面量 "javaAPI" 首次使用时直接复用池中引用,s == s2是 true。而在 JDK 6 上,intern 会复制到永久代,返回的是另一个对象,结果就是 false。

这个差异坑过很多人:在旧 JDK 上验证过的 intern 经验,放到新版本上结论反了。同时提醒一句——业务代码里千万别用 intern 做字符串去重,字符串常量池里放大量实习字符串会让 GC 扫描成本飙升,得不偿失。

6.4 StringBuffer 的线程安全误区

前面讲过方法级安全不等于操作级安全,这里给个反面例子:

public class OrderBuffer { private StringBuffer sb = new StringBuffer(); public void appendIfEmpty(String data) { if (sb.length() == 0) { sb.append(data); } } }

两个线程同时调用 appendIfEmpty,可能同时通过length() == 0的判断,然后都执行 append,最终 buffer 里出现两条 data。说到底,StringBuffer 的 synchronized 只能保证单个方法的原子性,跨方法的复合逻辑它保护不了。面试时被问"StringBuffer 是线程安全的吗",标准答案应该是"方法级安全,但复合操作不安全"。

6.5 两个冷门但确实存在的坑

第一个坑是 append(null) 的重载陷阱:

StringBuilder sb = new StringBuilder(); sb.append(null); // 追加的是字符串 "null" sb.append((char[]) null); // 直接 NPE

append(String)对 null 做了特殊处理,追加的是四个字符 "null";而append(char[])不接受 null,直接抛空指针。这个差异平时不常踩,但一旦遇到就很迷,尤其是拼接数组内容时。

第二个坑是旧的 substring 共享数组问题,前面已经提到。如果你在维护低版本 JDK 的存量系统,记住这个历史遗留特性,定位内存问题时能少走弯路。新项目基本不用考虑,JDK 7u6 以后已经是复制语义了。

最后再分享一个我自己的习惯:凡是涉及字符串性能的讨论,我从来不背结论,而是先问自己三个问题——这个字符串会不会被修改?修改发生在循环里还是循环外?构建器会不会被多线程共享?三个问题问完,该用谁基本就清楚了。字符串三兄弟的 API 本身不难,难的是搞懂它们各自的设计代价和适用边界。把这篇文章里的源码逻辑过一遍,再回去看自己的项目代码,你会发现很多用+拼接的地方其实可以写得更好。

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

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

立即咨询