循环里 s += x 很快,直到你顺手读了一下它
线上一个日志拼装函数,200 万次循环里 s += x,压测时 CPU 打满。有人把它改成数组 push 再 join,快了十倍,于是团队里流传「JS 字符串拼接必须用数组」——这个结论在 V8 上是错的。
实验
片段 abcdefgh,拼 20 万次,结果 160 万字符:
| 写法 | 耗时 |
|---|---|
+= 循环 |
17.1 ms |
数组 join |
5.2 ms |
只差 3 倍,绝对量都很小。真正的差别在后面。
为什么 += 这么快
V8 里 s += piece 不是每次拷贝整个字符串,而是建一个 ConsString:二叉树节点,左边挂旧串,右边挂新片段。20 万次拼接只是建 20 万个节点,单次 O(1),总成本 O(n)。
代价被推迟了,叫「摊平」(flatten)。只有真需要连续字节时才付:indexOf、charCodeAt、slice、正则、比较、写文件。V8 这时才遍历整棵树、按序拷进一块连续内存,并把根节点换成 flat 版本。
摊平一次的账单
拼完 160 万字符,做各种「第一次访问」,同一个串再访问一次作对照:
| 操作 | 第一次 | 第二次 |
|---|---|---|
.length |
0.07 ms | 0.02 ms |
indexOf("zzz") |
2.11 ms | 0.12 ms |
charCodeAt(末尾) |
1.54 ms | 0.00 ms |
toUpperCase() |
2.09 ms | 0.59 ms |
slice(0, 16) |
1.46 ms | 0.00 ms |
split(",") |
2.13 ms | 0.15 ms |
length 不用摊平,长度直接记在节点上;其余第一次都要付一遍账。摊平本身是线性的:40 万字符 0.77 ms、160 万 3.92 ms、640 万 7.68 ms,每字符 1~2 ns。
一次性摊平不贵。贵的是反复摊平。
O(n²) 是怎么长出来的
let s = '';
for (let i = 0; i < n; i++) {
s += 'x';
sum += s.charCodeAt(s.length - 1); // 每次读都把当前整串摊平
}
第 i 轮 append 之后,根是 (flat, x) 的 ConsString;charCodeAt 要摊平,就把 i 个字符全拷一遍;摊平后根变成 flat。下一轮再 append 又长出一层。总拷贝量 1+2+…+n。
实测:2000 次 1.48 ms、8000 次 7.04 ms、32000 次 83.27 ms、64000 次 314.76 ms。每翻一倍耗时涨 3.3~3.8 倍,不是线性是平方。按这个斜率,100 万次约 80 秒。
内存:内容 1.6 MB,树 6.1 MB
| 写法 | 堆驻留 |
|---|---|
+= 未摊平 |
6.1 MB |
join(已摊平) |
1.5 MB |
ASCII 字符 V8 用 one-byte 存,160 万字符就是 1.6 MB。join 那 1.5 MB 基本就是内容本身;+= 多出来的 4.6 MB 是 20 万个 ConsString 节点,每个约 23 字节。缓存在内存里一个 100 MB 的日志串,树可能占 300 MB。
结论
- 循环里
s += x没问题,前提是循环内不读它。cons string 让 append 变成 O(1)。 - 循环内
s.length安全;s.charCodeAt、s.indexOf、s[i]、s.match()不安全,它们逼着摊平。 - 需要反复随机读、跑正则、序列化时,先攒成数组再一次
join,让摊平只发生一次。 - 排查这类问题:CPU 各核都忙但单请求慢,直接看字符串处理循环体内的读取语句。
数据来自 Node 24.22.1 / Windows,--expose-gc --max-old-space-size=4096,每个变体独立建串,避免互相摊平影响计时。
- 点赞
- 收藏
- 关注作者
评论(0)