一个"导出 CSV"的方法用 s += row 拼 20 万行,被评审指出"要用 StringBuilder"。改完之后"快了一点但不多"——因为真正的开销不在"字符串不可变",而在"每次 += 都可能是一次数组拷贝 + 一次编码转换"。
Java 的 + 在单条表达式里已经被编译成 invokedynamic(StringConcatFactory),性能与手写 StringBuilder 接近。但它在"循环里"退化:因为循环的每一轮都是一次独立拼接,这个"退化"的位置很明确:invokedynamic 的优化是"针对一次表达式"的,它不知道"下一次循环还会再拼"——于是每轮都从"零"开始算长度、分配、复制。
于是"复制整个前缀"的行为又回来了。
1. 三种拼接的编译结果
| 写法 | 编译结果 |
|---|---|
a + b + c(单表达式) |
invokedynamic(一次分配) |
循环里 s += x |
每轮一次"复制 + 追加" |
手写 StringBuilder |
一次扩容 + 一次 toString |
第一项是"JDK 9+ 的优化":它在编译期算出"最终长度",一次分配第一项的"实现"是 StringConcatFactory:它把"若干段"编成一个 MethodHandle,按"各段长度之和"一次分配——于是"段数固定"时开销接近"一次拷贝"。
——所以"短拼接"用 + 完全没问题。
第二项的"量化"很容易记住:第 k 轮要复制"前 k-1 轮的结果",于是总复制量 ≈ n²/2 × 行长——行数翻倍,耗时变四倍。
第二项是"真正的坑":每轮 s += x 都会生成新字符串,于是"总复制量"是"前缀长度之和"(O(n²))。
2. "O(n²)"的账怎么算
| 行数 | 每行长度 | 总复制量(近似) |
|---|---|---|
| 1 万 | 100B | 0.5 GB |
| 10 万 | 100B | 50 GB |
| 20 万 | 100B | 200 GB |
这个表解释了"为什么 20 万行的导出会卡 10 秒":它不是"GC 慢",而是"复制了 200GB 的数据"。这个"200GB"还解释了"为什么 GC 会变频繁":每次复制都产生一个新字符串,旧的那个立刻变成垃圾——于是"复制"与"GC"同时发生,互相加剧。
3. "该用什么"的三条判据
| 情形 | 选择 |
|---|---|
| 单条表达式(几段拼接) | +(已优化) |
| 循环内累积 | StringBuilder(显式容量) |
| 多线程累积 | 各自 StringBuilder,最后合并 |
第二项的"默认值"值得知道:StringBuilder 默认 16 字符,扩容是"旧容量 × 2 + 2"——于是"拼 20 万行"要扩容十几次,每次都是一次数组复制。
第二项的"显式容量"很关键:估算"最终长度"并 new StringBuilder(n)——于是"扩容"(复制)从"多次"变成"零次"。
// 反例:循环里 +=(O(n²))
String s = "";
for (var row : rows) s += row + "\n"; // 20 万行 → 200GB 复制
// 正解:预估容量 + 一次 toString
var sb = new StringBuilder(rows.size() * 100);
for (var row : rows) sb.append(row).append('\n');
String s = sb.toString();
"复制量"可以直接算出来,而它比"跑一遍看时间"更快:
// 一个"预估复制量"的小工具(用于判断"该不该改")
long copies = 0;
int len = 0;
for (var row : rows) { copies += len; len += row.length() + 1; }
System.out.printf("总复制量 ≈ %.1f GB%n", copies / 1024.0 / 1024 / 1024);
// 判据:> 1GB → 必须改;< 50MB → 改不改差别很小
4. "看起来等价"的三种写法与差别
| 写法 | 差别 |
|---|---|
sb.append(a).append(b) |
一次扩容(最省) |
sb.append(a + b) |
先造一个中间字符串(多一次分配) |
String.join(delim, list) |
内部算长度(适合"已有集合") |
第二项是"常见退化":append(a + b) 里的 + 会先创建一个临时字符串第二项的"改法"很机械:把 append(a + b) 拆成 append(a).append(b)——它不改变语义,只是"少一个中间对象"。
——于是"用 StringBuilder 却还在产生垃圾"。
"流式写"的实现只有三行,而它同时解决"内存"与"复制":
// 大导出的正解:Writer + 固定缓冲
try (var w = new BufferedWriter(new FileWriter(path), 1 << 16)) {
for (var row : rows) { w.write(row); w.write('\n'); }
}
// 收益:内存峰值 ≈ 64KB;复制量 ≈ 一次性写入;GC 压力几乎为零
5. "编码"这一层容易被忽略
| 环节 | 开销 |
|---|---|
String → byte[](写文件/网络) |
一次编码 |
多次 getBytes() |
多次编码 |
用 Writer 逐段写 |
增量编码(最省) |
第三项的"附加收益"是"内存峰值":拼成整串时,20 万行 ≈ 20MB 字符串(UTF-8 可能更大),而流式写只要几 KB 缓冲——于是"堆压力"完全不同。
第三项是"大导出的正解":用 BufferedWriter 直接写——于是"内存里不需要那个 20MB 的字符串"。
"用 + 还是 StringBuilder"可以用 javap 直接看编译结果,而不是靠记忆:
// 看"单表达式"是否被优化成 invokedynamic
javac Main.java && javap -c Main | grep -A2 "makeConcatWithConstants"
// 有 makeConcatWithConstants → 已被优化(不必手写 StringBuilder)
// 循环里仍是 new StringBuilder + append + toString 的形态 → 该自己写
6. 排错:五种"拼接相关问题"
| 现象 | 成因 | 处理方向 |
|---|---|---|
| 导出卡 10 秒 | 循环里 += |
StringBuilder/Writer |
| 内存峰值高 | 拼出整串 | 流式写 |
| GC 频繁 | 中间字符串多 | 减少 append(a+b) |
| 改了没变快 | 瓶颈在编码/IO | profile 定位 |
| 多线程拼串结果乱 | 共享 StringBuilder | 各自拼再合并 |
第四项很常见:**"改成 StringBuilder 后只快了 10%"**第四项的"排查顺序"是"先看"复制量"(行数×行长),再看"编码"(getBytes 次数),最后看"IO""——顺序反了会"优化了最不重要的那层"。
——因为"真正的瓶颈是 getBytes() 或磁盘"。
"日志"是最容易被"过度优化"的地方,而它其实不该用字符串拼接:
// 三种日志写法(性能与正确性)
log.info("user={} order={}", uid, oid); // 占位符:不拼串(推荐)
log.info("user=" + uid + " order=" + oid); // 每次都拼(即使级别不开)
if (log.isDebugEnabled()) log.debug("..." + x); // 老的写法(占位符后可省)
// 判据:热路径上的日志,用占位符;一次性日志,随意
7. 边界:什么情况不必优化
| 场景 | 为什么 | 做法 |
|---|---|---|
| 拼接次数少(< 100) | 复制量可忽略 | 用 + |
| 单条表达式 | 已编译优化 | 用 + |
日志参数(log.info("{}", a)) |
不在循环里 | 用占位符 |
| 输出很小(< 1KB) | 无所谓 | 用 + |
最后一条与"可读性"有关的边界:StringBuilder 让代码"更长更丑",而它的收益只在"循环拼接"时出现——所以"为了一次三段的拼接"引入它,只是"用丑陋换零收益"。
判断口径一句话:"这段拼接会不会在循环里执行"。会(导出、报表、组装报文),用 StringBuilder(带容量)+ Writer;不会(几条日志、一次 toString),+ 是最清楚的写法。
还有一个"隐性"成本值得提:循环里 += 会让"字符串常量池"与"新生代"同时承压——每轮的中间结果都是"朝生夕死"的对象,于是"复制"与"GC"在同一条线程上抢 CPU。这也是为什么"改成 StringBuilder 后 CPU 掉一半、但你没有多写一行逻辑"。
8. 自测
a + b + c在 JDK 9+ 被编译成什么?为什么"短拼接"不必手写StringBuilder?- 循环里
s += x为什么是 O(n²)?用"复制量"解释它。 - "显式容量"为什么能省掉扩容?
new StringBuilder()默认容量是多少? sb.append(a + b)为什么"还在产生垃圾"?- 大导出为什么该用
Writer而不是"拼成字符串再写"? - 判断"是否需要优化拼接"的那个问题是什么?