Appearance
数组的特殊处理:Elements Kind 与数组优化
你是否注意到,相同长度的数组,操作性能却可能相差数倍?为什么有时候向数组添加一个不同类型的元素会导致性能急剧下降?为什么稀疏数组比密集数组慢得多?
这些问题的答案在于 V8 对数组的特殊优化策略:Elements Kind 系统。在第11章中,我们了解到数组的索引属性存储在 Elements 中,与命名属性分开。本章将深入探讨 V8 如何通过不同的 Elements Kind 来优化数组操作,以及如何编写对 V8 友好的数组代码。
数组的双重身份
在 JavaScript 中,数组既是对象,也是特殊的数据结构。这种双重身份给 V8 的优化带来了挑战。
javascript
const arr = [1, 2, 3];
// 作为数组使用
arr.push(4); // 数组操作
console.log(arr[0]); // 索引访问
// 作为对象使用
arr.name = 'myArray'; // 添加命名属性
arr['100'] = 'value'; // 字符串索引
console.log(arr.length); // 内置属性这种灵活性意味着 V8 需要处理多种存储策略:
- 密集数组:连续的整数索引,如
[1, 2, 3, 4] - 稀疏数组:有空洞的数组,如
[1, , , 4](索引1和2为空) - 混合类型:不同类型的元素,如
[1, 'hello', {}, null] - 类数组对象:有 length 属性但不是真正的数组
为了高效处理这些场景,V8 引入了 Elements Kind 系统。
Elements Kind:数组的内部类型
Elements Kind 是 V8 内部用来标识数组存储策略的分类系统。每个数组都有一个 Elements Kind,决定了 V8 如何存储和访问数组元素。
主要的 Elements Kind
V8 定义了超过 20 种 Elements Kind,但核心的几种是:
1. PACKED_SMI_ELEMENTS
- 密集数组,所有元素都是 Smi(小整数)
- 最快的数组类型
- 示例:
[1, 2, 3, 4]
2. PACKED_DOUBLE_ELEMENTS
- 密集数组,所有元素都是双精度浮点数
- 使用连续的内存存储浮点数,无需装箱
- 示例:
[1.1, 2.2, 3.3, 4.4]
3. PACKED_ELEMENTS
- 密集数组,元素类型混合(对象、字符串、数字等)
- 每个元素都是 Tagged Pointer
- 示例:
[1, 'hello', {}, null]
4. HOLEY_SMI_ELEMENTS
- 稀疏数组,元素是 Smi
- 有"洞"(hole),即未定义的索引
- 示例:
[1, , 3, 4](索引1为空)
5. HOLEY_DOUBLE_ELEMENTS
- 稀疏数组,元素是双精度浮点数
- 示例:
[1.1, , 3.3, 4.4]
6. HOLEY_ELEMENTS
- 稀疏数组,元素类型混合
- 最灵活但也最慢的类型
- 示例:
[1, , 'hello', , {}]
7. DICTIONARY_ELEMENTS
- 使用哈希表存储,适用于非常稀疏的数组
- 索引作为哈希表的键
- 示例:
arr[1000000] = 1(只有一个元素,但索引很大)
Elements Kind 的层次结构
Elements Kind 有一个严格的优化层次:
最优化(最快)
↓
PACKED_SMI_ELEMENTS (密集小整数数组)
↓
PACKED_DOUBLE_ELEMENTS (密集浮点数数组)
↓
PACKED_ELEMENTS (密集混合数组)
↓
HOLEY_SMI_ELEMENTS (稀疏小整数数组)
↓
HOLEY_DOUBLE_ELEMENTS (稀疏浮点数数组)
↓
HOLEY_ELEMENTS (稀疏混合数组)
↓
DICTIONARY_ELEMENTS (哈希表存储)
↓
最不优化(最慢)关键规则:Elements Kind 只能向下转换(降级),不能向上转换(升级)。一旦数组降级到更通用的类型,就无法回到更优化的类型。
Elements Kind 转换
让我们通过具体示例看看 Elements Kind 如何转换:
示例 1:类型转换
javascript
// 创建一个 Smi 数组
const arr = [1, 2, 3];
// Elements Kind: PACKED_SMI_ELEMENTS
arr.push(4.5);
// 添加浮点数,转换为 PACKED_DOUBLE_ELEMENTS
// 原有的 Smi 值 1, 2, 3 被转换为双精度浮点数
arr.push('hello');
// 添加字符串,转换为 PACKED_ELEMENTS
// 所有元素现在都是 Tagged Pointer示例 2:创建洞(Hole)
javascript
const arr = [1, 2, 3];
// Elements Kind: PACKED_SMI_ELEMENTS
arr[5] = 6;
// 创建洞:索引3和4未定义
// 转换为 HOLEY_SMI_ELEMENTS
// 即使后来填补洞,也不会回到 PACKED
arr[3] = 4;
arr[4] = 5;
// 仍然是 HOLEY_SMI_ELEMENTS示例 3:delete 操作
javascript
const arr = [1, 2, 3, 4];
// Elements Kind: PACKED_SMI_ELEMENTS
delete arr[1];
// 创建洞,转换为 HOLEY_SMI_ELEMENTS
// 等价于 arr[1] = undefined(从 Elements Kind 角度)示例 4:数组初始化
javascript
// 方式 1:字面量初始化(最优)
const arr1 = [1, 2, 3, 4];
// Elements Kind: PACKED_SMI_ELEMENTS
// 方式 2:预分配但未初始化(创建洞)
const arr2 = new Array(4);
// Elements Kind: HOLEY_SMI_ELEMENTS(即使还没赋值)
arr2[0] = 1;
arr2[1] = 2;
arr2[2] = 3;
arr2[3] = 4;
// 仍然是 HOLEY_SMI_ELEMENTS
// 方式 3:使用 Array.from 或 fill(最优)
const arr3 = Array.from({ length: 4 }, (_, i) => i + 1);
// Elements Kind: PACKED_SMI_ELEMENTS
const arr4 = new Array(4).fill(0);
// Elements Kind: PACKED_SMI_ELEMENTS性能影响
不同的 Elements Kind 对性能有显著影响。让我们通过实测看看差异:
测试 1:遍历性能
javascript
// PACKED_SMI_ELEMENTS
const packed = Array.from({ length: 100000 }, (_, i) => i);
// HOLEY_SMI_ELEMENTS
const holey = new Array(100000);
for (let i = 0; i < 100000; i++) {
holey[i] = i;
}
// PACKED_ELEMENTS (混合类型)
const mixed = Array.from({ length: 100000 }, (_, i) => i % 2 === 0 ? i : String(i));
function sum(arr) {
let total = 0;
for (let i = 0; i < arr.length; i++) {
total += arr[i];
}
return total;
}
console.time('PACKED_SMI');
sum(packed);
console.timeEnd('PACKED_SMI');
// 典型输出:PACKED_SMI: 1.2ms
console.time('HOLEY_SMI');
sum(holey);
console.timeEnd('HOLEY_SMI');
// 典型输出:HOLEY_SMI: 2.5ms(慢 2 倍)
console.time('PACKED_MIXED');
sum(mixed);
console.timeEnd('PACKED_MIXED');
// 典型输出:PACKED_MIXED: 3.8ms(慢 3 倍)为什么 HOLEY 更慢?
当 V8 遍历 HOLEY 数组时,必须检查每个索引是否有洞:
javascript
// V8 内部的简化逻辑
function getElement(arr, index) {
if (arr.elementsKind === PACKED_SMI_ELEMENTS) {
// 直接访问,最快
return arr.elements[index];
} else if (arr.elementsKind === HOLEY_SMI_ELEMENTS) {
// 需要检查洞
const value = arr.elements[index];
if (value === THE_HOLE) {
// 洞:需要查找原型链
return lookupInPrototypeChain(arr, index);
}
return value;
}
}这个额外的检查在每次访问时都会发生,累积起来造成显著的性能差距。
测试 2:数组方法性能
javascript
// PACKED vs HOLEY 在 map、filter 等方法上的差异
const packed = [1, 2, 3, 4, 5];
const holey = [1, , 3, , 5];
console.time('packed.map');
for (let i = 0; i < 100000; i++) {
packed.map(x => x * 2);
}
console.timeEnd('packed.map');
// 典型输出:packed.map: 50ms
console.time('holey.map');
for (let i = 0; i < 100000; i++) {
holey.map(x => x * 2);
}
console.timeEnd('holey.map');
// 典型输出:holey.map: 85ms(慢 70%)优化策略与最佳实践
1. 保持数组类型一致
javascript
// ❌ 不推荐:混合类型
const mixed = [1, 'two', 3, 'four'];
// Elements Kind: PACKED_ELEMENTS
// ✅ 推荐:类型一致
const numbers = [1, 2, 3, 4];
// Elements Kind: PACKED_SMI_ELEMENTS
const strings = ['one', 'two', 'three', 'four'];
// Elements Kind: PACKED_ELEMENTS(但至少类型一致)2. 避免创建洞
javascript
// ❌ 不推荐:预分配未初始化的数组
const arr1 = new Array(100);
for (let i = 0; i < 100; i++) {
arr1[i] = i;
}
// Elements Kind: HOLEY_SMI_ELEMENTS
// ✅ 推荐:使用字面量或 Array.from
const arr2 = Array.from({ length: 100 }, (_, i) => i);
// Elements Kind: PACKED_SMI_ELEMENTS
// ✅ 推荐:使用 fill 初始化
const arr3 = new Array(100).fill(0);
// Elements Kind: PACKED_SMI_ELEMENTS3. 避免删除元素
javascript
// ❌ 不推荐:使用 delete
const arr1 = [1, 2, 3, 4, 5];
delete arr1[2];
// 创建洞,降级为 HOLEY_SMI_ELEMENTS
// ✅ 推荐:使用 splice 或 filter
const arr2 = [1, 2, 3, 4, 5];
arr2.splice(2, 1); // 移除索引2,数组变为 [1, 2, 4, 5]
// 保持 PACKED_SMI_ELEMENTS
// ✅ 推荐:创建新数组
const arr3 = [1, 2, 3, 4, 5];
const filtered = arr3.filter((_, i) => i !== 2);
// 新数组是 PACKED_SMI_ELEMENTS4. 合理初始化数组
javascript
// ❌ 不推荐:逐个 push
const arr1 = [];
for (let i = 0; i < 1000; i++) {
arr1.push(i);
}
// 性能较差,涉及多次内存重分配
// ✅ 推荐:预知大小时使用 Array.from
const arr2 = Array.from({ length: 1000 }, (_, i) => i);
// 一次性分配,PACKED_SMI_ELEMENTS
// ✅ 推荐:小数组使用字面量
const arr3 = [0, 1, 2, 3, 4];
// 最快的初始化方式5. 避免读取超出边界
javascript
const arr = [1, 2, 3];
// ❌ 不推荐:读取不存在的索引
for (let i = 0; i <= arr.length; i++) { // 注意 <=
console.log(arr[i]); // 最后一次是 undefined
}
// V8 需要检查原型链,性能下降
// ✅ 推荐:严格控制边界
for (let i = 0; i < arr.length; i++) { // 注意 <
console.log(arr[i]);
}数组长度与内存分配
V8 对不同大小的数组采用不同的分配策略。
小数组 vs 大数组
javascript
// 小数组(< 1024 个元素):直接分配在堆上
const small = new Array(100).fill(0);
// Elements Kind: PACKED_SMI_ELEMENTS
// 内存布局:连续的内存块
// 大数组(>= 1024 个元素):可能使用不同的分配策略
const large = new Array(100000).fill(0);
// 同样是 PACKED_SMI_ELEMENTS,但内存分配可能分块动态增长
当数组通过 push 动态增长时,V8 使用容量加倍策略:
javascript
const arr = [];
console.log(arr.length); // 0
// 内部容量增长过程(简化):
arr.push(1); // 容量: 4(V8 预分配)
arr.push(2); // 容量: 4
arr.push(3); // 容量: 4
arr.push(4); // 容量: 4
arr.push(5); // 容量: 8(加倍)
arr.push(6); // 容量: 8
// ...这种策略平衡了内存使用和重分配次数。
稀疏数组与 DICTIONARY_ELEMENTS
当数组非常稀疏时,V8 会转换为 DICTIONARY_ELEMENTS,使用哈希表存储。
javascript
// 创建一个非常稀疏的数组
const sparse = [];
sparse[0] = 'a';
sparse[1000000] = 'b';
// Elements Kind: DICTIONARY_ELEMENTS
// V8 使用哈希表而不是连续内存
// 节省内存,但访问变慢性能对比:
javascript
// PACKED 数组
const packed = Array.from({ length: 1000 }, (_, i) => i);
// DICTIONARY 数组
const dictionary = [];
for (let i = 0; i < 1000; i++) {
dictionary[i * 1000] = i; // 非常稀疏
}
function accessElements(arr) {
let sum = 0;
for (let key in arr) {
sum += arr[key];
}
return sum;
}
console.time('PACKED access');
accessElements(packed);
console.timeEnd('PACKED access');
// 典型输出:PACKED access: 0.1ms
console.time('DICTIONARY access');
accessElements(dictionary);
console.timeEnd('DICTIONARY access');
// 典型输出:DICTIONARY access: 2.5ms(慢 25 倍)类型化数组(Typed Arrays)
对于需要最佳性能的数值计算,使用类型化数组:
javascript
// 普通数组
const normalArray = Array.from({ length: 1000 }, (_, i) => i);
// Elements Kind: PACKED_SMI_ELEMENTS,但仍然是 Tagged Pointer
// 类型化数组
const int32Array = new Int32Array(1000);
for (let i = 0; i < 1000; i++) {
int32Array[i] = i;
}
// 真正的连续内存,32位整数,无 tagging 开销
function sum(arr) {
let total = 0;
for (let i = 0; i < arr.length; i++) {
total += arr[i];
}
return total;
}
console.time('Normal Array');
for (let i = 0; i < 10000; i++) sum(normalArray);
console.timeEnd('Normal Array');
// 典型输出:Normal Array: 15ms
console.time('Int32Array');
for (let i = 0; i < 10000; i++) sum(int32Array);
console.timeEnd('Int32Array');
// 典型输出:Int32Array: 8ms(快近 2 倍)类型化数组的优势:
- 无 tagging 开销:直接存储原始数值,无需 Tagged Pointer
- 固定类型:V8 可以生成高度优化的机器码
- 连续内存:更好的缓存局部性
- WebAssembly 互操作:可以高效地与 WASM 共享内存
检测 Elements Kind
虽然 JavaScript 没有提供直接 API 查看 Elements Kind,但我们可以通过性能测试推断:
javascript
function guessElementsKind(arr) {
// 检查是否有洞
let hasHoles = false;
for (let i = 0; i < arr.length; i++) {
if (!(i in arr)) {
hasHoles = true;
break;
}
}
// 检查元素类型
let allSmi = true;
let allNumber = true;
for (const val of arr) {
if (typeof val !== 'number') {
allNumber = false;
allSmi = false;
break;
}
if (!Number.isInteger(val) || val < -2**30 || val >= 2**30) {
allSmi = false;
}
}
// 推断 Elements Kind
if (hasHoles) {
if (allSmi) return 'HOLEY_SMI_ELEMENTS';
if (allNumber) return 'HOLEY_DOUBLE_ELEMENTS';
return 'HOLEY_ELEMENTS';
} else {
if (allSmi) return 'PACKED_SMI_ELEMENTS';
if (allNumber) return 'PACKED_DOUBLE_ELEMENTS';
return 'PACKED_ELEMENTS';
}
}
console.log(guessElementsKind([1, 2, 3])); // PACKED_SMI_ELEMENTS
console.log(guessElementsKind([1.1, 2.2, 3.3])); // PACKED_DOUBLE_ELEMENTS
console.log(guessElementsKind([1, 'a', {}])); // PACKED_ELEMENTS
console.log(guessElementsKind([1, , 3])); // HOLEY_SMI_ELEMENTS实际开发中,使用 V8 的 --allow-natives-syntax 标志可以访问内部函数:
javascript
// node --allow-natives-syntax script.js
const arr1 = [1, 2, 3];
console.log(%DebugPrint(arr1)); // 打印内部结构,包括 Elements Kind
const arr2 = [1, , 3];
console.log(%DebugPrint(arr2)); // 可以看到 HOLEY 标记本章小结
Elements Kind 是 V8 优化数组操作的核心机制。通过为不同特征的数组选择不同的存储策略,V8 在保持 JavaScript 灵活性的同时实现了接近原生代码的性能。
核心概念:
- Elements Kind 分类:从 PACKED_SMI(最快)到 DICTIONARY(最慢)的层次结构
- 单向转换:只能降级不能升级,一旦变成 HOLEY 或 PACKED_ELEMENTS 就无法回退
- 洞的代价:HOLEY 数组需要额外检查,性能损失 50%-100%
- 类型一致性:保持数组元素类型一致可以获得最佳性能
最佳实践:
- 使用字面量或 Array.from:避免
new Array(n)创建未初始化数组 - 保持类型一致:避免混合不同类型的元素
- 避免创建洞:不要
delete arr[i],使用splice或filter - 避免读取越界:严格控制循环边界,避免访问
arr[arr.length] - 数值计算用类型化数组:
Int32Array、Float64Array等性能更好
性能对比:
- PACKED_SMI_ELEMENTS vs HOLEY_SMI_ELEMENTS:2-3倍性能差距
- PACKED vs PACKED_ELEMENTS:1.5-2倍性能差距
- 连续数组 vs DICTIONARY_ELEMENTS:10-25倍性能差距
理解 Elements Kind 机制后,你就能写出对 V8 更友好的数组代码。在下一章中,我们将探讨函数对象的内部实现,了解 V8 如何表示和优化函数。
思考题:
- 为什么
[1, 2, 3.5]会是 PACKED_DOUBLE_ELEMENTS 而不是 PACKED_ELEMENTS?V8 如何处理类型转换? - 在什么场景下,DICTIONARY_ELEMENTS 反而是最优选择?
- 如果必须处理稀疏数组,有哪些方法可以减少性能损失?