V8 数组元素类型与内联缓存的选型治理:从 PACKED/ELEMENTS 到 Holey 降级的性能真相
引言
很多人默认「数组就是数组」,但 V8 内部把数组按「元素类型」和「是否有空洞」分成了多种元素种类(Elements Kind),从最快的 PACKED_SMI_ELEMENTS 到最慢的 DICTIONARY_ELEMENTS。关键在它只单向降级:数组一旦从「紧凑」降到「带洞」,就再也回不去。本文不重复数组 API,只拆这条元素种类的迁移规则,以及如何守住「快的那一条线」。
一、元素种类是一张只降不升的阶梯
V8 描述一个数组用两个维度:元素是 Smi(小整数)、Double(浮点)还是普通对象;数组是否「紧凑」(无 hole)。越靠下的种类访问越快。
// 阶梯(从上往下越来越慢)
// PACKED_SMI -> [1, 2, 3] 最快,纯小整数
// PACKED_DOUBLE -> [1.5, 2.5] 浮点
// PACKED_ELEMENTS -> [1, 'a', {}] 混合,装箱
// HOLEY_SMI -> [1, , 3] 有洞
// HOLEY_DOUBLE -> [1.5, , 2.5]
// HOLEY_ELEMENTS -> [1, , 'a']
// DICTIONARY -> 稀疏到成为字典 最慢
一旦数组出现 hole(如 [1, , 3]),它就被标记为 HOLEY_,后续所有访问都要额外检查「这个索引到底有没有值」。这一刀,把 PACKED 一路砍到 HOLEY。
二、赋值是降级的导火索:从 Smi 到 Holey 的三连降
最隐蔽的坑,是「看起来无害」的赋值一步步把数组推下阶梯,而且这个过程不可逆。
// 反例:三连赋值把数组从最快降到带洞
const arr = [1, 2, 3]; // PACKED_SMI_ELEMENTS
arr.push(4.5); // 降级:PACKED_DOUBLE_ELEMENTS
arr[10] = 'x'; // 降级:HOLEY_ELEMENTS,中间全是洞
// 之后再怎么删掉 'x',数组也回不到 PACKED 了
push(4.5) 让小整数数组塞进浮点,降为 DOUBLE;arr[10] = 'x' 又制造了洞,进一步降到 HOLEY。这三步里没有任何语法错误,但数组的内在结构已经彻底「劣化」了。
// 正例:保持元素类型统一、索引连续
const arr = [1, 2, 3]; // PACKED_SMI,干净起步
// 追加仍用连续索引,且类型一致
arr.push(4); // 仍是 PACKED_SMI
// 若确实需要混合,就在初始化时一次声明,避免中途换型
const mixed = [1, 'a', {}]; // PACKED_ELEMENTS,一步到位
要么始终保持同类型、连续追加,要么在创建时就定下最高需要的元素种类,别让它在运行中反复换型。
三、删除元素时用「紧凑删除」而非 delete
delete arr[i] 是制造 hole 的经典元凶,而 hole 正是降级的入口。
// 反例:delete 留下 hole,数组降级为 HOLEY
const arr = [1, 2, 3, 4];
arr[1] = undefined; // 这样其实是「赋值 undefined」,不是洞,但仍是 ELEMENTS 里的丑值
// 反例(真正的洞):delete 直接造出 hole
delete arr[1]; // arr 变成 [1, , 3, 4],降级为 HOLEY_SMI
arr.forEach((v) => console.log(v)); // 1, 3, 4 —— hole 被跳过
// 正例:用 splice 做紧凑删除,保持数组无洞
const arr = [1, 2, 3, 4];
arr.splice(1, 1); // arr 变 [1, 3, 4],紧凑、无洞,仍是 PACKED
delete 留下 hole,splice 把后续元素前移、保持紧凑。要「删除一个元素」,永远优先 splice,而不是 delete。
四、forEach 与 for...of 在 hole 上的行为差异
hole 不只是性能问题,更是语义问题:不同遍历方式对 hole 的处理不一样,这个差异常常是 bug 的来源。
const arr = [1, , 3];
// forEach:跳过 hole
arr.forEach((v) => console.log(v)); // 1, 3
// for...of:把 hole 当成 undefined
for (const v of arr) console.log(v); // 1, undefined, 3
// map:也会保留 hole 的位置
const mapped = arr.map((v) => v * 2); // [2, , 6]
同一个数组,forEach 跳过洞,for...of 把洞读成 undefined,map 则保留洞。同一份数据三种读法三种结果,正是「带洞数组」这类隐蔽降级在应用层的具象暴露。
// 正例:遍历前先确认数组是否有洞,语义统一
const arr = [1, , 3];
// 显式把洞填平,避免三种遍历方式各说各话
const compact = Array.from(arr, (v) => v ?? 0); // [1, 0, 3]
需要统一语义时,用 Array.from 配默认值把洞填平,让后续遍历不管用哪种方式结果都一致。
五、大数组稀疏数据的正确姿势:别用数组装稀疏
当数据天然稀疏(如用很大的数字做下标),硬用数组会直接降成 DICTIONARY_ELEMENTS,访问退化为字典查找。
// 反例:用数组承载稀疏键,下标到 10 万
const sparse = [];
sparse[100000] = 'rare'; // 降级为 DICTIONARY,访问变字典查找
// 正例:稀疏键用 Map,保留 O(1) 且语义清晰
const map = new Map();
map.set(100000, 'rare'); // Map 天生适合稀疏键值
数组的强项是「密集、连续、同类型」。一旦数据天然稀疏,就该换 Map,而不是硬塞数组让它降级成字典模式。选型本身就是性能决策。
六、数组性能的治理清单
把上面的规则收敛成一张可执行清单:
// 正例:V8 数组治理清单
// 1. 保持元素类型统一(Smi 就都 Smi),需要混合一次声明到位
// 2. 连续追加、连续索引,避免 arr[i] 远跳制造洞
// 3. 删除用 splice,禁用 delete(delete 造 hole)
// 4. 稀疏键用 Map,不硬塞数组降级成字典
// 反模式速查
// push 混类型:Smi 降 Double 再降 Elements
// delete arr[i]:直接造 hole,降级 HOLEY
// arr[100000] = x:大下标直接字典化
// 遍历 hole 数组不统一语义:forEach/for...of/map 结果不一致
这四条的共同主题:数组的快,取决于「紧凑」和「同类型」两条线都守住。一旦降级,就是不可逆的单行道。
结语
V8 数组的「元素种类」系统,是把性能差异写进数据结构本身的一种设计:从 PACKED_SMI 到 DICTIONARY,每一步降级都对应一次真实的访问开销上升,而且只降不升。治理的关键不在背下那七种名字,而在守住两条底线——类型别中途混、索引别制造洞。删元素用 splice、稀疏用 Map、混合就一次声明到位。当数组始终保持「紧凑 + 同类型」时,你才真正用上了 V8 为数组准备的最快那条路,而不是无意中把它推下阶梯。
评论区
登录 后参与评论