Skip to main content
我最早以为数组就是连续内存,后来看到 V8 的元素种类和字典模式,才知道写法会影响它的优化路径。

Q1: V8 数组的历史?为什么数组优化这么重要?

1

JS 数组的本质:特殊对象

在 JS 规范中,数组(Array)本质上就是对象(Object),只是 length 属性会自动更新,且属性名是数字索引。理论上 V8 可以用通用的对象哈希表来存储数组元素——但这样每次访问 arr[i] 都需要哈希查找,性能极差。
2

V8 的优化:连续内存

V8 对数组做了特殊优化:如果数组元素是同类型的,V8 会用连续的内存块(类似 C 数组)来存储,访问 arr[i] 直接用偏移计算,O(1) 时间复杂度。这比哈希查找快 10-100 倍。
3

元素种类(Elements Kind)的细分

V8 进一步将数组按元素类型细分:全是小整数(SMI)的数组、全是浮点数的数组、全是对象的数组、有空洞的数组……不同种类的数组使用不同的访问策略,避免运行时类型检查。
V8 数组优化解决的核心问题:
V8 把 JS 数组”伪装”成了 C 数组——连续内存、O(1) 访问、类型特化,但代价是元素种类必须一致。

🚫 不了解 V8 数组优化

就像在图书馆找书,每次都要翻目录查索引号(哈希查找)。虽然能找到,但如果书架上的书按分类排列好了(连续内存),直接走到对应位置拿就行了。

✅ 了解 V8 数组优化

知道 V8 会把同类型元素放在连续内存中,就像超市货架按品类分区——饮料区全是饮料,零食区全是零食。同类商品放在一起,找起来最快。
先说”JS 数组本质是对象”这个前提,再引出 V8 的连续内存优化。想听的是你对引擎在底层做了什么的理解,而不是背 API。

Q2: 元素种类(Elements Kind)

V8 用”元素种类”(Elements Kind)来区分不同类型的数组。不同种类的数组使用不同的内存布局和访问策略。
元素种类的详细分类:
元素种类的降级路径是单向的:
一旦数组的元素种类降级了,就无法恢复。即使你后来把所有浮点数都改成整数,数组仍然是 PACKED_DOUBLE,不会变回 PACKED_SMI。
元素种类就像酒店的房间分类。标准间(PACKED_SMI)最便宜最快,大床房(PACKED_DOUBLE)其次,混合房(PACKED_ELEMENTS)一般。一旦你把标准间改造成了大床房(放入浮点数),即使后来换了张单人床,房间分类仍然是大床房。所以从一开始就选对房间类型(保持类型一致)很重要。

Q3: Packed 数组 vs Holey 数组

Packed(密集)和 Holey(有空洞)是 V8 数组优化的关键区分。

为什么 Holey 数组更慢?


Q4: 稀疏数组与 Dictionary 模式

当数组极度稀疏时(如 arr[1000000] = 1),V8 会将数组从连续内存切换为字典模式(Dictionary Mode),用哈希表存储元素。

字典模式的代价

连续内存模式就像超市货架——商品按编号排列,要拿 3 号商品直接走 3 步。字典模式就像文件柜——每个文件有标签(哈希键),要找某个文件需要查标签索引。如果文件很少但编号很大(稀疏),用文件柜更省内存;如果文件很多且编号连续,用货架更快。

Q5: V8 对数组方法的优化

V8 对常见的数组方法(forEach、map、filter、reduce 等)做了专门优化,但并非所有方法都能被优化。

为什么 for…in 最慢?

for…of 的性能


Q6: 数组的内存布局与 SMI 优化

V8 内部对小整数(SMI, Small Integer)有特殊优化——SMI 直接存在指针中,不需要额外的堆对象。

PACKED_SMI 数组的内存布局


Q7: 数组退化的场景与避免方法

数组退化(Array Degradation)是指数组从高效的连续内存模式退化为低效的字典模式,或从 Packed 退化为 Holey。

常见退化场景

反模式 vs 最佳实践


Q8: TypedArray 与高性能数组操作

