JavaScript 数组的内存存储方式

了解 JS 数组在堆栈中的存储机制和 V8 引擎的优化策略

问题

JavaScript 中数组是如何在内存中存储的?

解答

基本存储机制

JavaScript 数组不是以连续内存区域存储,而是采用哈希映射的形式。数组作为引用类型,其数据存储在堆内存中,栈内存中只保存指向堆内存的地址引用。

堆栈的区别

栈(Stack):连续的内存区域,按后进先出的顺序存放。主要存储基本类型的值(Boolean、Null、Undefined、Number、BigInt、String、Symbol)以及指向堆中对象的地址引用。栈的特点是访问速度快,但空间有限,生命周期短。

堆(Heap):不连续的内存区域,数据可以任意存放。主要存储对象和数组等引用类型。堆的特点是空间大,访问灵活,但速度相对较慢。

原始类型的不可变性

原始类型的值是不可变的。当修改一个原始类型变量时,实际上是在内存中创建了一个新值:

// 例子1:字符串不可变
var str = "abc";
str[0] = "d";
console.log(str); // "abc"

// 例子2:重新赋值创建新值
var str2 = "abc";
str2 = "dbc";
console.log(str2); // "dbc"

这样设计的原因:

  1. 安全性:避免多线程环境下的数据竞争和意外修改
  2. 共享性:值相同的变量可以共享同一内存区域,节省内存

V8 引擎中的对象存储

V8 将对象的属性分为两类存储:

  • Elements:存储数字索引属性(数组元素)
  • Properties:存储字符串属性

字符串属性的三种存储方式

1. In-object(对象内属性)

对象创建时预留的属性空间,直接存储在对象本身上,访问速度最快。通过字面量创建的空对象默认分配 4 个对象内属性空间。

2. Fast properties(快属性)

当对象内空间用完后,通过 HiddenClass(隐藏类)和 DescriptorArrays 来实现快速访问:

var a = {};
a.x = "value1";
a.y = "value2";
// 具有相同结构的对象共享同一个隐藏类
var b = {};
b.x = "value3";
b.y = "value4";
// a 和 b 拥有相同的隐藏类

隐藏类通过树状结构生成,每添加一个属性就创建一个新的隐藏类节点。相同结构的对象共享隐藏类,配合 Inline Caches(ICs)机制可以快速定位属性位置。

3. Slow properties(慢属性/字典模式)

当频繁增删属性时,维护隐藏类的开销过大,V8 会切换到字典模式。此时 DescriptorArrays 置空,属性直接以哈希表形式存储在 properties 数组中。

触发字典模式的情况:

  • 动态添加过多属性
  • 删除属性(使用 delete)
  • 删除非最后添加的属性(V8 6.0+)

数组的特殊存储

数组元素存储在 elements 数组中,按索引访问。V8 针对不同场景做了优化:

普通数组:连续存储,访问效率高

const arr = ['a', 'b', 'c'];

稀疏数组:当数组存在大量空位时,切换为字典模式以节省内存

const sparseArray = [];
sparseArray[9999] = 'foo'; // 切换为字典模式

带空洞的数组:缺失元素会通过原型链查找

关键点

  • JavaScript 数组作为引用类型存储在堆内存中,栈内存只保存引用地址
  • 原始类型的值不可变,修改时会创建新值而非改变原值
  • V8 将对象属性分为 Elements(数字索引)和 Properties(字符串属性)两类存储
  • 对象属性有三种存储模式:In-object(最快)、Fast properties(隐藏类)、Slow properties(字典模式)
  • 稀疏数组会自动切换为字典模式以节省内存空间