我在 Java 循环里用加号拼字符串拼了几万次,本以为就是简单拼接,结果跑了几十秒、内存还飙升,我对着这段慢到离谱的拼接排查了大半天的复盘
这是一个让我对 Java 的 String 不可变性刻骨铭心的故事。我有个需求,要把几万条数据,拼接成一个大字符串(比如拼一个大的 CSV 或日志)。我顺手就在循环里用 += 拼:result += line;。逻辑再直白不过。可跑起来,我傻眼了:处理几百条时还行,可一到几万条,这段拼接代码就慢得离谱——跑了几十秒都没拼完;同时,我盯着监控,发现内存占用和 GC,在这段拼接期间疯狂飙升。一个看起来"不就是拼个字符串嘛"的操作,怎么会慢成这样、还这么吃内存?
我顺着这个现象深挖,才终于揭开真相,补上了我对 Java String 一个最基础、却影响巨大的认知漏洞:问题的核心,是 Java 的 String 是不可变(immutable)的,而我在循环里反复用 += 拼接它。我一直想当然地以为,result += line 就是"在 result 后面追加一段",代价很小;可真相是:因为 String 不可变,它根本无法"在原地追加"——每一次 result += line,Java 都会创建一个全新的 String 对象,把原来 result 的全部内容、加上新的 line,完整地拷贝一遍到这个新对象里,然后让 result 指向它(旧的那个 String 则变成垃圾,等着被 GC)。这就酿成了一个灾难性的复杂度:假设最终字符串长度是 N,循环拼接 N 次,那么第 1 次拷贝 1 个字符、第 2 次拷贝 2 个、第 3 次拷贝 3 个……第 N 次拷贝 N 个;总的拷贝量是 1+2+3+…+N ≈ N²/2,也就是 O(N²) 的时间复杂度!数据量小时,N² 还不明显;可一旦 N 到了几万,N² 就是几亿次字符拷贝,自然慢到几十秒;同时,每一次拼接,都产生一个被立刻丢弃的、巨大的临时 String 对象,这几万个临时大对象,把内存和 GC 压得喘不过气。我这才痛彻地明白:Java 的 String 不可变,意味着"对它的任何"修改",本质都是"创建一个新的";在循环里反复"修改"(拼接)一个 String,就等于在反复地全量拷贝、并制造海量垃圾,是一个 O(N²) 的性能陷阱。正确的做法,是用 StringBuilder——一个可变的字符缓冲区:它能真正地"在原地追加",把整个拼接过程的复杂度,从 O(N²) 降到 O(N)。理解 String 的"不可变",是写出高效 Java 字符串处理的必修前提。
故障现场:循环里 += 拼接,每次全量拷贝
我把这个"O(N²) 拼接"的现场,摊开给你看:
// ✗ 灾难: 循环里用 += 拼接字符串, O(N²) 复杂度 + 海量临时对象
String result = "";
for (int i = 0; i < 50000; i++) {
result += data.get(i); // ✗ 每次都新建 String, 全量拷贝旧内容!
}
// 5 万次 → 慢到几十秒, 内存/GC 飙升
// 为什么这么慢? String 不可变, += 不是"追加"而是"新建+拷贝":
// result += line 实际等价于:
// result = new StringBuilder(result).append(line).toString();
// 即: 每次都把"整个旧 result"拷进一个新对象, 再加上 line。
// 复杂度分析(致命的 O(N²)):
// 第1次拼: 拷贝 ~1 个字符
// 第2次拼: 拷贝 ~2 个字符
// ...
// 第N次拼: 拷贝 ~N 个字符
// 总拷贝量 = 1+2+...+N ≈ N²/2 → O(N²)!
// N=5万 → 约 12.5 亿次字符拷贝 → 几十秒。
// 内存/GC 灾难:
// - 每次 += 产生一个"立刻被丢弃"的临时 String(越往后越大)。
// - 5 万次 → 5 万个临时大对象 → 疯狂分配 + 疯狂 GC。
// ✗ 同样的坑也藏在:
// String log = "";
// for (Event e : events) log += e.format() + "\n"; // ✗ O(N²)
// String sql = "SELECT ...";
// for (Cond c : conds) sql += " AND " + c; // ✗ O(N²)
// 根因: String 不可变, 循环里 += 每次全量拷贝旧内容并产生临时对象,
// N 次拼接是 O(N²) 时间 + 海量垃圾, 数据一多就奇慢且吃内存。
看着这段"慢到几十秒"的拼接,我才算彻底想明白了根源。问题的核心,是 String 不可变,而我在循环里用 += 拼接。+= 为什么这么慢?因为 String 不可变,+= 不是"追加"而是"新建+拷贝":result += line 实际等价于 result = new StringBuilder(result).append(line).toString()——每次都把"整个旧 result"拷进一个新对象、再加上 line。复杂度因此变成了致命的 O(N²):第 1 次拷 ~1 个字符、第 2 次拷 ~2 个……第 N 次拷 ~N 个,总拷贝量 ≈ N²/2;N=5 万就是约 12.5 亿次字符拷贝,自然几十秒。内存/GC 也是灾难:每次 += 产生一个"立刻被丢弃"的临时 String(越往后越大),5 万次就是 5 万个临时大对象,疯狂分配 + 疯狂 GC。同样的坑还藏在循环拼日志、循环拼 SQL 等地方。归根结底:String 不可变,循环里 += 每次全量拷贝旧内容并产生临时对象,N 次拼接是 O(N²) 时间 + 海量垃圾,数据一多就奇慢且吃内存——这,就是根源。
第一件事:搞懂 String 不可变与拼接的代价
定位到根源,我必须把"String 为什么不可变、拼接为什么贵"从根上彻底搞清楚:
String 不可变: 任何"修改"都是"新建"; 循环拼接 = O(N²) + 海量垃圾
# String 不可变(immutable)是什么意思?
# - String 对象一旦创建, 它的内容就永远不能改。
# - 所有"看起来在改 String"的操作(+、substring、replace、toUpperCase...)
# 都不是改原来的, 而是"返回一个新的 String"。
# 那 s += x 在循环里发生了什么?
# - 不能在 s 原地追加(它不可变)。
# - 每次都: new 一个新 String, 把 s 的全部内容 + x 拷进去, s 指向新的。
# - 旧的 s 变成垃圾。
# → 循环 N 次 = N 次"全量拷贝" + N 个临时对象 = O(N²) + 海量垃圾。
# 为什么 String 要设计成不可变?(它有充分理由)
# - 线程安全: 不可变天然线程安全, 多线程共享无需加锁。
# - 可缓存 hashCode: 内容不变, hashCode 算一次就能缓存(做 Map key 高效)。
# - 字符串常量池: 相同字面量可安全共享同一对象, 省内存。
# - 安全: 作为参数(如文件路径、SQL)传递, 不怕被偷偷改掉。
# → 不可变是 String 的优点; "循环拼接慢"是误用它的代价, 不是它的错。
# 可变的字符串缓冲: StringBuilder
# - 内部是一个可扩容的 char[](类似 ArrayList), 能"真正地原地追加"。
# - append 平均 O(1)(偶尔扩容拷贝, 摊还后是 O(1))。
# - 循环拼接 N 次总共 O(N), 最后 toString() 一次性生成结果。
# ⚠ StringBuilder 非线程安全; 多线程用 StringBuffer(加了锁, 略慢)。
# 关键认知: 单次/少量拼接, 用 + 没问题(可读); 循环/大量拼接, 必须 StringBuilder。
# 核心: String 不可变, 任何"修改"都是新建; 循环里 += 是 O(N²)+海量垃圾;
# 大量拼接用 StringBuilder(可变缓冲, O(N)), 单次少量用 + 无妨。
原理终于清晰了。String 不可变是什么意思?——String 对象一旦创建,内容就永远不能改;所有"看起来在改 String"的操作(+、substring、replace、toUpperCase)都不是改原来的,而是返回一个新的 String。所以 s += x 在循环里:不能原地追加(它不可变),每次都 new 一个新 String、把 s 的全部内容+x 拷进去、s 指向新的,旧的变垃圾——循环 N 次 = N 次全量拷贝 + N 个临时对象 = O(N²) + 海量垃圾。那 String 为什么要设计成不可变(它有充分理由)?线程安全(不可变天然线程安全、共享无需加锁)、可缓存 hashCode(内容不变、算一次缓存、做 Map key 高效)、字符串常量池(相同字面量安全共享、省内存)、安全(作为路径/SQL 传递不怕被偷改)——不可变是 String 的优点,"循环拼接慢"是误用它的代价、不是它的错。而可变的字符串缓冲是 StringBuilder:它内部是可扩容的 char[](类似 ArrayList)、能真正原地追加,append 摊还后是 O(1)、循环拼接 N 次总共 O(N),最后 toString() 一次生成结果(注意 StringBuilder 非线程安全,多线程用 StringBuffer)。由此,我刻下一个关键认知:单次/少量拼接用 + 没问题(可读);循环/大量拼接,必须 StringBuilder。归根结底:String 不可变、任何"修改"都是新建;循环里 += 是 O(N²)+海量垃圾;大量拼接用 StringBuilder(可变缓冲、O(N)),单次少量用 + 无妨。
第二件事:正解——循环拼接用 StringBuilder
搞懂了原理,正解就清晰了:循环/大量拼接,用 StringBuilder(可变缓冲、O(N));单次少量拼接,继续用 + 即可。
// ✓ 正解一: 循环拼接用 StringBuilder, O(N), 不产生海量临时对象
StringBuilder sb = new StringBuilder();
for (int i = 0; i < 50000; i++) {
sb.append(data.get(i)); // ✓ 真正"原地追加", 平均 O(1)
}
String result = sb.toString(); // ✓ 最后一次性生成结果
// 5 万次拼接: 从几十秒 → 毫秒级! 内存/GC 也平稳。
// ✓ 预设容量, 进一步优化(避免中途扩容拷贝)
StringBuilder sb2 = new StringBuilder(50000 * 20); // 预估总长度
// → 一次分配够大的缓冲, 避免 append 过程中反复扩容。
// ✓ 正解二: 单次/少量拼接, 用 + 没问题(更可读)
String msg = "用户 " + name + " 在 " + time + " 登录"; // ✓ 就几段, + 清晰
// ✓ 正解三: 集合拼接, 用 String.join / Stream(更简洁)
List items = List.of("a", "b", "c");
String csv = String.join(",", items); // ✓ "a,b,c"
String s = items.stream().collect(Collectors.joining(", ", "[", "]")); // ✓ "[a, b, c]"
// ✓ 正解四: 多线程拼接用 StringBuffer(线程安全, 但单线程别用它, 有锁开销)
// StringBuffer sb = new StringBuffer(); // 加了 synchronized
// 编译器的"小聪明"和它的边界:
// - 单行的 a + b + c, 编译器会自动优化成 StringBuilder, 不用担心。
// - ✗ 但"循环里的 +="编译器优化不了! 它会在每次循环新建 StringBuilder
// (相当于 result = new StringBuilder(result).append(x).toString()),
// 循环 N 次就是 N 个 StringBuilder + N 次全量拷贝 → 还是 O(N²)!
// → 所以循环拼接必须"手动"把 StringBuilder 提到循环外。
// 核心: 循环/大量拼接用 StringBuilder(提到循环外, 可预设容量)O(N);
// 单次少量用 +; 集合拼接用 String.join/Stream; 多线程用 StringBuffer。
修复的方案,核心是"用对工具"。正解一,循环拼接用 StringBuilder:把 StringBuilder 提到循环外,循环里 append(真正原地追加、平均 O(1)),最后 toString() 一次生成——5 万次拼接,从几十秒降到毫秒级,内存/GC 也平稳。还能进一步预设容量(new StringBuilder(预估长度)),避免 append 过程中反复扩容拷贝。正解二,单次/少量拼接用 +(就几段时 + 更可读);正解三,集合拼接用 String.join / Stream 的 Collectors.joining(更简洁);正解四,多线程拼接用 StringBuffer(线程安全,但单线程别用、有锁开销)。这里有个关键的认知补丁:编译器有"小聪明"——单行的 a + b + c,编译器会自动优化成 StringBuilder,不用担心;但"循环里的 +="编译器优化不了!它会在每次循环都新建一个 StringBuilder、append、再 toString,循环 N 次还是 N 个 StringBuilder + N 次全量拷贝 = O(N²)——所以循环拼接,必须"手动"把 StringBuilder 提到循环外。归根结底:循环/大量拼接用 StringBuilder(提到循环外、可预设容量)O(N);单次少量用 +;集合拼接用 String.join/Stream;多线程用 StringBuffer。
第三件事:String 不可变带来的其他常见误区
这次踩坑后,我顺势把 String 不可变性带来的其他常见误区,也一并梳理清楚了:
// String 不可变引发的其他误区:
// 误区1: 以为方法能"改"传入的 String
void process(String s) {
s = s.trim(); // ✗ 只是让局部变量 s 指向新串, 不影响调用方的!
s += "x"; // ✗ 同样不影响外面
}
// → String 作参数, 方法内"改"它不会反映到外面(它不可变, 改=指向新的)。
// 想返回修改结果, 要 return; 想"原地改"得用 StringBuilder 传进去。
// 误区2: 以为 replace/substring 改了原串
String a = "hello";
a.toUpperCase(); // ✗ 返回值没接收! 原 a 还是 "hello"
a = a.toUpperCase(); // ✓ 必须接收返回值
// 误区3: 用 == 比较 String 内容(见 Python is 篇同理)
String x = new String("hi");
String y = "hi";
x == y; // ✗ false! (== 比对象引用, x 是 new 的新对象)
x.equals(y); // ✓ true (equals 比内容)
// → 比较字符串内容永远用 equals(), 不用 ==。
// 误区4: 大量字符串字面量重复 / 没利用常量池
// - 字面量 "abc" 会进常量池复用; new String("abc") 强制建新对象(浪费)。
// - 一般直接写字面量即可, 别没事 new String。
// 误区5: 敏感信息存 String
// - String 不可变 + 进常量池, 内容会在内存里留存较久、不可控清除。
// - 密码等敏感数据建议用 char[](用完可手动清零)。
// 核心: String 不可变 → 方法内改它不影响外部、replace/substring 要接收返回值、
// 比较内容用 equals 非 ==、别滥用 new String、敏感信息用 char[]。
原来 String 不可变,还藏着这么多误区。误区一,以为方法能"改"传入的 String:方法内 s = s.trim() 只是让局部变量指向新串、不影响调用方(想返回结果要 return)。误区二,以为 replace/substring 改了原串:a.toUpperCase() 返回值没接收就白做了,必须 a = a.toUpperCase()。误区三,用 == 比较 String 内容:== 比的是引用,比内容永远用 equals()(和 Python is 篇同理)。误区四,滥用 new String:字面量会进常量池复用,new String("abc") 强制建新对象、浪费;误区五,敏感信息存 String:String 不可变+进常量池、内容在内存留存久且不可控清除,密码等建议用 char[](用完可手动清零)。它们的共同根源,都是"String 不可变"这一特性的连锁影响。归根结底:String 不可变 → 方法内改它不影响外部、replace/substring 要接收返回值、比较内容用 equals 非 ==、别滥用 new String、敏感信息用 char[]。
下面这张图,是这次"O(N²) 拼接"的成因与解法:
第四件事:几种字符串拼接方式的对比
这次踩坑后,我把 Java 里几种字符串拼接方式,按场景横向比了一遍,以后拼接时一看就知道该用哪个。
| 方式 | 复杂度 | 适用 | 注意 |
|---|---|---|---|
| 循环里 += | O(N²) | ✗ 永远别在循环里用 | 每次全量拷贝+临时对象 |
| 单行 a + b + c | O(N) | 少量、固定段数 | 编译器自动优化成 StringBuilder |
| StringBuilder | O(N) | 循环/大量拼接(首选) | 提到循环外, 可预设容量 |
| StringBuffer | O(N) | 多线程拼接 | 有锁开销, 单线程别用 |
| String.join | O(N) | 用分隔符拼集合/数组 | 简洁, 自动处理分隔符 |
| Collectors.joining | O(N) | Stream 流式拼接 | 可加前后缀和分隔符 |
把它们排在一起,选择就一目了然了。循环/大量拼接,首选 StringBuilder(O(N),记得提到循环外、可预设容量);少量、固定段数的拼接,直接 +(可读,且编译器会自动优化成 StringBuilder);用分隔符拼集合/数组,用 String.join(最简洁,自动处理分隔符,不用手动判断"最后一个要不要加逗号");流式处理,用 Collectors.joining(可加前后缀);多线程,用 StringBuffer(但单线程别用、有锁开销)。而那个 循环里 +=,是O(N²) 的、永远要避开的写法。它给我的启发是:Java 为"字符串拼接"这一个需求,提供了好几种工具,每一种都对应着一个最佳场景;用错了(比如循环里用 +=),性能可能差几个数量级;而记住"循环用 StringBuilder、集合用 join、少量用 +"这几条简单规则,就能让你在每种场景下,都用对那个最快、最清晰的工具。
第五件事:Java 里其他"不可变 vs 可变"该选对的地方
String 的"不可变 vs 可变(StringBuilder)"之选,在 Java 里并非孤例。我把类似的"该选对可变性"的场景,梳理了一遍。
| 场景 | 不可变(只读/少改) | 可变(频繁改) |
|---|---|---|
| 字符串 | String(单次/少量拼) | StringBuilder(循环/大量拼) |
| 列表(只读 vs 频繁增删) | List.of() 不可变 | ArrayList 可变 |
| Map | Map.of() 不可变 | HashMap 可变 |
| 数值累加 | Integer(每次装箱新建) | 用 int / long 基本类型 |
| 大数运算累积 | BigDecimal(不可变, 每步新建) | 循环累积要注意复用/减少中间对象 |
| 配置/值对象 | 不可变(线程安全, 推荐) | 有状态才用可变 |
这张表,让我看到了一个贯穿 Java 的设计权衡——不可变 vs 可变。它们各有所长:不可变(String、List.of、Map.of、不可变值对象)线程安全、可安全共享、做 key 安全、不怕被偷改,是"只读或极少修改"场景的优选;可变(StringBuilder、ArrayList、HashMap、基本类型)能高效地原地修改,是"频繁修改"场景的必需。选错的代价是真实的:在频繁修改的场景用了不可变(如循环里拼 String、循环里累加 Integer 反复装箱),就会因为"每次修改都新建对象"而性能暴跌、垃圾暴增;反之,在只读场景用了可变的,则白白失去了线程安全等好处、还可能被意外修改。它给我的最大启发是:"可变性",是一个需要主动、按场景去选择的重要属性,而不是"随便用一个就行"。核心判断很简单:这个东西,会被频繁修改吗?——会,就用可变的(为效率);不会(只读或极少改),就用不可变的(为安全)。把"可变还是不可变"这个选择,纳入你设计每一个数据结构时的例行考量,就能既避开 String 拼接这类性能陷阱,又享受到不可变带来的安全红利。
第六件事:要拼接字符串时,我现在会怎么决策
现在,每当我要拼接字符串,脑子里都会过一遍这张(很简单的)决策图——核心就一问:是在循环里 / 大量拼吗?
这张图虽简单,却能挡住一个常见的性能陷阱。核心就一个判断:是在循环里 / 大量拼接吗?——是,就用 StringBuilder(提到循环外、能预估长度就预设容量、循环 append、最后 toString 一次);不是、只是少量固定段数,再看是不是拼集合/数组:是就用 String.join/Collectors.joining(简洁)、否就直接 +(可读)。最后补一个维度:多线程共享拼接吗?——是就换 StringBuffer,否则 StringBuilder/+/join 都行。这套判断,简单到几乎是条件反射,但能让我永远不再在循环里写出 O(N²) 的 +=——核心始终是那句:循环拼接用 StringBuilder,少量拼接用 +,集合拼接用 join。
我立下的几条规矩
这场"O(N²) 拼接"的事故,换来了我写 Java 时,刻进骨子里的几条铁律:
- 循环里绝不用 += 拼字符串。String 不可变,循环 += 是 O(N²)+海量临时对象,数据一多就奇慢吃内存。
- 循环/大量拼接,一律用 StringBuilder。提到循环外、循环 append、最后 toString 一次,复杂度降到 O(N)。
- 能预估长度就预设容量。new StringBuilder(预估长度),避免 append 过程中反复扩容拷贝。
- 少量固定段数用 +,集合拼接用 String.join。+ 可读且编译器会优化;join 自动处理分隔符。
- 多线程拼接用 StringBuffer,单线程别用。StringBuffer 有锁开销,单线程用 StringBuilder。
- 记住 String 不可变的连锁影响。方法内改它不影响外部、replace 要接收返回值、比较内容用 equals。
- 按"会不会频繁改"选可变性。频繁改用可变(StringBuilder/ArrayList),只读用不可变(String/List.of)。
附:亲手测一测 += 和 StringBuilder 的天壤之别
口说无凭。下面这段基准测试,能让你亲眼看到 += 和 StringBuilder 拼接 N 次的耗时差距,跑一遍胜过千言:
public class StringConcatBenchmark {
public static void main(String[] args) {
int n = 50000;
// ✗ 方式1: 循环 += (O(N²))
long t1 = System.currentTimeMillis();
String s = "";
for (int i = 0; i < n; i++) {
s += i; // ✗ 每次全量拷贝
}
long cost1 = System.currentTimeMillis() - t1;
System.out.println("+= 耗时: " + cost1 + " ms, 长度=" + s.length());
// ✓ 方式2: StringBuilder (O(N))
long t2 = System.currentTimeMillis();
StringBuilder sb = new StringBuilder();
for (int i = 0; i < n; i++) {
sb.append(i); // ✓ 原地追加
}
String s2 = sb.toString();
long cost2 = System.currentTimeMillis() - t2;
System.out.println("StringBuilder 耗时: " + cost2 + " ms, 长度=" + s2.length());
// ✓ 方式3: StringBuilder 预设容量(再快一点, 免扩容)
long t3 = System.currentTimeMillis();
StringBuilder sb2 = new StringBuilder(n * 5); // 预估容量
for (int i = 0; i < n; i++) sb2.append(i);
System.out.println("预设容量耗时: " + (System.currentTimeMillis() - t3) + " ms");
System.out.println("差距: " + (cost1 / Math.max(cost2, 1)) + " 倍");
}
}
// 典型输出(机器不同数值不同, 但量级差距是确定的):
// += 耗时: 1800 ms
// StringBuilder 耗时: 3 ms
// 预设容量耗时: 2 ms
// 差距: 600 倍
// → N 越大, 差距越夸张(因为 += 是 O(N²), StringBuilder 是 O(N))。
// 核心: 同样拼 5 万次, += 比 StringBuilder 慢几百倍, 且 N 越大差距越大;
// 一跑便知 O(N²) 和 O(N) 在真实世界里是天壤之别。
这段基准测试,把"O(N²) vs O(N)"从抽象的复杂度符号,变成了肉眼可见的、刺目的耗时数字。同样是拼接 5 万次:+= 耗时近 1800 毫秒,而 StringBuilder 只要 3 毫秒,预设容量更是 2 毫秒——差距高达几百倍!而更要命的是:这个差距,会随着 N 的增大而急剧拉大——因为 += 是 O(N²)、StringBuilder 是 O(N),N 翻倍,+= 的耗时翻 4 倍、StringBuilder 只翻 2 倍;数据量越大,+= 就越发地、灾难性地慢下去。这,正是我想用这段测试,留给每一个写 Java 的人的最后一课:当你对两种写法的性能差异"没概念、拿不准"时,最好的办法,就是写一段最简单的基准测试,把它们真刀真枪地跑一遍、用数字说话。"O(N²) 比 O(N) 慢"是一句抽象的理论,而"慢了 600 倍"是一个能让你永远记住、再也不敢乱用 += 的具体的、有冲击力的事实。用基准测试,把抽象的复杂度,变成你能感同身受的真实代价——这,是培养"性能直觉"最快、也最扎实的途径。
写在最后
回头看,这场由"循环里 += 拼字符串"引发的、慢到几十秒的事故,真正教给我的,是一个比"用 StringBuilder"本身更深的道理:很多看起来"理所当然、代价很小"的简单操作,在循环、在规模的放大下,其真实代价,可能远超你的直觉;而理解一个操作"底层到底做了什么、它的复杂度是多少",正是预判这种"规模放大效应"的唯一办法。我之前犯的错,本质是只看到了 result += line 这行代码"表面上的简单",却完全没意识到它"底层是一次全量拷贝",更没去想"这个拷贝,在循环 N 次后,会累积成 O(N²) 的庞然大物"。这让我深刻地领悟到:写代码,尤其是写循环和处理数据的代码,心里要时刻有一杆"复杂度"的秤:不仅要问"这行代码对不对",更要问"当它在循环里、在大数据量下被放大时,它的代价会变成多少"。一个 O(1) 的操作放进循环可能是 O(N) 还好,但一个看似不起眼、实则 O(N) 的操作(如全量拷贝)放进循环,就成了致命的 O(N²)。所以,对"循环里的每一个操作",都多一份"复杂度敏感":看穿它表面的简单,算清它在规模下的真实代价。培养"复杂度的直觉"、警惕"规模的放大"——这,是我用一次"O(N²) 拼接"的事故,换来的、关于 Java、也关于一切性能问题的、最朴素也最深刻的领悟。如果这篇复盘,能让你在下一次于循环里拼字符串时,条件反射地掏出 StringBuilder,那我对着那段慢到几十秒的拼接熬的这大半天,就值了。
—— 别看了 · 2026