当需要处理大量数值数据时,TypedArray 是比普通 Array 更好的选择。

TypedArray vs 普通数组的性能对比

TypedArray 的使用场景

TypedArray 只能存储数字(BigInt64Array / BigUint64Array 存 BigInt),不能存储普通对象、字符串等。如果你需要存储混合类型,只能用普通 Array。另外,TypedArray 的长度在创建后固定,不能 push/pop。如果需要动态扩展,需要用新的 TypedArray 替换旧的(或提前分配足够大的空间)。

底层源码:V8 是如何实现数组的?

V8 的数组对象由多个部分组成(详见 V8 源码:src/objects/js-array.h):
这段代码做了 3 件事:(1)JSArray 继承自 JSObject,elements 字段存储数组元素;(2)elements 的具体类型取决于元素种类——PACKED_SMI 用 FixedArray 存储 Smi,PACKED_DOUBLE 用 FixedDoubleArray 存储浮点数;(3)Holey 数组用 TheHole 哨兵值标记空洞。 V8 在数组扩容时检查稀疏度(详见 V8 源码:src/objects/js-objects.cc):
这段代码做了 3 件事:(1)V8 在数组扩容时检查稀疏度;(2)如果新索引远大于当前长度且稀疏度低于 25%,切换为字典模式;(3)字典模式用 NumberDictionary(哈希表)存储,只占用实际元素的内存。

十、及格回答 vs 满分回答

问题:“V8 是怎么优化数组的?”

及格回答(60-70分):
“V8 用连续内存存储数组,同类型的数组更快。有空洞的数组性能差,稀疏数组会变成哈希表。”
问题出在:知道结论但不理解机制。 满分回答(90-100分):
V8 对数组的优化核心是**元素种类(Elements Kind)**系统。V8 把数组按元素类型和密集程度细分为 PACKED_SMI、PACKED_DOUBLE、PACKED_ELEMENTS、HOLEY_SMI 等多种种类。PACKED 类型的数组用连续内存存储(类似 C 数组),O(1) 偏移访问;HOLEY 类型需要额外的空洞检查;极度稀疏的数组会退化为字典模式(哈希表)。[1]
关键优化点是 SMI(Small Integer)。V8 把小整数直接编码在指针中(最低位为 0),不需要堆分配。PACKED_SMI 数组的元素全是 SMI,内存布局是纯粹的连续 32 位整数——没有指针解引用、没有 GC 压力、可以被 CPU 缓存友好地预取。这使得 PACKED_SMI 数组的遍历速度接近 C++ 的 std::vector<int32_t>。[2]
元素种类只会”降级”不会”升级”:PACKED_SMI → PACKED_DOUBLE → PACKED_ELEMENTS → HOLEY → DICTIONARY。一旦放入一个浮点数,整个数组就从 PACKED_SMI 降级为 PACKED_DOUBLE,即使后来全部改回整数也不会恢复。所以实际开发中,保持数组元素类型一致、避免空洞、避免稀疏赋值是写出高性能代码的关键。[2]
TypedArray(如 Float64Array、Int32Array)是数值密集场景的最优选择——内存完全连续、类型固定、无 GC 压力、可直接传给 WebGL/AudioWorklet。在图像处理、音频处理、科学计算等场景,TypedArray 通常比普通 Array 更快、更省内存。

常见追问

答题要点: 不能只说”一个是动态的一个是静态的”,要从内存布局分析。高分回答: “C++ 数组是纯连续内存块,类型在编译时确定,没有额外开销。 V8 数组本质上是 JSObject,由 Hidden Class + length + elements 指针组成。elements 指向的内存才是实际存储元素的地方。最大的区别是 V8 数组需要元素种类检查。C++ 的 int[] 保证全是 int,编译器直接生成整数操作指令。V8 的数组在运行时才知道元素种类,TurboFan 需要根据类型反馈做特化优化——如果假设被违反(比如放入浮点数),还要反优化。但在 PACKED_SMI 状态下,V8 数组的内存布局(连续 32 位整数)和 C++ 的 int32_t[] 几乎一样,性能差距很小。”
答题要点: 现代 V8 中差距已经很小,但仍有细微差别。高分回答: “在现代 V8 中,forEach 已经可以被内联优化,与 for 的差距远小于早期版本。但 for 循环仍然略快,原因是:
  1. forEach 需要创建回调函数的执行上下文(虽然 TurboFan 可以内联,但有内联限制)
  2. forEach 需要检查回调是否修改了数组长度
  3. for 循环的 i++ 和 i < length 是最简单的机器指令
