Skip to content

数组的特殊处理: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. 密集数组:连续的整数索引,如 [1, 2, 3, 4]
  2. 稀疏数组:有空洞的数组,如 [1, , , 4](索引1和2为空)
  3. 混合类型:不同类型的元素,如 [1, 'hello', {}, null]
  4. 类数组对象:有 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_ELEMENTS

3. 避免删除元素

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_ELEMENTS

4. 合理初始化数组

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],使用 splicefilter
  • 避免读取越界:严格控制循环边界,避免访问 arr[arr.length]
  • 数值计算用类型化数组Int32ArrayFloat64Array 等性能更好

性能对比

  • 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. 为什么 [1, 2, 3.5] 会是 PACKED_DOUBLE_ELEMENTS 而不是 PACKED_ELEMENTS?V8 如何处理类型转换?
  2. 在什么场景下,DICTIONARY_ELEMENTS 反而是最优选择?
  3. 如果必须处理稀疏数组,有哪些方法可以减少性能损失?
数组的特殊处理:Elements Kind 与数组优化 has loaded