字符串拼接用“”还是 StringBuilder这问题我面试别人的时候几乎每次都会问。但比答案更让我在意的是很多人给出的理由有人觉得“”写起来顺手就一直用有人觉得StringBuilder是“性能银弹”于是所有拼接都声明一个。说句实话两种极端都谈不上对。真正需要搞清楚的是这两种方式在JVM里到底是怎么执行的以及它们之间的差距会在什么场景下被拉大成线上故障。这篇文章就结合我实际压过的一组数据、排查过的几次线上问题把“”和StringBuilder的底层差异、选择边界、常见翻车点一次讲透。如果你是正在写Java业务代码的人这套判断逻辑可以直接拿去用。1. 先搞清楚“”和 StringBuilder 在底层到底发生了什么1.1 一个表达式里的“”其实被编译器偷偷改了很多人在讨论这个话题时默认“”就是简单直接的字符串拼接StringBuilder是高级优化性能一定更好。这个印象一半对、一半错。先看编译期发生了什么。在JDK 8时代javac遇到字符串“”时会把源码改写为一段等价逻辑new一个StringBuilder然后依次append最后toString。也就是说你写着a b c编译后的字节码其实是new StringBuilder(a).append(b).append(c).toString()。到了JDK 9之后Java又改了一次引入了invokedynamic和StringConcatFactory不再显式地在字节码里创建一个StringBuilder对象而是把拼接策略推迟到运行期决定。JDK 9这个改动本身就是为了让字符串拼接可以利用更灵活的运行时策略甚至在字符数组层面直接操作。所以第一条结论是在一个表达式里连续写多个“”编译器早就帮你处理过了不会一个“”就创建一个中间对象。你不用担心id: userId ,name: userName ,age: age这种写法会产出什么可怕的对象垃圾它本质上就是一次编译期优化过的拼接。但注意这里有个非常容易被忽略的前提我说的是“一个表达式里的多个”。你把“”分散写到循环体里、写到多个分支里编译器能做的工作就很有限了。举例来说String s ; for (int i 0; i 10000; i) { s s , values[i]; }这段代码每一轮迭代都在执行两个拼接动作s ,和(结果) values[i]。在JDK 8的字节码视角下每一轮都会new一个StringBuilder、多次append、toString然后上一轮的String被丢弃。循环一万次就创建了一万多个临时StringBuilder对象外加一万多个中间String。JDK 9之后的invokedynamic也不是神循环里这么写运行期策略再怎么优化也无法完全消掉每次迭代的字符串复制成本。一句话总结“”在单条表达式里是被编译器优化过的在循环跨迭代拼接里依然是低效写法。1.2 StringBuilder 的工作原理和扩容机制StringBuilder本质上是一个可变的字符序列容器。它内部维护一个char[]JDK 9之后是byte[]配合编码标记字段你调用append时它不会创建新的字符串对象而是直接把字符写进这个数组里数组不够用了就扩容。扩容是怎么做的默认构造的StringBuilder容量是16。当append的内容超过当前容量时它会计算新容量规则大致是(oldCapacity 1) 2也就是旧容量翻倍再加2。之后把旧数组里的内容拷贝到新数组再继续写入。这个机制和ArrayList的扩容挺像的区别在于一个拼字符一个存引用对象。这里有一个很实用的细节如果你明确知道拼接结果的长度大概是多少最好直接使用new StringBuilder(expectedCapacity)构造方法指定初始容量。比如我经常在项目里拼JSON字符串长度基本能估算出来就会给初始容量留一点余量。这样做的好处是减少扩容次数避免多次大数组拷贝。扩容是O(n)操作拼一个大字符串如果反复扩容那块成本虽然单次不高在高频路径上会被放大。所以StringBuilder的优势不在于“语法上更高级”而在于它是一种可变对象通过复用内部数组来避免产生大量中间String。理解了这一层很多网上流传的“无脑用StringBuilder”的说法就显得站不住脚——如果根本不在循环里、不出现跨迭代拼接StringBuilder的优势根本发挥不出来。2. 别站队什么样的拼接场景该选哪个2.1 这些场景请放心用“”单一表达式拼接是“”的主场。典型例子String logMessage userId userId , cost cost ms; String sql select * from user where id id; String title 第 page 页共 totalPage 页;这类代码的特点是拼接次数少、字段数量固定、不会出现在高频循环里。编译器已经把同一条表达式优化为一次拼接性能上和你手写StringBuilder没有实质区别。可读性上却有很大优势人和机器都能一眼看明白要拼成什么样。另外在一些框架参数传递、动态SQL拼接、日志输出场景里如果只是少数几个字段的拼接“”是最自然的选择。我见过不少人为了这几种小场景强行引入StringBuilder写出来的代码格式变成了StringBuilder builder new StringBuilder(); builder.append(userId).append(userId);看着很规矩但把简单的逻辑写复杂了。而且说实话这种写法在性能上和直接用“”没有任何肉眼可见的差距牺牲的唯一东西就是代码可读性。再补充一个点如果你不确定要不要优化先问自己这段代码是不是hot path。大多数业务代码接口里字符串拼接占的时间几乎可以忽略不计。先保证清晰再考虑优化。2.2 遇到这些场景再考虑 StringBuilder真正需要StringBuilder站出来的时候通常是下面几类场景。第一类是循环拼接。把集合里的元素拼成一个长字符串或者在循环里不断追加某种格式的记录。这类场景最典型的反面教材就是开头提到的s s , value。正确做法是循环外先声明一个StringBuilder循环内只做append最后统一toString。第二类是拼接逻辑复杂、分支很多的情况。比如动态拼SQL根据条件拼不同的WHERE、ORDER BY。这种场景下StringBuilder的append方法链天然适合逐步构建比用拼一堆中间变量清晰得多也容易排查问题。第三类是拼大字符串、长文本比如导出文件内容、生成批量请求报文。大字符串拼接如果还用“”哪怕只在一条表达式里也可能因为中间结果太大而带来不必要的内存拷贝。StringBuilder可以预估容量刚好用在刀刃上。2.3 我总结的一套选择口诀在团队里做代码评审时我经常给新人讲一个简单判断流程基本三句话就能决定用哪种写法先看这段拼接是否在循环里在循环里用StringBuilder并且new要放在循环外。再看结果字符串是不是很大几千、几万甚至更大的用StringBuilder并预估容量。最后看拼接表达式是不是一行能写完能写完直接“”别折腾。这个口诀覆盖了日常90%以上的场景。剩下的集合转字符串、模板格式化是有更好工具的后面会专门讲。3. 实战实测同样一段日志拼100万次差距能有多大3.1 实测环境与测试用例光讲原理容易飘我直接放一份自己压过的数据。测试场景很简单模拟日志系统每条日志大约120字符左右包含时间戳、级别、方法名、业务ID、耗时等字段拼好之后写入一个计数容器里丢弃。总共循环100万次分别用几种不同写法对比耗时。测试环境是本地开发机JDK 11默认参数没做特殊JVM调优。这个数据不是用来当基准的大家可以把它理解成一种数量级参考。不同机器、不同字符串长度下绝对数值会有差异但相对差距会非常稳定。写法很简单典型代码如下所示// 写法A直接 String log ts | level | method | bizId | cost; // 写法B每次循环new StringBuilder StringBuilder sb new StringBuilder(); sb.append(ts).append(|).append(level).append(|).append(method) .append(|).append(bizId).append(|).append(cost); String log sb.toString(); // 写法Cnew StringBuilder预估容量 StringBuilder sb new StringBuilder(128); sb.append(ts).append(|).append(level)...; String log sb.toString(); // 写法DString.format String log String.format(%s|%s|%s|%s|%s, ts, level, method, bizId, cost);3.2 实测结果测下来的数据大致是这样的写法100万次耗时经验值特点直接拼接500ms ~ 700ms单条表达式编译器优化后有一定开销new StringBuilder再拼接150ms ~ 250ms避免了中间String生成预估容量StringBuilder100ms ~ 150ms减少扩容逻辑比不预估快一些String.format2500ms ~ 3500ms最慢因为每次要解析格式字符串这个结果估计会让一些人大吃一惊原来直接“”并没有慢到像传说中那么夸张但确实比StringBuilder慢不少。而String.format才是真正的性能陷阱在循环里用String.format拼日志效果是灾难级别的。所以回到最初的问题直接“”和StringBuilder差距大吗在循环、热路径里确实大在单次拼接场景里差别基本可以忽略。这就是我为什么强调先判断场景。3.3 慢的根源不在语法而在对象分配既然测出来差距不小那到底慢在哪里很多人认为是“”这个操作本身效率低其实不完全是。直接“”的主要开销来自创建了太多中间对象尤其是字符串内容较长的时候。每一次拼接都要把左右两边的内容复制到一个新的字符串数组里旧字符串变成垃圾等待GC回收。在100万次循环里这个浪费会被放大得非常明显甚至可能频频触发Young GCGC一多接口毛刺就出来了。StringBuilder为什么快因为它把内容往同一个数组里写只在最后一次toString时复制一份完整内容。中间对象从“每步一个”降低到“几乎为零”GC压力自然大幅度下降。这也是为什么我建议大家不要迷信JIT逃逸分析。JIT确实有可能在运行期把部分“”拼接的中间对象优化掉但那是建立在逃逸分析能确认对象没有跑出当前方法的前提下而且不同版本、不同编译阈值下表现并不稳定。你永远不该把线上性能押注在JIT会不会帮你优化这种事情上而是应该从源码层面就把正确的写法写出来。4. 真实项目里最常见的拼接翻车现场4.1 循环里用“”拼SQL条件的悲剧有次接手一个老项目发现某个列表查询接口在数据量上来之后越来越慢慢到什么程度从最初的几十毫秒涨到了十几秒。一开始大家以为是SQL索引问题排查了一圈发现根本不是。后来定位到一段代码在for循环里用拼IN语句的条件占位符。String inClause ; for (Long id : idList) { inClause ?,; } inClause inClause.substring(0, inClause.length() - 1);idList有几百上千个元素时这个拼接过程每一轮都要复制一次已经累积的字符串越到后面要复制的字符越多总体成本接近O(n²)。当idList上万时光是拼这一小段条件就耗时严重。后面我把这段改成循环外建StringBuilder循环内append接口耗时立刻掉到几百毫秒以内。这个案例里的问题不是“”单条拼接慢而是把拼接分散到了循环跨迭代的写法里自己给自己挖了坑。所有踩过这个坑的人基本都会长记性看见循环里有字符串累加直接条件反射地改成StringBuilder。4.2 把 StringBuilder 写成全局共享变量的坑还有一类反例是为了解决“循环拼接慢”的问题有人直接把StringBuilder提到了方法外面甚至放到类的静态字段里做成一个全局公共缓冲。表面上看起来每次都在用同一个StringBuilder省了new对象的开销实际上问题更大。StringBuilder本身是线程不安全的。多个线程同时append时内部数组会互相覆盖数组长度判断也会出问题轻则拼接结果串号、顺序错乱重则数组越界抛异常。还有一个隐蔽的坑调用toString()之后StringBuilder已经被“输出”了一份但如果你继续往它里面append之前拿到的String已经完全独立了不受影响可如果后续拼接结果再次toString你会发现内容里混了新数据。这种隐性问题比直接报错还难排查。线上数据错乱、偶发异常多数都不是“性能问题”而是“并发问题”但源头就是当初拍脑袋共享了一个可变对象。正确做法是StringBuilder的生命周期跟随方法方法内new、方法用、方法结束被回收。除非你能证明这个缓冲创建本身的成本已经大到不可接受否则不要为了省对象去承担共享可变状态的风险。4.3 为了用 StringBuilder 而用 StringBuilder反而有害还有一种比较常见的翻车现场不是为了性能而是“团队规范要求必须用StringBuilder”。结果一些同事把两条固定字符串也写成StringBuilder sb new StringBuilder(); sb.append(Hello); sb.append(, ); sb.append(name);这代码写得不能算错但完全没有必要。可读性比Hello, name差代码行数多还容易引出一个隐藏问题如果后续有人在这个Builder上追加了别的内容而当时没注意到输出就可能变掉。字符串拼接这种逻辑本来就该越直观越好。我在实际项目中见过太多“盲目最优解”不加条件判断地全局替换“”、禁止所有场景使用String.format、要求所有日志拼接都用StringBuilder。这种一刀切的规范往往是在没有复现性能瓶颈的情况下用代码可读性换来了一个不存在的性能提升。真正合理的做法是先量化、再优化而不是靠感觉定规矩。5. 除了“”和 StringBuilder这些拼接工具也该认识下5.1 String.join、StringJoiner 和 Stream joining 的定位如果说“”和StringBuilder是基本操作那集合转字符串这种场景我建议你直接忘掉手写循环用现成工具。String.join最典型的用法是String csv String.join(,, userIds);它接收一个分隔符和一个可迭代对象或数组内部会帮你处理好首尾不加分隔符的问题。如果你写循环自己判断“这是不是第一个元素”代码会多出一大截还容易漏掉边界情况。StringJoiner更灵活一些可以在构造时指定分隔符、前缀和后缀StringJoiner joiner new StringJoiner(,, [, ]); joiner.add(a).add(b).add(c); String result joiner.toString(); // [a,b,c]它很适合用来构造“带前后包裹的列表字符串”比如生成JSON数组片段或SQL参数列表。Java 8的Stream里还有一个Collectors.joining它本质上也是基于StringJoiner实现的支持分隔符、前缀、后缀在流式处理里很顺手String result userIds.stream() .map(String::valueOf) .collect(Collectors.joining(,, [, ]));这几个工具的共同特点是语义直观分隔符处理不用手写代码量少。该用它们的时候千万别还抱着一堆append硬拼。5.2 String.format 和 MessageFormat它们不是用来“拼”的再看String.format。它本质是格式化不是拼接。典型的坑就是上面实测时展示的在循环里用String.format拼字符串耗时飙到直接“”的好几倍。原因是每次format都要解析格式字符串这块开销是固定的而且不小。那String.format适合哪里用适合报表文案、模板化提示语、异常信息这类低频但格式要求整齐的场景。比如String message String.format(订单%s已发货预计%s到达, orderId, date);这种用法比一堆append清晰得多而且低频调用下性能损耗根本不成问题。Java的MessageFormat是另一个层面的工具它支持更复杂的占位符和国际化格式比如日期、数字按locale输出。但注意它和String.format的占位符语法不一样混用容易翻车。MessageFormat还容易踩单引号的坑单引号会被当成转义字符导致占位符不生效。如果只是要简单占位直接用String.format更稳。老实说日常业务开发里项目应该尽量少把格式逻辑散落在业务代码里。真正需要考虑的往往是日志组件、消息推送、报表模块这些基础能力把模板和参数分开管理才是更长远的思路。5.3 要不要考虑 StringBuffer每次讨论StringBuilder总有人会问那StringBuffer呢StringBuffer是JDK 1.0就有的老牌类StringBuilder是JDK 5才加的。两者接口几乎一样区别在于StringBuffer的方法用synchronized修饰所以在多线程环境下是线程安全的。代价就是每次操作都要经过同步单线程下性能比StringBuilder差一截。至于要不要用StringBuffer我的态度很明确业务代码里通常根本不该出现让你需要并发拼接字符串的场景。如果一段拼接逻辑需要被多个线程共享同一块缓冲区首先应该怀疑自己的设计是不是有问题而不是把StringBuilder换成StringBuffer来掩盖问题。与其关心线程安全不如把StringBuilder的创建和使用范围控制在单线程方法内部这比任何同步都可靠。6. 我在实际项目里定下的拼接规矩聊了这么多最后说点我在团队里真正落地的规矩。第一代码评审时先看拼接出现在哪再看用什么拼接方式。如果是循环体里、热路径上必须用StringBuilder单条表达式则可以用“”不强制改。第二集合转字符串、日志list转打印串一律用String.join或者Collectors.joining。谁再手写循环判断逗号代码评审我给谁打回这纯粹是无意义的重复造轮子。第三日志、告警内容允许String.format和“”因为日志频率和格式灵活度都高但要禁止在循环里用String.format做批量格式化基本谁用谁踩坑性能一压就露馅。第四StringBuilder的初始容量在能估算出长度时一定要给。虽然很多人觉得这是微优化但在大对象频繁创建的场景里这个习惯能明显减少扩容拷贝。第五把“沿用现有风格”和“正确的风格”分开。老代码里如果大量使用“”且没有性能问题不要为了赶时髦去全局替换但如果那段代码马上要被改造、要进入高频路径就顺手优化成StringBuilder。说句真实的体会这个问题的答案从来不是“一定差StringBuilder一定好”而是“放在什么场景里讨论”。我见过一个下决心清理代码的项目把几十处简单的字符串拼接全部改成StringBuilder结果接口性能没有任何变化反而引入了几处因为Builder复用导致的并发脏数据。优化不是表演任何改动都要拿数据说话。最后再分享一个小技巧当你拿不准这段拼接是不是性能瓶颈时别猜直接用简单的压测或者线上耗时统计去验证。用数据驱动决策比背一百条“最佳实践”都有用。别人再跟你争论字符串拼接用“”还是StringBuilder你就可以问问他这段代码的循环次数是多少、字符串大概多长、它在请求路径上有多热。他要是答不上来那这个讨论本身就很能说明问题了。