「别在循环里用 += 拼字符串」——我在四种语言里测了一遍
先看结论表。拼接 n 次,每次 8 个字符:
n = 160,000 时的耗时 += 写法 推荐写法 倍数
--------------------------------------------------------------
Java 18,178.7 ms 1.7 ms 10,693x
Go 28,142.0 ms 14.5 ms 1,941x
Python (标准写法) 7.5 ms 6.2 ms 1x ← ?
JavaScript (V8) 21.8 ms 9.7 ms 1x ← ?
同一条建议,一半的语言里是救命的,另一半已经不需要了。
Java:10693 倍,没有悬念
写法 20000 40000 80000 160000
---------------------------------------------------------------
s += x 442.5 ms 968.6 ms 4199.3 ms 18178.7 ms
s.concat(x) 208.7 ms 890.1 ms 3789.6 ms 21311.4 ms
StringBuilder 5.1 ms 0.7 ms 1.0 ms 1.7 ms
StringBuilder 预分配 1.2 ms 2.0 ms 1.3 ms 1.7 ms
n 翻倍,耗时翻四倍——标准的 O(n²)。
很多人知道「编译器会把 a + b 优化成 StringBuilder」,这是对的——但只在一条语句内。写成循环,每一轮都会新建一个 StringBuilder、把已有的全部内容 append 进去、再 toString() 拷贝一份出来。
拷贝总量是 8 + 16 + 24 + ... + 8n,约 n²×4 字节。n=160,000 时是 100 GB 的内存拷贝。
Go:28142 倍,更狠
写法 20000 40000 80000 160000
---------------------------------------------------------------
s += x 391.4 ms 1649.6 ms 7740.6 ms 28142.0 ms
strings.Builder 0.3 ms 0.7 ms 1.0 ms 14.5 ms
Builder + Grow 0.2 ms 0.1 ms 0.2 ms 1.0 ms
bytes.Buffer 0.5 ms 2.2 ms 1.0 ms 16.1 ms
Go 的 string 是不可变的(底层是指向只读字节数组的指针+长度),s += x 每次都要分配一块新内存并全量拷贝。没有任何编译器优化能救它。
额外看一眼最后一列:strings.Builder 14.5ms,加上 b.Grow(n*8) 预分配后 1.0ms,快 14 倍。
因为 Builder 内部也是切片,也要按倍数扩容、也要拷贝——预分配把这部分全省了。知道最终长度就一定要 Grow。
Python:反转——标准写法已经是线性的
写法 20000 40000 80000 160000
---------------------------------------------------------------
s += x 1.8 ms 1.9 ms 3.7 ms 7.5 ms ← 线性
s += x (有别名) 175.5 ms 1006.3 ms 1698.1 ms 6667.7 ms ← O(n²)
"".join() 0.8 ms 2.1 ms 2.9 ms 6.2 ms
io.StringIO 2.6 ms 2.0 ms 4.0 ms 8.4 ms
第一行是线性的。 那条背了十几年的 Python 建议,在标准写法下已经不成立了。
因为 CPython 有个特判:当 s += x 里的 s 是字符串、且它的引用计数为 1(也就是没有别人在用这块内存)时,解释器会直接 realloc 原地扩展缓冲区,而不是分配新对象。
但注意第二行——我只是加了一行 keep = s 保存一个额外引用:
python
for i in range(n):
keep = s # 就这一行
s = s + "abcdefgh"
引用计数变成 2,优化立刻失效,退回 O(n²),慢 889 倍。
这个优化的脆弱性正是它危险的地方:它是 CPython 的实现细节,不是语言保证。传给函数、放进列表、被闭包捕获、在 PyPy 或其他实现上跑——任何一件事都能让它消失,而且没有任何提示。
所以结论不变:该用 join 还是用 join,只是理由从「性能」变成了「确定性」。
JavaScript:V8 压根不拷贝
写法 20000 80000 160000 320000
---------------------------------------------------------------
s += x 0.6 ms 5.5 ms 11.5 ms 21.8 ms ← 线性
array.join() 2.5 ms 2.5 ms 4.0 ms 9.7 ms
+= 是线性的,而且在小规模下比 join 还快。
V8 用的是 ConsString(rope 的一种):a + b 不做任何拷贝,只创建一个新节点记录「左边是 a,右边是 b」,O(1) 。真正的字符串内容依然散落在各处。
只有当某个操作需要连续内存时(charAt、正则匹配、传给 C++ 层),V8 才会展平(flatten)这棵树,一次性拷贝成连续字符串。
我试过每轮读 .length 想强制展平,但没触发——ConsString 节点自带长度字段,读长度是 O(1)。要真正触发展平得用 charAt 或正则。
所以在 JS 里,「用数组 push 再 join」这条老建议,大部分场景下已经是一个没有收益的习惯。
为什么会是 O(n²)
不可变字符串 + 每次拷贝全量内容,拷贝总量就是:
8 + 16 + 24 + ... + 8n = 8 × n(n+1)/2 ≈ 4n²
而所有的解法本质上只有三种:
① 可变缓冲区 —— StringBuilder、strings.Builder、StringIO。摊还 O(1),需要时按倍数扩容。
② 先收集后一次性拼 —— join。总拷贝量恰好是 n。
③ 惰性求值 —— V8 的 ConsString。拼接时不拷贝,用到时才展平。
实践清单
1. Java、Go、C#、Rust:老老实实用 Builder。 这些语言的字符串不可变且没有 rope 优化,不照做就是千倍级别的差距。
2. 知道最终长度就预分配。 Go 的 Grow、Java 的 new StringBuilder(cap)——实测能再快一个数量级。
3. Python 用 join,不是因为 += 慢,而是因为 += 的快是偶然的。 依赖一个引用计数细节来保证性能,不是好工程。
4. JS 里的 += 可以放心用。 但如果拼完之后要频繁做 charAt/正则,考虑主动 join 一次避免反复展平。
5. 别凭直觉优化,先测。 这次四个语言四种真相,而它们背的是同一句话。
最后
「循环里别用 += 拼字符串」是少数几条几乎每个人都听过的性能建议。
但它其实是一句关于实现细节的建议,而实现细节是会变的。Java 和 Go 至今照旧,CPython 打了个补丁,V8 换了整套数据结构。
你以为你记住了一条原理,其实你记住的是 2005 年的一个实现。