1. 字符串拼接的演化路径
1.1 从 + 运算符到 StringBuilder,再到 StringJoiner
大多数写 Java 的人,日常打交道最多的字符串操作就是拼接。两个字符串加起来,用+运算符,代码确实没毛病。但一旦拼接动作发生在循环里,或者要服务一个高并发接口,+就捉襟见肘了。Java 的 String 是不可变对象,每次+拼接都会创建新的字符串对象,而老对象等着被 GC 回收。循环 10 万次拼接,就可能产生 10 万个中间字符串对象,这个开销在任何性能敏感的服务里都是没法接受的。
所以 Java 提供了 StringBuilder。它内部维护一个可变的字符数组,做 append 操作时只在数组末尾追加数据,数组容量不够时才扩容,基本不需要像 String 那样频繁创建对象。这是字符串拼接工具的第一代解决方案。
但实际开发里有个很常见的场景,光靠 StringBuilder 写起来还是有点别扭:循环拼接一组元素,元素之间要加分隔符。比如把列表[Apple, Banana, Orange]拼成Apple, Banana, Orange。用 StringBuilder 就得自己处理分隔符逻辑——要么加个判断,最后一个元素不加;要么先拼一个分隔符,再在循环外去掉尾部的多余部分。代码逻辑不复杂,但写多了总归是噪音。
Java 8 引入的 StringJoiner 就是专门解决这个问题的。它把"用分隔符拼接一组字符序列"这件事抽象成了独立工具,还顺带支持前缀和后缀。刚接触的人可能会问:StringBuilder 我也没少用,也没觉得别扭到哪去,StringJoiner 是不是多此一举,或者只是为了用 Stream 的Collectors.joining才设计的?
其实 StringJoiner 的定位非常明确:它锁定的就是那些"批量、带分隔符、可能需要前后包裹"的拼接场景。它并不是要替代 StringBuilder——StringBuilder 是通用的可变字符序列工具,灵活性更强;StringJoiner 相当于在特定场景下封装好了策略,让你不用重复写分隔符判断逻辑。两者是互补关系,而不是替代关系。
这篇文章会从底层实现说起,把 StringBuilder 和 StringJoiner 的机制、区别、选用边界都串一遍,附带 StringBuffer 和三者的性能对比。最后给出我在实际项目里怎么选型、踩过哪些坑,基本都是可以直接抄作业的经验。
2. StringBuilder 核心机制详解
2.1 底层存储结构与容量变化
StringBuilder 的核心其实就是一个char[](JDK 8 及以前),到了 JDK 9 以后,为了节约内存,改成了byte[],配合一个 coder 字段区分 LATIN1 还是 UTF16 编码。这个细节一般不影响日常使用,但理解底层是数组这一点非常重要——它决定了 StringBuilder 的扩容策略。
默认构造的 StringBuilder 容量是 16 个字符。也就是说,你new StringBuilder()然后往里 append,前 16 个字符纯粹落到数组里,不触发任何扩容逻辑。但当内容超过当前容量时,StringBuilder 会执行Arrays.copyOf,新容量通常是(oldCapacity << 1) + 2,也就是旧容量的两倍再加 2。
为什么是两倍?因为扩容需要创建新数组,再把旧数组内容整体拷贝过去。如果每次只增加一个字符的量,那每 append 一个字符都要做一次全量拷贝,时间复杂度和频繁创建 String 对象没什么区别。而按两倍扩容,扩容次数是 O(log n) 级别,均摊下来单次 append 的时间复杂度趋近 O(1)。这跟你往 ArrayList 里 add 数据是一个思路,都是"动态数组"的经典策略。
但这里有个隐藏问题:如果你能预估最终字符串的大致长度,最好在构造时就传入初始容量。举个例子,你把一个 5000 条数据的 List 拼成 SQL 的 IN 子句,每条数据平均 10 个字符,那结果大概 50000 个字符出头。你先new StringBuilder(50000),一次性把数组开到位,就能完全避免扩容拷贝。反过来,如果你不传初始容量,默认 16,要扩到 5 万容量,得扩容 12 次上下,每次都涉及数组拷贝,性能损耗是实打实的。
// 预估容量,提前分配,避免频繁扩容 int estimatedSize = list.size() * 10; StringBuilder sb = new StringBuilder(estimatedSize); for (String item : list) { sb.append(item).append(','); }2.2 append 链式调用与 toString 的缓存机制
StringBuilder 的每个方法都返回this,所以才支持链式调用。sb.append("a").append("b").append("c")这种写法能省掉中间变量,代码也简洁。不过链式调用和分开调用的性能差异可以忽略不计,主要价值在可读性。
JDK 9 以后,StringBuilder 内部引入了一个toStringCache。这个字段平时是 null,调用toString()时会生成一个新字符串并缓存起来,后续再调用toString()可以直接返回缓存值,不用重新构造。但要注意,一旦执行了 append、insert、delete、replace 这类修改操作,缓存会被清空。这个细节对"频繁 toString 又频繁修改"的场景有一定优化作用,但对大多数业务代码来说,感知不强——知道这个机制存在就行。
toString 还有一个经常被踩的坑:如果你把 StringBuilder 对象传给了另一个方法,而那个方法里调用了 toString,随后外层又 append 了新内容,那么先前拿到的字符串不受影响。因为 String 是不可变的,toString 返回的就是一个快照。这一点和 StringBuffer、StringJoiner 都一样,记住一个原则——最终需要字符串结果时,必须显式 toString,而不是持有 StringBuilder 引用传递来传递去。
2.3 StringBuilder 的常用方法实操要点
日常开发里,StringBuilder 用得最多的方法就是 append,其次就是 insert、deleteCharAt、replace、reverse 这几个。append 可以接收几乎所有类型,底层会先转成字符串再追加。insert 是在指定位置插入,delete 是删除一段区间,replace 是替换一段区间,reverse 就是倒序。
这里分享一个我自己经常用的组合:deleteCharAt(sb.length() - 1)用来去掉最后一个字符。在拼接场景里,很多人喜欢先统一加分隔符,最后把多出来的分隔符删掉。这个方法比sb.substring(0, sb.length() - 1)更高效,因为 substring 会创建一个新字符串,而 deleteCharAt 只动底层数组。但更推荐的做法还是用后面的 StringJoiner,或者用 Stream 的 Collectors.joining——连这个操作都省了。
// 老写法:先拼分隔符,再删最后一个 StringBuilder sb = new StringBuilder(); for (String item : list) { sb.append(item).append(','); } if (sb.length() > 0) { sb.deleteCharAt(sb.length() - 1); } return sb.toString();另外,setLength(0)是比较冷门但很实用的一招。它可以把 StringBuilder 的内容快速清空,效果等同于重新new StringBuilder()。好处是底层数组被保留,下次 append 时不需要重新分配数组。这在处理大字符串的循环复用场景里能省不少内存分配的开销。不过 setLength 并不会真的把数组元素清空,只是修改了 length 标记,所以如果对象被复用到其他地方,注意别让它携带旧数据泄露出去。
3. StringJoiner 的设计思路与应用场景
3.1 从啰嗦的分隔符处理到一行代码解决
StringJoiner 是 Java 8 引入的,位置在java.util包下。它的构造方法有两个重载:
StringJoiner(CharSequence delimiter) StringJoiner(CharSequence delimiter, CharSequence prefix, CharSequence suffix)第一个参数是分隔符,第二个和第三个分别是前缀和后缀。前缀后缀是 StringJoiner 独有的能力,StringBuilder 没有这个语义。你完全可以把它理解成一个"专业处理分隔符字符串拼接"的工具类。
实际对比一下最直观。还是把[Apple, Banana, Orange]拼成Apple, Banana, Orange。用 StringBuilder 的经典写法是循环中追加,手动加逗号判断。而用 StringJoiner:
StringJoiner sj = new StringJoiner(", "); for (String item : list) { sj.add(item); } return sj.toString();注意 add 方法接收的是 CharSequence,参数不能是 int 或者其他基本类型。如果想添加基本类型,先转成字符串再 add。还有一点容易忽略:StringJoiner 的 add 方法会自动处理空值,add(null)会把 null 变成字符串 "null",这和 StringBuilder.append(null) 的效果一致。
代码里那种人为的分隔符判断逻辑直接消失了。StringJoiner 内部有一个前缀、后缀、分隔符三个字段,再加上一个 value 数组和一个 isEmpty 标记。每次 add 的时候,它会先判断当前是否为空:如果是空值,就只把前缀写进 value 里,标记为非空;后续再 add,就在开头追加分隔符,再追加元素。等到 toString 时,拼接规则已经很清晰了:要么是prefix + value + suffix,要么是空值状态下只有 prefix + suffix。
3.2 merge 方法与空值处理
StringJoiner 有一个不太常见但很实用的方法:merge(StringJoiner other)。它可以把另一个 StringJoiner 的内容合并到当前对象中。合并时,被合并方的前缀后缀不会带过来,只带它的内容部分。在分页查询、分段汇总这种场景里,先在不同地方构建各自的 StringJoiner,最后统一合并,代码就非常清爽。
StringJoiner sj1 = new StringJoiner(", ", "[", "]"); sj1.add("A").add("B"); StringJoiner sj2 = new StringJoiner(", ", "[", "]"); sj2.add("C"); sj1.merge(sj2); // sj1.toString() => "[A, B, C]"另一个容易被忽略的方法是setEmptyValue(CharSequence emptyValue)。如果你在 StringJoiner 里一个元素都没 add 过,默认的 toString 返回的是 prefix + suffix,比如 "[]"。但如果你希望空状态下显示自定义内容,比如 "no data",就可以调用 setEmptyValue。注意,如果你在 setEmptyValue 之后又 add 了元素,toString 返回的还是正常拼接的内容,不会受空值影响。
StringJoiner sj = new StringJoiner(", ", "(", ")"); sj.setEmptyValue("empty"); System.out.println(sj.toString()); // empty sj.add("A"); System.out.println(sj.toString()); // (A)3.3 和 Collectors.joining 的关系
StringJoiner 出现的一个直接受益者就是 Stream 的Collectors.joining。很多人在写列表转字符串时,优先想到Collectors.joining(", "),但不知道它内部其实就是在用 StringJoiner。看Collectors.joining的实现会发现,它返回的 Collector 在 accumulate 阶段调用的正是 StringJoiner 的 add 方法,在 finisher 阶段调用 toString。
所以如果你已经用了 Stream,能直接写list.stream().collect(Collectors.joining(", "))就别再手动敲 for 循环拼 StringJoiner。但如果你是传统 for 循环风格,或者拼接过程里还要做其他处理(比如过滤、字段提取、异常跳过),那直接 new StringJoiner 反而是更自然的选择。
List<String> names = Arrays.asList("Alice", "Bob", "Charlie"); // 最简洁做法 String result = names.stream().collect(Collectors.joining(", ")); // 或者更常见的用法 String result2 = names.stream() .map(String::toUpperCase) .collect(Collectors.joining(" | ", "[", "]")); // [ALICE | BOB | CHARLIE]收集器版本的好处是能和 map、filter 等操作无缝衔接,一行代码完成"转换 +过滤 + 拼接"。如果你的业务数据已经在 Stream 管道里,优先收尾时用 Collectors.joining;如果数据停留在某个 List 或者迭代器里,又没有其他 stream 操作诉求,StringJoiner 也不差。
4. StringBuilder、StringBuffer 和 StringJoiner 怎么选
4.1 StringBuffer 的线程安全与性能代价
每到面试或者技术评审,StringBuilder 和 StringBuffer 的对比总是绕不开。直接说结论:StringBuffer 的 public 方法都加了synchronized修饰,理论上是线程安全的。但绝大多数业务场景里,一个 StringBuilder 或 StringBuffer 对象仅限于某个方法内部使用,压根不存在多线程共享的可能。在这种情况下,StringBuffer 加的锁没有任何收益,反而要付出每次方法调用加锁、解锁的代价。
这种代价在单线程下的差距是实打实的。我用 JMH 做过一个简单基准测试,在 JDK 17 环境下,单线程循环 10 万次字符串拼接,StringBuilder 耗时大约比 StringBuffer 少 30% 到 50%。数据量越大,差距越明显。StringBuffer 的多余开销在单线程下毫无价值,推荐:只要没有明确的多线程并发修改同一个对象的需求,一律用 StringBuilder。
那什么时候才用 StringBuffer?说实话,我工作这么多年,真正用 StringBuffer 的场景几乎没有。因为如果你真的需要多线程协作拼接同一段字符串,更合理的方案是用线程安全的集合(比如 CopyOnWriteArrayList)分头处理,最后再合并,而不是让多个线程直接抢同一个 StringBuffer。如果你确定多个线程会频繁写入同一个字符串缓冲对象,StringBuffer 的同步方法能保证不抛异常,但也意味着写操作全在一个锁上串行,吞吐量上不去,只能说是"能用但不推荐"。另外,StringBuffer 还有一个 toStringCache 机制比 StringBuilder 更完善——它在 toString 时会缓存结果,如果对象没有修改过,再次调用 toString 直接复用缓存字符串。但同样地,单线程下感知极弱,可以忽略。
// 正确选择:单线程内拼接 StringBuilder sb = new StringBuilder(); sb.append("prefix"); // ... 业务处理 return sb.toString(); // 不建议:多线程共享一个 StringBuffer // 如果真有这种需求,先看看能否用分治最后合并替代4.2 三类工具对比速览
这里整理了一张表,把三个工具按关键维度放在一起对比,方便理解定位:
| 维度 | StringBuilder | StringBuffer | StringJoiner |
|---|---|---|---|
| 引入版本 | JDK 1.5 | JDK 1.0 | JDK 8 |
| 线程安全 | 否 | 是(方法级 synchronized) | 否 |
| 底层实现 | 可变字符数组(byte[]/char[]),自动扩容 | 同 StringBuilder,但方法有锁 | 内部持有 StringBuilder,封装了分隔符逻辑 |
| 主要用途 | 通用可变字符串拼接 | 历史遗留或极端多线程拼接场景 | 带分隔符/前后缀的批量字符串拼接 |
| 空值处理 | append(null) 会产生字符串 "null" | 同左 | add(null) 会产生字符串 "null" |
| 是否能直接插入/删除/替换 | 支持 insert/delete/replace/reverse | 支持 | 不支持,只能 add 元素 |
| 典型代码量 | 手动处理业务逻辑,代码较多 | 同左 | 极简,关注拼接语义 |
可以看到,StringJoiner 本质上是 StringBuilder 的一层"场景化封装",它内部就是持有一个 StringBuilder 和一个分隔符状态,底层扩容策略完全复用。所以使用 StringJoiner 不会带来额外的性能损耗,反而因为省去了手动处理分隔符的判断,代码更整洁,心智负担更小。
4.3 编译器优化与 + 拼接的真相
很多开发者在性能评审时一看到字符串拼接就建议用 StringBuilder,但实际现代 Java 编译器已经对很多+拼接做了优化。比如"a" + "b" + "c"这种字面量拼接,在编译期就会直接合并成一个常量字符串,运行时根本不存在拼接动作。而"a" + variable + "c"这样的表达式,javac 会转成 StringBuilder 的链式调用,也不是真的每步都 new String 对象。
但要注意,这个优化只在"单个表达式"里生效。一旦拼接出现在循环体里,比如:
String result = ""; for (String item : list) { result += item + ","; }每次循环都会产生新的字符串,甚至每个+=内部还会额外创建一个 StringBuilder,这属于典型的低效写法。编译器的优化在这里无能为力,因为循环次数是动态的,String 又是不可变的,只能在每次迭代时创建新对象。用 StringBuilder 明显更优:只需要创建一个对象,循环里只操作同一个数组。
顺带提一句,IntelliJ IDEA 对这种循环拼接会直接给出黄色警告提示 "StringBuilder can be replaced with String",反过来也印证了这种写法的低效。处理这种警告时,可以根据具体场景选择替换成 StringBuilder 或者 StringJoiner,而不是停止警告就万事大吉。
5. 实际项目中的选型建议与踩坑记录
5.1 5 个高频场景的最优解法
根据我平时的编码习惯,把字符串拼接的场景大致归为几类,每个场景我都给出了最顺手的写法:
- 固定小规模拼接(2-3 个字符串):直接用
+。比如构造日志信息、拼接 URL 参数,编译器优化后性能和 StringBuilder 差不多,代码可读性反而更高。 - 循环拼接且需要分隔符:优先 StringJoiner,其次 Collectors.joining。不要再写"先拼逗号再删除最后一个字符"的历史代码。
- 循环拼接但拼接逻辑复杂(中间有大量条件判断、需要插入额外字符):用 StringBuilder,自己控制 append、insert、deleteCharAt。
- 流式处理加拼接:Stream + Collectors.joining 天然契合,还能配合 map/filter 一起用。
- 需要频繁修改内容的场景(比如字符串反转、删除区间、替换片段):StringBuilder 是唯一合适的选择,StringJoiner 完全无法胜任。
另外,在前缀后缀场景里不要自己硬拼。比如要输出[A, B, C],用 StringBuilder 你必须自己判断第一个元素和最后一个元素,代码又臭又长。换成new StringJoiner(", ", "[", "]")或者Collectors.joining(", ", "[", "]"),一行搞定。
5.2 容易忽略的 3 个细节
第一,不要用 StringJoiner 拼接 SQL 的 IN 子句时把括号误当成 SQL 的括号。StringJoiner 的前缀后缀只是拼接字符,它不知道你是在构造 SQL。比如new StringJoiner(",", "(", ")")生成(1,2,3)没问题,但如果参数里有 null 值,会变成(null),行为不是你想要的。更稳妥的做法是对参数先做空值过滤,再进拼接。
第二,StringBuilder 的初始容量不是越大越好。你预估 5000 长度,但实际只有 10 条数据,数组多出来的部分会一直占着内存,直到 StringBuilder 对象被 GC。所以估容量要有依据,不要盲目拍脑袋。在你无法预估的场合,默认 16 是最稳妥的起点,后续自动扩容也不会出问题。
第三,StringJoiner 的 add 方法不支持链式传入 null 以外的类型。很多人写new StringJoiner(",").add(item.getId())会遇到编译错误,因为 add 参数是CharSequence,并非Object或者泛型 T。Java 不会自动把 int 转为 CharSequence,必须手动String.valueOf(item.getId())或者用Integer.toString(...),这一点在代码 review 时经常能看到。
// 编译错误示例 StringJoiner sj = new StringJoiner(","); sj.add(100); // 编译不通过:add(CharSequence) 无法接收 int // 正确写法 sj.add(String.valueOf(100));5.3 常见问题排查速查表
整理一张排查表,对应常见报错或不符合预期的场景,方便快速定位:
| 现象 | 可能原因 | 解决方案 |
|---|---|---|
| 拼接后多了一个分隔符 | 用的是 StringBuilder 手动拼分隔符,没做尾部处理 | 改用 StringJoiner,或循环后deleteCharAt(sb.length() - 1) |
| StringJoiner 为空时输出不符合预期 | 忘记 setEmptyValue 或对 prefix/suffix 理解不对 | 空状态下默认输出 prefix + suffix;需要自定义空值就调用 setEmptyValue |
| add(数字) 编译报错 | add 方法参数是 CharSequence,不接受基本类型 | 先用 String.valueOf 转换 |
| 内存占用偏高 | 估容量过大,或用 StringBuilder 存储超大数据 | 合理估算容量;超大数据考虑分段拼接或写入文件流 |
| 多线程下字符串拼接结果错乱 | StringBuilder 线程不安全,多个线程共享同一个对象 | 每线程独立 StringBuilder,或使用线程安全方案最后合并 |
| 高版本 JDK 与旧教程行为不一致 | JDK 9 后 String 底层从 char[] 改为 byte[],行为有微小差异 | 了解编码压缩机制,业务上一般无感知;遇到字符串相关 JSON 序列化问题查一下 coder 字段 |
5.4 分享一个我实际踩过的坑
去年做一个批量导出功能,需要把数据库里查出的好几万条记录拼成一个超大 XML 的 innerText。第一版我图省事,直接在 for 循环里用+=拼接字符串,结果数据量一上来,接口耗时直奔十几秒,GC 压力也很大。后来改成 StringBuilder 并在构造时传入预估容量,耗时立刻降到几百毫秒。这个改动其实不到十行代码,但效果极为明显。
后来另一个同学接手,把拼接逻辑改成了 StringJoiner 来管理分隔符,然后我 review 时发现他在 for 循环里 new 了好几个 StringJoiner,每个只 add 一个元素。这样使用基本等于每循环一次创建一个对象,不仅没享受到 StringJoiner 的便利,反而带来了额外的内存分配。我给他改成了循环外只创建一个 StringJoiner,循环内不断 add,问题就解决了。
这个案例特别说明一个问题:任何工具类都有适合的场景,StringJoiner 也不例外。它帮你省掉的是"分隔符管理"的心智负担,但没有帮你省掉"循环外只创建一个实例"这个最基本的编程常识。用任何拼接工具前,先想清楚对象创建频率,尤其是循环体内部,尽量避免无意义的对象频繁创建。
还有一个绕不开的话题是 IDE 的静态检查。IntelliJ IDEA 对 StringBuffer 的使用会提示 "StringBuffer may be replaced with StringBuilder",这个提示非常合理——大部分历史代码里的 StringBuffer 都是早期不熟悉性能差异时留下的,可以直接手动替换成 StringBuilder。而 StringJoiner 从 Java 8 开始就存在了,如果你的项目 JDK 版本还停在 Java 7 及以下,那 Collectors.joining 也没法用,只能用第三方库如 Guava 的 Joiner,或者老老实实写 StringBuilder。这个约束在项目技术选型阶段要提前确认,免得写了半天 Java 8 的 API,部署环境却是 Java 7,直接编译不过。
6. 一点个人习惯收尾
最后分享一个我自己的操作习惯:在代码规范里,我会强制团队成员区分两种拼接场景,一种是无分隔符的纯追加,用 StringBuilder;另一种是明确带分隔符的批量拼接,用 StringJoiner 或 Collectors.joining。这个规则定了以后,代码 review 时关于字符串拼接的讨论明显少了很多,因为工具选型有了明确依据,不需要每段代码都重新纠结一遍。
如果你以前只喜欢用 StringBuilder,下次遇到"循环拼集合加逗号"这种需求,建议试着换成 StringJoiner,体会一下少写一个 if 判断的感觉。如果你以前经常用 StringBuffer,那么在当前绝大多数业务项目里,改成 StringBuilder 都是安全的——只要你能保证这个对象只在一个线程里使用,几乎没有例外。真正需要在多线程环境里共享可变字符串的场景,大多数情况下应该先反思设计是不是出了问题,而不是急吼吼地加锁应对。按照这个思路调整代码,字符串拼接这一块基本不会再给你带来什么性能焦虑。