数组里混进一个 0.5,遍历慢了 4 倍
压测时遇到一个怪事:同样一段求和循环,数组里的数全换成浮点之后,耗时涨了 4 倍。存储没变、循环没变、数据量没变,变的只是一个元素的类型。
实验
2000 万个元素,循环体 acc = (acc + a[i]) | 0,每种数组各跑 3 轮取最快:
| 数组元素种类 | 耗时 | 每元素 | 倍数 |
|---|---|---|---|
| PACKED_SMI(全整数) | 22.9 ms | 1.15 ns | 1.0x |
| PACKED_DOUBLE(混一个 0.5) | 96.3 ms | 4.82 ns | 4.2x |
| PACKED_ELEMENTS(混一个字符串) | 160.1 ms | 8.01 ns | 7.0x |
| Float64Array | 92.1 ms | 4.61 ns | 4.0x |
机制
V8 的数组背后是一块按元素种类(elements kind)分配的内存:
PACKED_SMI_ELEMENTS:每个槽直接存 32 位小整数PACKED_DOUBLE_ELEMENTS:每个槽存一个 double,整块 8 字节对齐PACKED_ELEMENTS:每个槽存指针,指向堆上的对象
这个种类只能往下降,不能往回升。给 a[10000000] = 0.5 赋一个浮点,V8 要新建一块 double 数组、把两千万个值逐个搬过去;之后再写回整数也不会退回 SMI。再混进一个字符串,就整体变成指针数组,读出来的每个数字都要多做一次拆箱。
反转:慢的不是存储
第二轮实验,把循环体从 (acc + a[i]) | 0 换成 acc += a[i]:
| 数组元素种类 | 取整累加 | 浮点累加 |
|---|---|---|
| PACKED_SMI | 22.9 ms | 32.6 ms |
| PACKED_DOUBLE | 96.3 ms | 24.4 ms |
| Float64Array | 92.1 ms | 23.8 ms |
DOUBLE 从 96.3 掉到 24.4,比 SMI 走浮点累加还快。所以那 4 倍不是「double 存储比 SMI 存储贵」,而是循环体在整数模式下必须每次把 double 截回 int32 —— 每轮多一次读、转、写。存储本身在两种累加方式下是一个价。
SMI 反过来也有坑:acc += a[i] 累加到 2000 万 × 1023 ≈ 2×10^10 时超出 int32 范围,累加器自己被提升成 double,于是整数数组走浮点累加反而更慢(32.6 > 22.9)。两个方向都指向同一件事:成本在类型转换,不在数组。
内存:字节数一样,GC 待遇不同
三种表示都是 2000 万 × 8 B ≈ 152 MB。实测驻留:
PACKED_SMI 堆 +152 MB external + 0 MB
PACKED_DOUBLE 堆 +153 MB external + 0 MB
Float64Array 堆 + 0 MB external +153 MB
Typed array 的数据在 GC 堆之外,ArrayBuffer 的 backing store 记在 external。152 MB 的 GC 堆每轮标记-清扫都得扫一遍,152 MB 的 external 只是一笔记账。元素多、生命周期长的数值流(时序点、坐标、音频帧)换成 typed array,省的不是几个字节,是每次 GC 的停顿。
确认元素种类只需要一行:
node --allow-natives-syntax -e 'const a=[1,2,3]; a[1]=0.5; %DebugPrint(a)'
输出里会写明元素种类是 PACKED_DOUBLE_ELEMENTS。
实操要点
- 数值密集的数据用
Float64Array/Int32Array,别用普通数组装数字。 - 不要用
null、undefined、空字符串表示空值。它们的槽位是对象引用,会直接把数组降级成指针数组。用NaN或约定的哨兵值。 - 不要
delete a[i],也不要给短数组赋a[1000] = x。这会让数组变成 HOLEY(带空洞),访问路径要额外查原型链。 new Array(n)预分配出来的是 HOLEY_SMI,不是 PACKED。真正等价要 push 或 fill 之后。- 循环体尽量和元素种类对齐:整数数组配整数运算,double 数组配浮点累加,别在一个热循环里来回横跳。
数据来自 Node 24.22.1 / Windows,参数 --expose-gc --max-old-space-size=4096。绝对值随机器变化,比例关系稳定。
- 点赞
- 收藏
- 关注作者
评论(0)