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 的经典写法是循环中追加手动加逗号判断。而用 StringJoinerStringJoiner 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 反而是更自然的选择。ListString 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 三类工具对比速览这里整理了一张表把三个工具按关键维度放在一起对比方便理解定位维度StringBuilderStringBufferStringJoiner引入版本JDK 1.5JDK 1.0JDK 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需要自定义空值就调用 setEmptyValueadd(数字) 编译报错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 都是安全的——只要你能保证这个对象只在一个线程里使用几乎没有例外。真正需要在多线程环境里共享可变字符串的场景大多数情况下应该先反思设计是不是出了问题而不是急吼吼地加锁应对。按照这个思路调整代码字符串拼接这一块基本不会再给你带来什么性能焦虑。
