先问一个问题:你上一次在代码评审里被人问“为什么不让在 for 循环里用 + 拼接字符串”是什么时候?我入职第三个月就碰到过。当时我提交了一段从订单列表拼接导出内容的代码,自认为逻辑清晰、注释到位,结果 review 的老哥只看了一眼,就撂下一句话:“这个循环里别用加号拼字符串,改 StringBuilder。”我嘴上答应,心里其实犯嘀咕:都是拼字符串,能差多少?
后来我才明白,这个问题的答案藏着字符串类型最本质的设计——不可变。搞懂这一条,你不仅能说服自己,还能在下次评审时把别人说服。这篇文章不绕弯子,直接把这个规则背后的原理、不同语言的处理方式、排查手段和适用边界一次讲清楚,适合刚写代码两三年的同学,也适合想给团队定一条代码规范的技术负责人。
1. 从一次代码评审说起:String “+=” 的隐藏开销
1.1 字符串不可变:每一次赋值都是一次“誊写”
先看 Java。String 是 final 的,内部保存字符的数组也被 final 修饰,一旦创建就不可修改。这意味着你没有办法在原对象上“追加”内容,任何修改操作都只能创建一个全新的对象。
拿这段最常见的低效代码举例:
java复制String result = "";
for (int i = 0; i < 10000; i++) {
result += i + ",";
}
循环里的 result += i + ",",实际发生的事有三步:先算 i + "," 得到临时字符串,再把当前 result 的字符复制进新的字符数组,最后把临时字符串的内容也复制进去。也就是说,每轮循环你都在把已经拼好的所有字符重新“誊写”一遍。
打个比方:你要在一张纸上写一篇一万字的文章,每次写一个字都觉得不满意,于是换一张新纸把之前所有内容重新抄一遍,再写一个新字。循环到第 1 万次时,你为这一次赋值誊写了 1 万个字符,而这一万轮加起来,誊写总量是五千万字符。这不是修辞,是字面意义上的字符复制次数。
1.2 O(n²) 是怎么算出来的:1+2+...+n 的代价
把上面的过程数学化。假设循环 n 次,第 i 次迭代需要复制前 i 个字符(近似当成 i 次字符拷贝),总复制次数就是:
1 + 2 + 3 + ... + n = n(n+1)/2
这是标准的 O(n²)。n 等于 1 万时,约 5000 万次字符复制;n 等于 10 万时,约 50 亿次。这就是为什么循环次数一上去,接口响应时间会肉眼可见地变慢。
更隐蔽的问题是内存和 GC。每次拼接产生的旧字符串都变成垃圾对象,循环 1 万次就产生 2 万个左右无用对象。如果这些字符串还挺长,Young GC 会变得频繁,甚至把对象顶进老年代,最终触发 Full GC。线上很多“接口突然卡顿”的问题,往上追根因,内存里全是这种循环拼接留下的临时对象。
对比一下 StringBuilder:它内部像一块可以不断写字的草稿纸,append 只是在末尾继续写,只有空间不够时才扩容一次。扩容需要复制旧数组,但容量是按倍数增长的,整体复制次数是 O(n) 而不是 O(n²)。两笔账一算,差距就出来了。
1.3 这不是 Java 的锅:Python、C#、JavaScript 都一样
很多同学以为这是 Java 特有的毛病,其实只要是“不可变字符串”的语言,循环内反复拼接都会有类似问题。Python 的 str、C# 的 string、JavaScript 的 string、Go 的 string,设计上都是不可变的。
换句话说,这个规则具有跨语言的普适性。你在 Python 里写 result += str(i),在 C# 里写 result += i.ToString(),在 JavaScript 里写 result += i,本质上都在重复创建对象。JavaScript 的 V8 引擎确实对字符串做了一些优化,比如延迟拼接的 cons string(rope)结构,但引擎是否触发优化、触发时机如何,都不由你控制,依赖它不靠谱。
这也是为什么几乎所有语言的性能规范里,都有“循环内禁止字符串重赋值”这一条。这不是某个语言的偏见,而是字符串不可变设计下的数学必然。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 各语言的正规军打法:StringBuilder、join 和它们的兄弟
2.1 Java/C#:StringBuilder 的正确打开方式
Java 的正确写法是:
java复制StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
sb.append(i).append(',');
}
String result = sb.toString();
注意两个细节。
第一,StringBuilder 默认容量只有 16,append 超过容量会触发扩容,扩容逻辑是旧容量乘以 2 再加 2,扩容时要复制旧数组。如果循环次数大,扩容会反复发生。所以能预估长度时,最好直接指定初始容量:
java复制StringBuilder sb = new StringBuilder(10000 * 5);
这里的 5 是我对“单条记录拼接后的平均字符数”的估算,实际按你的数据来。容量太大浪费内存,太小又频繁扩容,拍脑袋不如量一量。
第二,不要在循环里调用 sb.toString()。toString() 会复制当前字符数组生成新的 String,一旦放进循环,每轮都白白复制一遍,前面省下来的性能又还回去了。正确做法是循环结束后再统一 toString()。
C# 的逻辑完全一致:
csharp复制var sb = new StringBuilder(50000);
for (int i = 0; i < 10000; i++) {
sb.Append(i).Append(',');
}
C# 里还有个更省事的方案:string.Join 和 string.Concat,这两个方法内部会先计算总长度,再分配一次缓冲区,适合把集合拼成字符串的场景。如果循环里需要的是复杂的逐行拼接,StringBuilder 仍然是正解。
Java 里 StringBuffer 是线程安全版,方法加了 synchronized,单线程场景用它反而有锁开销,所以默认选 StringBuilder 就好。
2.2 Python:join 才是亲儿子
Python 初学者最常见的写法是:
python复制result = ""
for i in range(10000):
result += str(i) + ","
这行代码在 Python 里慢得很直接,因为字符串不可变,每轮 += 都要创建新对象。
正确姿势是 join:
python复制result = ",".join(str(i) for i in range(10000))
join 的原理是:先遍历一遍可迭代对象,统计所有元素的总长度,一次性分配足够大的缓冲区,再把每个元素拷贝进去。整个拼接过程只分配一次内存,不像循环 += 那样反复分配。
注意 join 的第二个参数传的是生成器表达式而不是列表,这样不会额外创建一个装 1 万个字符串的列表,省内存。如果你需要处理复杂的逻辑,比如每个元素还要经过函数处理,可以先写到列表里再 join:
python复制parts = []
for item in items:
parts.append(transform(item))
result = ",".join(parts)
这里列表的 append 是 O(1) 操作,列表本身可扩容,整体成本远低于字符串拼接。Python 的 f-string 虽然写起来方便,但它在循环内同样每次生成新字符串,只是语法糖,不解决性能问题。
2.3 JavaScript/Go/MySQL:从数组 join 到递归 CTE
JavaScript 的推荐写法是用数组:
js复制const parts = [];
for (let i = 0; i < 10000; i++) {
parts.push(i);
}
const result = parts.join(",");
push 到数组只是追加元素,数组扩容成本可控,最后 join 一次性拼接。比起每轮 result += i,这个方案在语义上更清楚,性能也更稳定。
Go 语言的 string 同样不可变,推荐用 strings.Builder:
go复制var b strings.Builder
b.Grow(10000 * 4)
for i := 0; i < 10000; i++ {
b.WriteString(strconv.Itoa(i))
b.WriteString(",")
}
result := b.String()
strings.Builder 内部维护一个 []byte,WriteString 只是追加字节,避免了频繁复制。Grow 方法可以预分配容量,减少扩容次数。
顺手提一句 C 语言。C 没有现成的字符串类型,常用 char[],性能问题的解法也不同:如果目标字符串长度可预估,先 malloc 足够空间再 strcpy/strcat 一次到位;如果长度不可预估,用 realloc 按倍数扩容。反正核心思路和 StringBuilder 一样:预先分配、批量写入。
还有一个很容易被忽略的场景:MySQL 里“循环查树下所有数据”然后拼接。很多人写存储过程循环查子节点,再逐层拼字符串,这个方案又慢又难维护。正确做法是用递归 CTE 一次性查出全树:
sql复制WITH RECURSIVE cte AS (
SELECT id, name, parent_id FROM category WHERE parent_id IS NULL
UNION ALL
SELECT c.id, c.name, c.parent_id FROM category c JOIN cte ON c.parent_id = cte.id
)
SELECT GROUP_CONCAT(name ORDER BY id SEPARATOR '>') FROM cte;
数据库层的循环拼接,往往比应用层字符串拼接更致命,因为每次循环都是一次网络往返。能用一条 SQL 解决的事,别写成一百条。
3. 别靠猜:三种手段定位循环拼接的性能问题
3.1 静态检查:把规则挡在代码评审之前
与其等到代码评审时靠人眼盯,不如让工具自动查。IntelliJ IDEA 对 Java 循环内字符串拼接会直接给出 “String concatenation in loop” 警告,VS Code 的 Python 插件也有类似提示。看到黄色波浪线别无视,这就是编译器在提醒你。
团队层面可以上静态检查工具。Java 这边 SpotBugs 有一条规则叫 SBSC_USE_STRINGBUFFER_CONCATENATION,专门检测循环内的字符串拼接;PMD 也有对应的规则。可以挂到 CI 流程里,发现问题直接让构建失败,从源头卡住。
Python 可以用 pylint 的 consider-using-join 等规则,同样能接进 CI。这些工具不需要团队成员记住所有规范,规则由平台统一执行,省心。
3.2 基准测试:用 JMH 把差距砸到桌面上
如果你在代码评审里说“用 StringBuilder 性能更好”,总有人会问“好多少”。光靠嘴说没有说服力,跑个基准测试。
Java 里推荐用 JMH(Java Microbenchmark Harness),这是官方认可的微基准测试框架。简单写一下:
java复制@Benchmark
public String plus() {
String result = "";
for (int i = 0; i < 10000; i++) {
result += i;
}
return result;
}
@Benchmark
public String builder() {
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 10000; i++) {
sb.append(i);
}
return sb.toString();
}
跑出来的数据在不同机器、不同 JDK 版本上会不一样,但数量级差异是稳定的:循环次数越大,+ 拼接的耗时呈平方级增长,而 StringBuilder 基本是线性增长。1 万次循环可能差几十倍,10 万次可能差几百倍。
用 JMH 有个坑:方法要有返回值或用 Blackhole 消费结果,否则 JIT 可能把整个循环优化掉,测出来全是 0。我在刚接触 JMH 时犯过这个错,报出来的数据比真实情况好看得多,后来才发现是结果被优化没了。
3.3 真实排障链路:从慢接口到 GC 异常
有一回线上接口响应变慢,我第一反应是数据库查询慢,结果一查 SQL 执行计划,索引正常,数据库耗时也就几十毫秒。真正的问题藏在应用层。
整个排查思路是这样的:
- 先看耗时分布。用 Arthas 的
trace命令定位方法耗时,发现一个拼接方法占了接口总耗时的 65%。 - 再看 GC 日志。Young GC 频率明显偏高,老年代占用也在缓慢上涨,说明有大量短生命周期的大对象。
- 最后看代码。问题方法里有一个循环,循环里用
+拼接 CSV 行,而且这个循环还嵌套在另一个外层循环里。
定位到根因后,把内层拼接改成 StringBuilder,并预分配容量,接口耗时直接从 8 秒降到 1.2 秒,GC 频率也肉眼可见地降了下来。
这条链路提醒我:不要上来就猜,先用工具定位,再动手改。很多时候你以为的性能瓶颈,实际根本不是。
4. 别教条:什么时候可以放心用 “+” 拼接
4.1 编译器帮你干的活:常量折叠和 invokedynamic
不是所有字符串拼接都该被禁止。Java 编译器对编译期常量的拼接做了优化:
java复制String s = "a" + "b" + "c";
这行代码在编译后直接变成 String s = "abc",没有任何运行时拼接开销。因为 "a"、"b"、"c" 都是常量,编译器在编译期就能算出结果。
JDK 9 之后,Java 对字符串拼接引入了 invokedynamic 机制,通过 StringConcatFactory 把多个参数一次性拼成目标字符串,单条拼接语句的效率比之前高不少。但注意,这个优化对循环内反复重赋值的场景不起作用——每次迭代仍然是新的对象、新的复制,循环结构本身没有变。所以别拿“JDK 9 优化了拼接”当借口,在循环里继续写 +=。
4.2 小循环、日志与可读性的取舍
如果循环次数很少,比如少于几十次,用 + 和用 StringBuilder 的差距在纳秒级别,对整体接口耗时几乎没有影响。这时候我倾向于保留 +,因为可读性更好:
java复制String label = "订单" + orderId + "状态" + status;
这种一眼能看懂的拼接,没必要为了规则写成 new StringBuilder() 的样式。
我自己的判断标准是三条:循环次数是否确定且很小、这段代码是否在高频热路径上、每次拼接的内容是否很长。三条都满足“否”,才需要用 StringBuilder。只满足一条“小”,直接 + 也问题不大。
日志场景是个例外。即使单次拼接开销很小,如果用 SLF4J 这类日志框架,也要用 {} 占位符而不是字符串拼接:
java复制log.debug("order id: {}, status: {}", orderId, status);
占位符的意义在于延迟拼接:当日志级别为 INFO 时,DEBUG 日志根本不会输出,占位符写法不会执行拼接;而 "order id: " + orderId 这种写法,在调用 debug 之前就已经完成了拼接,白白创建了无用对象。这在循环打日志时会放大成性能问题。
4.3 动态SQL例外:用框架而不是手工拼接
有人会说:“那我在 Java 里拼 SQL 是不是也得用 StringBuilder?”对,拼 SQL 确实经常用到 StringBuilder,但它同样有维护性和安全性问题。
当 SQL 条件多、动态组合复杂时,手工拼接非常容易出错:少一个空格、多一个逗号、引号没转义,都会引发线上故障。更危险的是 SQL 注入——如果拼接内容里混入了用户输入,等于把数据库暴露给攻击者。
更稳的做法是交给框架处理。Java 里用 MyBatis 的动态 SQL,通过 <where>、<if> 标签组合条件;或者用 Query DSL、JPA Criteria。这些方案帮你处理空格、括号和参数绑定,既安全又易维护。规则的核心不是“禁止字符串拼接”,而是“别用笨办法重复造轮子”。
5. 实战复盘:一次导出接口从 8 秒到 1.2 秒的优化过程
5.1 现象与第一轮排查
背景是一个报表导出接口:用户选择一批订单,系统生成 CSV 文件下载。单次导出的数据量不算极端,1 万行左右,每行大约 20 个字段,但接口耗时很不稳定,高峰期能到 8 秒,偶尔还伴随内存告警。
第一轮排查把目光集中在数据库上。检查了查询 SQL、索引、连接池配置,数据库端耗时很少,完全解释不了 8 秒的延迟。于是用 Arthas trace 看方法级耗时,结果让人意外:最耗时的不是一个查询方法,而是一个看起来平平无奇的字符串拼接方法,占了超过 60% 的时间。
5.2 两层循环叠加:N+1 查询遇上 O(n²) 拼接
翻开代码,问题比想象的更复杂。外层循环遍历 1 万行订单,内层循环根据订单 ID 逐条查询子表数据,这是典型的 N+1 查询;更糟的是,内层拼接 CSV 行时用的还是 +。两层叠加,性能雪崩:数据库要扛 1 万次查询,内存要反复复制逐行拼接的字符串,GC 被拖垮也就不奇怪了。
优化分两步。第一步,把内层查询改成一次 IN 查询,拿到全部子表数据后再在内存里做关联,彻底消灭 N+1;第二步,CSV 行拼接改用 StringBuilder,并按单行预估长度设置了初始容量,避免扩容复制。
这里有个操作细节:预估初始容量时,我先统计了单行 CSV 的实际字符数分布,取了一个超过 90% 场景的值,而不是随便填个大数。容量开太大,StringBuilder 内部数组会白白占着内存;开太小,扩容复制又会浪费性能。量一量再拍板,比拍脑袋靠谱。
5.3 优化结果与这套方法论的复用
优化后接口耗时从 8 秒降到 1.2 秒,GC 频率明显下降,内存告警消失。后来我把这套排查思路沉淀成团队 check list:循环内禁止字符串重赋值、循环内禁止数据库访问、集合类初始化尽量预分配容量。这三条看着朴素,但真能挡住大多数性能隐患。
回头看这次优化,最值钱的不是那几行代码改动,而是“先定位、再优化、后验证”的流程。很多人一听到性能问题就凭感觉改代码,改完也不测,结果改动越多问题越多。静下心用工具定位根因,才是效率最高的路。
最后说点我在实际项目里的体会。这条规则的真正价值不在于“禁止用 +”,而在于让你在下笔写循环之前,先想清楚每一次迭代到底创建了多少个临时对象。我后来给自己定了一条自查标准:只要循环体内出现了字符串类型的变量重新赋值,我就会停下来问一句——这段代码会不会被别人在一个更大的循环里调用?如果会,那就老老实实改 StringBuilder 或者 join。规则是死的,但判断力是练出来的,多跑几次基准测试、多看几次 GC 日志,你的直觉自然会越来越准。
