前端开发··2 阅读·预计 10 分钟

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) 让小整数数组塞进浮点,降为 DOUBLEarr[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

四、forEachfor...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 把洞读成 undefinedmap 则保留洞。同一份数据三种读法三种结果,正是「带洞数组」这类隐蔽降级在应用层的具象暴露。

// 正例:遍历前先确认数组是否有洞,语义统一
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_SMIDICTIONARY,每一步降级都对应一次真实的访问开销上升,而且只降不升。治理的关键不在背下那七种名字,而在守住两条底线——类型别中途混、索引别制造洞。删元素用 splice、稀疏用 Map、混合就一次声明到位。当数组始终保持「紧凑 + 同类型」时,你才真正用上了 V8 为数组准备的最快那条路,而不是无意中把它推下阶梯。

0 评论

评论区

登录 后参与评论