我在循环里用加号一段段拼接字符串,数据少时飞快、数据一多就慢得令人发指、CPU 还飙满,排查半天才明白 Java 的字符串是不可变的、我每拼一次都在悄悄复制一遍之前拼好的全部内容的深度复盘
这是一次让我对"同一个操作,重复很多遍时,单次的代价会被放大成什么样"有了刻骨认知的事故。我有段 Java 代码,要把一大批数据拼成一个大字符串(比如把几万行记录拼成一个报表、一段 CSV、一个大 JSON)。我写得自然又朴素:搞个 String result = "",然后在循环里一段段 result += 每条数据。测试时数据量小,跑得飞快,我觉得这写法再直白不过了。
可一上真实数据量,灾难就来了:数据从几百条涨到几万条,这段拼接代码慢得令人发指——本该几十毫秒的事,跑了好几十秒,CPU 还一直飙满。我一开始以为是数据处理逻辑慢,各种排查,直到我把耗时打点打到那个循环里,才傻眼:慢的就是那一行毫不起眼的 result += ...;而且越往后越慢——拼前几百条时每次很快,拼到几万条时,单次拼接竟要花掉前面好多倍的时间。这"越拼越慢"的特征让我恍然大悟:Java 的 String 是不可变(immutable)的——一个字符串对象一旦创建就不能改。所以 result += x 根本不是"在原字符串后面追加",而是创建一个全新的字符串,把"原来 result 的全部内容"加上"x"整个复制一遍进去。于是循环到第 n 次时,这一次拼接要复制前面已经拼好的 n-1 段的全部字符;整个循环下来,复制的总字符数是 1+2+3+...+n,复杂度退化成了 O(n²)。数据量一大,这个平方级的复制就把性能彻底拖垮了。
故障现场:每次 += 都复制一遍已有的全部内容
我把这个"越拼越慢"的过程还原出来,问题一目了然:
// 我的写法: 循环里用 += 拼接, 数据一多就 O(n²)
String result = "";
for (Row row : rows) { // rows 有几万条
result += row.toString(); // ← 看着是"追加", 实则每次都新建+全量复制!
}
// 为什么是 O(n²): String 不可变, result += x 等价于:
// result = new String(result 的全部内容 + x);
// 即: 把"已经拼好的那一大坨" 完整复制一遍, 再加上 x
// 循环第 n 次时, result 已经很长了, 这次要复制它的全部 ≈ n 个单位
// 总复制量 = 1 + 2 + 3 + ... + n ≈ n²/2 → O(n²), 数据翻倍, 耗时翻4倍
// 现象: 拼前期快(result 还短, 复制量小), 越往后越慢(result 越来越长)
// 几百条: 几十毫秒; 几万条: 几十秒、CPU 飙满 ✗
// 正解: 用 StringBuilder, 它内部维护可变的字符缓冲, 追加是 O(1) 摊销
StringBuilder sb = new StringBuilder();
for (Row row : rows) {
sb.append(row.toString()); // ← 真正的"追加", 不每次全量复制
}
String result = sb.toString(); // 最后一次性生成字符串 → 整体 O(n) ✓
看着那条"越往后越慢"的耗时曲线,我才彻底明白:我以为 result += x 是一个"廉价的追加"——在已有字符串末尾添一段,代价只和 x 的长度有关。可因为 String 不可变,它根本做不到"原地追加",每次 += 都是"把旧的全部内容 + 新的一段,生成一个全新的字符串",单次代价和"已经拼了多长"成正比。单看一次拼接,这点复制不算什么;可在循环里重复 n 次,而每次的代价又随着已拼长度线性增长,这些代价累加起来,就成了压垮性能的 O(n²)。我以为我在做 n 次廉价的小操作,其实我在做一件代价随规模平方膨胀的事。
第一件事:搞懂 String 不可变 + 循环拼接 = O(n²) 的机理
冷静下来,我去把"Java String 的不可变性与字符串拼接"这一课认真补了,才明白这个"越拼越慢"的根源:
【为什么循环里 += 拼字符串是 O(n²)】
前提: Java 的 String 是【不可变的(immutable)】
- 字符串对象一旦创建, 内容永不改变
- 所以"修改"一个字符串(拼接、替换), 本质都是【创建一个新字符串】
result += x 实际发生的:
1. 算出 result + x 的总长度
2. 分配一块新内存
3. 把 result 的【全部字符】复制进去 ← 代价 ∝ result 当前长度
4. 再把 x 的字符复制进去
5. result 指向这个新字符串(旧的等着被 GC)
放进循环(n 次), 第 i 次时 result 长度 ≈ 前 i-1 段之和:
总复制量 = L1 + (L1+L2) + (L1+L2+L3) + ... ≈ O(n²)
→ 数据量翻倍, 耗时变 4 倍; 这就是"越拼越慢、数据一多就爆"
为什么 StringBuilder 是 O(n):
- 它内部是一个【可变的字符数组缓冲区】, append 直接往缓冲尾部写
- 缓冲满了才扩容(成倍扩容, 摊销下来每次 append 是 O(1))
- 不需要每次都把已有内容全量复制 → 整个循环 O(n)
- 最后 toString() 一次性生成最终字符串
要点:
- 循环/大量拼接 → 用 StringBuilder(单线程)或 StringBuffer(线程安全)
- 少量、固定次数的拼接(a + b + c)→ 直接用 + 没问题(编译器会优化)
- 关键是"在循环里"对一个不可变对象反复"重建", 才会平方爆炸
这一下点醒了我:我犯的错,是把"对一个不可变对象的反复'修改'"当成了"廉价的原地操作"。String 不可变,意味着每一次"改"都是"整个重建";单次重建的代价和当前规模成正比;而把这个"代价随规模增长的操作"放进循环重复 n 次,总代价就从 O(n) 恶化成了 O(n²)。StringBuilder 的本事,正是用一个可变的缓冲区,把"每次都全量重建"变成"真正的增量追加",从而把平方拉回线性。不是拼接本身慢,是"在循环里反复重建一个不可变的、还越来越大的东西"这件事,慢得平方级。
第二件事:正解——循环/大量拼接用 StringBuilder,把全量重建变增量追加
找到根因,正解就清晰了:循环里、或大量、不定次数的字符串拼接,用 StringBuilder(单线程)/StringBuffer(线程安全)——它内部是可变缓冲区,append 是摊销 O(1) 的真追加,整个循环 O(n);最后 toString() 一次性出结果。少量、固定次数的拼接直接用 + 即可。别在循环里对不可变的 String 反复重建。
// 错误: 循环里 += , O(n²), 数据一多就爆
String result = "";
for (Row row : rows) {
result += row.toString() + ","; // ✗ 每次全量复制
}
// 正解1: StringBuilder, O(n)
StringBuilder sb = new StringBuilder();
for (Row row : rows) {
sb.append(row.toString()).append(","); // ✓ 真追加, 不全量复制
}
String result = sb.toString();
// 预估容量可进一步减少扩容(锦上添花):
StringBuilder sb2 = new StringBuilder(rows.size() * 32); // 预留大致容量
// 正解2: 用现成的 join / 流, 内部已是高效拼接, 还更清晰
import java.util.stream.Collectors;
String csv = rows.stream()
.map(Row::toString)
.collect(Collectors.joining(",")); // ✓ 内部用 StringBuilder
String joined = String.join(",", listOfStrings); // 直接 join 字符串集合
// 注意区分: 少量/固定次数拼接, 直接 + 没问题(编译器会优化成 StringBuilder)
String msg = "name=" + name + ", age=" + age; // ✓ 不在循环里, 没问题
// 多线程共享拼接才用 StringBuffer(方法加锁); 单线程一律 StringBuilder(更快)
这套做法的精髓,是用一个可变的缓冲区,把"每拼一次就把已有内容全量复制重建"的昂贵操作,换成"直接往缓冲尾部写"的廉价增量操作:StringBuilder 平时只在缓冲满了才成倍扩容,摊销下来每次 append 都是 O(1),整个循环就是 O(n);Collectors.joining、String.join 这些现成工具内部也是这个原理,既高效又比手写循环清晰。不是不让拼字符串,而是别用"反复重建不可变对象"的方式拼——用专为增量构建而生的可变缓冲。
【Java 字符串拼接, 几条原则】
1. 循环里 / 大量 / 不定次数拼接 → 用 StringBuilder, 别用 +=(它 O(n²))
2. 少量 / 固定次数拼接(a + b + c, 非循环)→ 直接 + 即可, 编译器会优化
3. 拼集合 → String.join(分隔符, 集合) 或 stream().collect(joining(...))
4. 单线程用 StringBuilder(快); 多线程共享同一个才用 StringBuffer(加锁)
5. 已知大致总量, 可 new StringBuilder(预估容量) 减少扩容次数
6. 本质: 别在循环里对"不可变对象"反复"修改"(=反复全量重建)
第三件事:其他"单次便宜、放进循环就平方爆炸"的同类坑
顺着"单次随规模增长的操作放进循环会平方"这条线,我把同类的坑都梳理了一遍,它们都源于"在循环里反复做一件代价随规模增长的事":
第一个,循环里往 ArrayList 头部插入/删除。add(0, x) 要把后面所有元素后移,单次 O(n),循环里就 O(n²)。该用尾插,或用 LinkedList/Deque。
第二个,循环里反复拼接列表/数组(每次 new 一个更大的复制)。和字符串同理——每次都复制全部已有元素再加一个,O(n²)。该用可增长的容器一次性收集。
第三个,循环里用 list.contains 判重。每次 contains 都线性扫一遍列表,循环里就 O(n²)。判重该用 HashSet(O(1) 查找)。
第四个,循环里反复读整个文件/查全表。每轮都把全量数据读一遍/查一遍,单次随数据量增长,循环里就平方。该把数据读一次缓存起来复用。
第四件事:+= 拼接 vs StringBuilder,一张表对照
我把"循环里 += 拼接"和"StringBuilder 拼接"的关键差别整理成一张表,这是我现在决定怎么拼字符串的依据:
| 维度 | 循环里 result += x | StringBuilder.append |
|---|---|---|
| 底层 | String 不可变,每次新建 | 可变字符缓冲区 |
| 单次代价 | 复制已有全部内容(∝ 已拼长度) | 写到缓冲尾部(摊销 O(1)) |
| 循环 n 次总复杂度 | O(n²) | O(n) |
| 数据翻倍耗时 | 变 4 倍 | 变 2 倍 |
| 大数据量表现 | 慢得发指、CPU 飙满 | 稳定线性 |
| 适合 | 少量/固定次数(非循环) | 循环/大量拼接 |
这张表把真相摊开了:同样是"拼字符串",+= 在循环里是 O(n²)、数据翻倍耗时翻 4 倍;StringBuilder 是 O(n)、数据翻倍耗时只翻 2 倍。数据量小时两者都快、看不出差别(这正是测试时没暴露的原因);可一旦数据量上来,这个复杂度的差距就被放大成"几十毫秒"和"几十秒"的天壤之别。循环里拼字符串,几乎总该用 StringBuilder。
第五件事:我对"循环里 += 拼字符串"的几个想当然
这次事故,本质是我把"+= 是廉价的追加"当成了理所当然。把这些想当然列出来,每一条都值得警惕:
| 我曾经的想当然 | 事故教我的真相 |
|---|---|
| "result += x 就是在末尾追加一段" | String 不可变,它是新建+全量复制,不是追加 |
| "拼接很便宜,放循环里没事" | 循环里 += 是 O(n²),数据一多就爆 |
| "测试时拼接很快,上线也一样" | 小数据看不出 O(n²);大数据慢几百倍 |
| "StringBuilder 太麻烦,+ 更简洁" | 循环拼接性能差几个量级,该用 StringBuilder |
| "任何 + 拼接都该换 StringBuilder" | 非循环的少量拼接,直接 + 即可(编译器会优化) |
| "慢一定是数据处理逻辑的问题" | 也可能是循环里 O(n²) 的拼接/操作拖垮的 |
第六件事:在循环里做操作时,我现在的自检习惯
现在每当我在循环里做某个操作、或排查"数据一多就慢得离谱",我都会先按这张图问自己:
这张图的精髓,是"在循环里反复做事时,先看单次代价是固定的还是随规模增长——随规模增长的放进循环就是 O(n²)"。写时就循环拼字符串用 StringBuilder、循环判重用 HashSet、避免在循环里做随规模增长的操作、排查就看数据一多就慢是不是循环里藏了个 O(n²)。这套习惯,让我从"能拼出结果就行"变成了"循环里每个操作的单次代价都要心中有数"——核心始终是:Java 的 String 不可变,result += x 不是原地追加而是新建一个字符串并把已有内容全量复制一遍,单次代价正比于已拼长度;放进循环重复 n 次,总复制量是 1+2+...+n,复杂度退化成 O(n²),数据量一大就慢得发指、CPU 飙满;正解是循环/大量拼接用 StringBuilder(可变缓冲、append 摊销 O(1)、整体 O(n))或 String.join/Collectors.joining,少量固定次数拼接直接用 + 即可,本质是别在循环里对不可变对象反复全量重建。
我立下的几条规矩
这场"循环里 += 拼字符串导致 O(n²)"的事故,换来了我写 Java 时,刻进骨子里的几条铁律:
- Java 的 String 不可变;result += x 不是追加,而是新建一个字符串、把已有内容全量复制一遍。
- 循环里 += 拼字符串,单次代价随已拼长度增长,n 次累加是 O(n²),数据一多必爆。
- 循环/大量/不定次数拼接,用 StringBuilder(单线程)或 StringBuffer(线程安全),整体 O(n)。
- 拼集合用 String.join 或 stream().collect(Collectors.joining(...)),高效又清晰。
- 少量、固定次数的拼接(非循环的 a+b+c)直接用 + 即可,编译器会优化,不必上 StringBuilder。
- 已知大致总量可 new StringBuilder(预估容量) 减少扩容;单线程别用 StringBuffer(白白加锁)。
- 推而广之:循环里头插、contains 判重、全量复制/重读,凡单次随规模增长的操作都会平方爆炸。
附:一段把"+= vs StringBuilder"性能差距跑出来的小实验
这是我后来写的一段小基准实验,把循环里 += 拼接和 StringBuilder 拼接的耗时,随数据量增长并排打出来——它把那个抽象的 O(n²) vs O(n) 变成了眼见为实的数字,现在我也常拿它给同事演示这个坑:
public class ConcatBenchmark {
static long timePlus(int n) {
long t = System.nanoTime();
String s = "";
for (int i = 0; i < n; i++) {
s += "x"; // O(n²): 每次全量复制
}
return (System.nanoTime() - t) / 1_000_000; // 毫秒
}
static long timeBuilder(int n) {
long t = System.nanoTime();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < n; i++) {
sb.append("x"); // O(n): 摊销追加
}
sb.toString();
return (System.nanoTime() - t) / 1_000_000;
}
public static void main(String[] args) {
for (int n : new int[]{10_000, 20_000, 40_000, 80_000}) {
System.out.printf("n=%6d +=: %5dms StringBuilder: %3dms%n",
n, timePlus(n), timeBuilder(n));
}
}
}
// 典型输出(数字随机器而异, 关键看增长趋势):
// n= 10000 +=: 60ms StringBuilder: 1ms
// n= 20000 +=: 240ms StringBuilder: 1ms ← += 翻4倍! StringBuilder 几乎不变
// n= 40000 +=: 980ms StringBuilder: 2ms ← += 又翻4倍
// n= 80000 +=: 3900ms StringBuilder: 3ms ← += 再翻4倍 = O(n²)铁证
这段实验把这次的教训摆得明明白白:+= 那一列,数据量每翻一倍、耗时就翻大约 4 倍——这正是 O(n²) 的铁证(规模 ×2,代价 ×2²);而 StringBuilder 那一列,数据翻倍耗时也就翻倍、且绝对值小到几乎可以忽略,稳稳的 O(n)。跑完这段我才真正在脑子里刻下:同样是拼字符串,在小数据量(几千条)下两者都是几十毫秒、看不出差别,这正是它在测试里骗过我的原因;可数据量一上来,O(n²) 和 O(n) 的差距就被指数级地放大成"几秒"和"几毫秒"的鸿沟。亲手把这条增长曲线跑出来、看着 += 那一列翻着跟头往上窜,比记住任何一句"循环里要用 StringBuilder"的口诀,都更让我对"单次小代价在重复中如何平方放大"心怀敬畏。
这件事过后,我把项目里所有在循环里拼字符串的地方都搜了一遍,凡是 += 的统统换成了 StringBuilder 或 String.join。光是那个生成大报表的接口,改完之后从原来的几十秒直接降到了几百毫秒,用户再没抱怨过导出卡死。那种把一段平方级的代码拉回线性、眼看着耗时曲线从陡峭变平缓的踏实,是这次事故最实在的回报。
更让我警醒的,是这类性能坑的隐蔽方式:它从不在你测试的小数据上露馅,反而表现得人畜无害,专挑生产环境的真实规模发作。小数据是它最好的伪装。从此我对任何循环里的操作,哪怕看着再廉价,都会多问一句它在十倍百倍数据量下还撑得住吗——因为性能问题往往不是写错了,而是在小规模下被规模本身掩盖了。
说到底,这次的修改不过是把 += 换成 append,可它真正纠正的,是我看待代码代价时只盯单次、不看重复与规模的近视。把单次代价、重复次数、规模增长这三者一起放进视野,远比记住某个具体的优化技巧更重要。
写在最后
回头看,这场由"循环里 += 拼字符串"引发的"数据一多就慢成 O(n²)"事故,真正教给我的,远不止"改用 StringBuilder"这一个技巧。它让我对"一个'单看一次微不足道'的代价, 在'重复很多遍'的循环里, 会被放大成惊人的总量; 而如果这个单次代价本身还会'随着已经做过的次数而增长', 那么放大就不是线性的、而是平方甚至更可怕的——平时小打小闹时它温顺得让你毫无察觉, 规模一上来就凶相毕露",有了一次刻骨的体会。我栽跟头,是因为我只盯着'单次操作'看, 觉得它'不就拼一下嘛, 能费多大劲', 而忽略了'重复 × 单次代价'这个乘法, 更忽略了单次代价本身是'会随规模增长'的——我把"result += x"看成一个孤立的、廉价的小动作;我没意识到, 第一: 它会在循环里被重复成千上万次(重复); 第二: 因为 String 不可变, 它每一次的代价都不是固定的, 而是和"已经拼了多长"成正比、越往后越贵(单次代价随规模增长);这两件事一相乘, 一个我以为是 O(n) 的循环, 就成了 O(n²)——而在小数据下, 这个平方完全藏得住, 直到规模上来才轰然爆发。这让我领悟到一个关于"单次代价、重复与规模"的深刻认知:评估一个操作的真实开销, 不能只看"单独做一次有多贵", 而要看"它会被重复多少次" 以及 "它的单次代价会不会随着规模/已做次数而变化"——这三者相乘, 才是真实的总代价;尤其危险的, 是那种"单次便宜、但单次代价随规模增长"的操作: 它在小规模下伪装成廉价, 一旦被放进循环、规模上来, 就会以平方级别吞噬性能, 而你却因为"它单看那么便宜"而对它毫无戒备;所以凡是要"反复做某件事", 都要追问: 这件事的单次代价是恒定的, 还是会越做越贵?如果会, 那把它放进循环, 我是不是在亲手制造一个平方级的陷阱?。这给了我一种看待"一切'重复执行某操作'之事"时的清醒:每当我准备"循环/反复地做某个操作"时, 要追问"这个操作单次的代价, 是固定不变的, 还是会随着数据规模、随着我已经做过的次数而增长?如果它会增长, 那重复 n 次的总代价, 是不是已经从我以为的线性, 悄悄变成了平方?"——对那些"单次代价随规模增长"的操作, 在放进循环前就换一种"单次恒定(摊销 O(1))"的做法(可变缓冲、合适的数据结构、缓存复用), 别让"单次看着便宜"骗过自己;"看清单次代价是否随规模增长、警惕它在重复中放大成平方", 是写出能扛规模的代码、也是做对一切'重复性工作'的关键。认清 String 不可变让 += 每次全量复制、循环拼接退化成 O(n²)、要用可变缓冲把全量重建变增量追加——这,是我用一次拼字符串慢成几十秒的事故,换来的、关于 Java、也关于如何看待单次代价与重复放大的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次在循环里顺手写下 result += ... 时,先想想"它每次都在全量复制吗?数据一多会不会平方爆炸?",并果断换上 StringBuilder,那我对着那段"几万条数据拼了几十秒"的代码折腾的大半天,就值了。
—— 别看了 · 2026