答题要点: 要给出完整的降级路径和具体触发条件。高分回答: “V8 的元素种类降级路径是单向的:
降级触发条件:
  • PACKED → HOLEY:数组中出现空洞(delete、new Array(n)、跳跃赋值)
  • SMI → DOUBLE:数组中出现浮点数(3.14)
  • DOUBLE → ELEMENTS:数组中出现非数字值('string'、{})
  • HOLEY → DICTIONARY:极度稀疏(arr[1000000] = 1)
一旦降级就无法恢复。所以最佳实践是:创建数组时就确定好类型,后续不要混入不同类型。”
答题要点: 给出具体的使用场景和性能对比数据。高分回答: “TypedArray 适用于以下场景:
  1. 大量数值计算:图像像素处理、音频采样、物理模拟
  2. 二进制数据:WebSocket 二进制传输、文件解析
  3. GPU 交互:WebGL 顶点数据、WebGPU Buffer
  4. 高性能需求:需要 SIMD 优化的计算密集型任务
性能对比:
  • 内存:TypedArray 元素是原始值,没有指针和对象头开销
  • 访问:TypedArray 不需要运行时类型检查
  • GC:TypedArray 无 GC 压力(元素是原始值,不是堆对象)
答题要点: 从 V8 优化角度分析,ArrayLike 没有元素种类优化。高分回答: “ArrayLike 是一个具有 length 属性和数字索引的对象,但不是真正的 Array。常见的 ArrayLike 包括 arguments、NodeList、TypedArray。关键区别在 V8 层面:
  • Array 有元素种类优化、连续内存、TurboFan 特化
  • ArrayLike(普通对象)的数字索引不属于 elements 的快速路径,访问要按通用属性查找处理
答题要点: 介绍 d8 工具和 %HaveSameMap 内置函数。高分回答: “V8 提供了调试工具来检查数组的元素种类:
  1. d8 的 --trace-maps 参数:
  1. %HaveSameMap 内置函数(需要 --allow-natives-syntax):
  1. Chrome DevTools: 在 Console 中 %DebugPrint(arr) 可以看到数组的 elements kind。
  2. 经验法则:如果一个数组的遍历性能突然下降,很可能是退化了。”
答题要点: 不只是单个数组慢,会影响整个函数的 JIT 优化。高分回答: “当一个函数内出现稀疏数组时,TurboFan 不只是对这个数组不优化——它可能放弃对整个函数的优化。原因是 TurboFan 的优化是基于类型反馈的。如果一个数组操作的反馈是 DICTIONARY(字典模式),TurboFan 无法为它生成高效的机器码。更严重的是,如果一个函数的不同调用中,同一个数组操作有时是 PACKED 有时是 DICTIONARY,TurboFan 会认为类型不稳定,放弃优化整个函数。
所以最佳实践是:不要在同一个函数中对同一个数组做不同模式的操作。”
答题要点: V8 用 TimSort 算法,并对特殊类型做了优化。高分回答: “V8 的 Array.prototype.sort() 使用 TimSort 算法(和 Python 的排序相同),这是一种稳定的、自适应的混合排序算法。V8 的优化细节:
  1. 不传比较函数时:引擎按元素类型走内部比较路径,不需要进入 JS 回调
  2. PACKED_DOUBLE 数组:用专门的 NaN 处理逻辑,避免 JS 层面的比较
  3. 传比较函数时:每次比较都要调用用户回调,开销最大
注意:V8 的 sort 在 Chrome 70+ 从 QuickSort 换成了 TimSort,因为 TimSort 对部分有序的数组更快(O(n) 最好情况),且是稳定排序。“

延伸阅读与引用参考