深入V8 - js数组的内存是如何分配的

5,332 阅读21分钟

画饼

我知道你看到怎么小的滚动条可能会放弃阅读,因为它确实太长了,所以我决定给你画饼。看完这篇文章你将会知道下面的情况是为什么

let arr = new Array(100).fill(1)
arr[0] = 1000
arr[1] = 0.001 // 这一行代码居然会让arr占用的内存翻一倍

arr[1] = 1 // 这样并无法挽回增加的一倍内存

arr[1] = "1" // 但是这样却会让arr的内存变为原来的大小

是不是特别好奇呢?那就老老实实的留下来慢慢看吧。😁

不重要的话写在前面

昨天没事研究了一下数组,在另一篇文章 《图解数据结构js篇-数组结构

我花了两天时间看了近20篇有关V8数组实现的文章和写了不少于50个案例近百张内存快照来学习 V8 中数组的内存存储结构。我思考了很久 "如何才能把它讲的通俗易懂",我觉得这是一个很有挑战的问题。

首先看到第一个问题就是

Javascript的Array中的元素在内存上的分布是连续的吗?为什么?

咋一看,我心里想的答案是:肯定是啊,数组在内存空间分配的内存不就是连续的嘛?为什么要问这样的问题呢?答案真的这么简单吗?我开始不坚定自己的想法,毕竟我也没有深究过JavaScript的Array底层是怎么实现的。然后我就带着问题,开始我的 Google 之旅...好家伙,果然有问题!

什么是数组

在讨论JS数组之前,我们先回顾一下数据结构中数组的定义:如果你不了解数组结构,可以看看 图解数据结构js篇-数组结构

在计算机科学中,数组数据结构(英语:array data structure),简称数组(英语:Array),是由相同类型的元素(element)的集合所组成的数据结构,分配一块连续的内存来存储。利用元素的索引(index)可以计算出该元素对应的存储地址。引自维基百科\

由维基百科给出的数组的定义可知,数组满足:

  1. 数组中所有元素是同种类型的元素(同一类型元素所需存储空间大小一致,所以我们可以很方便的利用元素的索引来计算出元素所在的位置);
  2. 分配一块连续的内存存储(固定长度、连续)。

回想一下JS数组,你就会发现JS数组有点“特殊”...

js的数组有点特殊

写过JS代码的童鞋对下面这段代码肯定不陌生:

let arr = [1, true, "月夕正在找工作", {name:"月夕", age:21}] // 1.数组中可以存储任何元素,并且可以各不相同
console.log(arr.length) // 4

arr.push("跪求内推") // 数组可以随意追加任何元素,长度可以更改
console.log(arr.length) // 5  

从上面的代码我可以直观的看到,JS数组与数据结构中的数组相比有些特殊:

  1. 同一个JS数组的元素可以是不同的数据类型,那我们肯定没法固定长度为每个元素分配空间,那么这样的数组也就没办法通过元素的索引来计算出某个元素对应的存储地址了。
  2. JS数组可以任意更改大小。看到这里,你还会觉得JS数组在内存中分配的空间是连续的吗?如果是连续的,如果我们无限制的增加数组的大小,怎么保证后面的区域也是可以分配给数组的呢?

文章很长,且静下心听我慢慢道来。

从V8看数组

引出了上面的几个问题,我们需要来系统的认识一下 JavaScript 中的数组了。这里会提到一些有关 V8 中对象的内容(包括,elementspropertiesHidden ClassDescriptor Array快属性慢属性),如果不了解的话建议先看看 V8 中的快慢属性与快慢数组 这篇文章。

V8 中的快慢属性

我们有这样一段代码

function Foo() {
  this[100] = 'test-100'
  this[1] = 'test-1'
  this["B"] = 'foo-B'
  this[50] = 'test-50'
  this[9] = 'test-9'
  this[8] = 'test-8'
  this[3] = 'test-3'
  this[5] = 'test-5'
  this["A"] = 'foo-A'
  this["C"] = 'foo-C'
}

const foo = new Foo()

for (const key in foo) {
  console.log(`key:${key}, value:${foo[key]}`)
}
// key:1, value:test-1
// key:3, value:test-3
// key:5, value:test-5
// key:8, value:test-8
// key:9, value:test-9
// key:50, value:test-50
// key:100, value:test-100
// key:B, value:foo-B
// key:A, value:foo-A
// key:C, value:foo-C

我们创建一个 Foo 实例 foo,foo 中有 10 个属性,我们遍历该对象并依次打印,可以发现打印的顺序与设置的顺序并不一致。细心一点观察可以发现,对于整数型的 key 值,会从小到大遍历,对于非整数型的 key 值,会按照设置的先后顺序遍历。在 V8 中,前后者分别被称为 数组索引属性Array-indexed Properties)和 命名属性Named Properties),遍历时一般会先遍历前者。前后两者在底层存储在两个单独的数据结构中,分别用 elements 和 properties 两个指针指向它们,如下图:

image.png

之所以存储在两个数据结构中,是为了使不同情况下对属性的增删改查都相对高效。

image.png

咦?没有 properties 啊?实际上,V8 有一种策略:如果命名属性少于等于 10 个时,命名属性会直接存储到对象本身,而无需先通过 properties 指针查询,再获取对应 key 的值,省去中间的一步,从而提升了查找属性的效率。直接存储到对象本身的属性被称为 对象内属性In-object Properties)。对象内属性与 properties、elements 处于同一层级。

为了印证这个说法,将代码替换为如下内容,重新打 snapshot。可以看到超出 10 个的部分 property10 和 property11 存储在 properties 中,这部分命名属性称为普通属性:

function Foo(properties, elements) {
  //添加可索引属性
  for (let i = 0; i < elements; i++) {
    this[i] = `element${i}`
  }

  //添加常规属性
  for (let i = 0; i < properties; i++) {
    const prop = `property${i}`
    this[prop] = prop
  }
}
const foo = new Foo(12, 12)

image.png

至此,我们已经对命名属性、数组索引属性与对象内属性有一个基本了解。

快数组与慢数组

类比快慢属性,再看我们上一节中举的例子:

const LIMIT = 6 * 1024 * 1024;
let arr = new Array(LIMIT); // 快数组
arr[arr.length+1026] = 1; // 快数组转为慢数组

这个例子中,在行 2 声明完毕后 arr 是一个空数组,但在行 3 马上又定义索引 arr.length+1026 处值为 1,此时如果为 arr 创建一个长度为 arr.length+1026+1 的数组连续内存来存储这样的稀疏数据将会非常占用内存,为了应对这种情况,V8 会将数组降级为慢数组,创建一个字典来存储「键、值、描述符」(key、value、descriptor) 三元组。当使用 Object.defineProperty 自定义 key、value、descriptor 时,V8 都会使用慢属性,对应到数组中就是慢数组。这样说还是太浅显了,到底什么是快数组什么是慢数组,什么时候快数组会转换为慢数组,转换规则是什么?

// v8/src/objects/js-array.h
// The JSArray describes JavaScript Arrays
//  Such an array can be in one of two modes:
//    - fast, backing storage is a FixedArray and length <= elements.length();
//       Please note: push and pop can be used to grow and shrink the array.
//    - slow, backing storage is a HashTable with numbers as keys.
class JSArray : public JSObject {
 public:
  // [length]: The length property.
  DECL_ACCESSORS(length, Object)
  // ...
}

通过在 V8 数组的定义可以了解到,数组可以处于两种模式:

  1. Fast模式的存储结构是 FixedArray 并且长度小于等于elements.length,可以通过 push 和 pop 增加和缩小数组;
  2. slow模式的存储结构是一个以数字为键的 HashTable.

快数组 FixedArray

  1. 快数组是一种线性的存储方式,内部存储是连续的内存(新创建的空数组,默认的存储方式是快数组);
  2. 快数组长度是可变的,可以根据元素的增加和删除来动态调整存储空间大小,内部是通过扩容收缩机制实现;

从第一点的线性、连续来看我可以确定 FixedArray 是符合 数据结构中对数组结构的定义的。

图解数据结构js篇-数组结构

扩容机制

// v8/src/objects/js-array.h  105
// Number of element slots to pre-allocate for an empty array. (默认的空数组预分配的大小为 4)
static const int kPreallocatedArrayElements = 4;

// v8/src/objects/js-objects.h  537
static const uint32_t kMinAddedElementsCapacity = 16;

// v8/src/objects/js-objects.h  540 
// Computes the new capacity when expanding the elements of a JSObject. (计算扩充后的容量)
static uint32_t NewElementsCapacity(uint32_t old_capacity) {
    // (old_capacity + 50%) + kMinAddedElementsCapacity 
    // (扩容的公式: new_capacity = old_capacity + old_capacity / 2 + 16)
    return old_capacity + (old_capacity >> 1) + kMinAddedElementsCapacity;
}

// v8/src/code-stub-assembler.cc  5137 
Node* CodeStubAssembler::CalculateNewElementsCapacity(Node* old_capacity,
                                                      ParameterMode mode) {
  CSA_SLOW_ASSERT(this, MatchesParameterMode(old_capacity, mode));
  Node* half_old_capacity = WordOrSmiShr(old_capacity, 1, mode);
  Node* new_capacity = IntPtrOrSmiAdd(half_old_capacity, old_capacity, mode);
  Node* padding =
      IntPtrOrSmiConstant(JSObject::kMinAddedElementsCapacity, mode);
  return IntPtrOrSmiAdd(new_capacity, padding, mode);
}

// v8/src/code-stub-assembler.cc  5202  
// Allocate the new backing store.
Node* new_elements = AllocateFixedArray(to_kind, new_capacity, mode);
// Copy the elements from the old elements store to the new.
// The size-check above guarantees that the |new_elements| is allocated
// in new space so we can skip the write barrier.
CopyFixedArrayElements(from_kind, elements, to_kind, new_elements, capacity,
                     new_capacity, SKIP_WRITE_BARRIER, mode);
StoreObjectField(object, JSObject::kElementsOffset, new_elements);

默认的空数组预分配的大小为4,当数组进行扩充操作例如 push 时,数组的内存若不够则将进行扩容,最小的扩容容量为16,扩容的公式为 new_capacity = old_capacity + old_capacity / 2 + 16,即申请一块原容量 1.5 倍加 16 这样大小的内存,将原数据拷贝到新内存,然后 length + 1,并返回 length。

例如当前数组占用的内存长度为 4 ,那么其扩容后的内存长度就为22

4 + 4/2 + 16 = 22

我们可以试一试

对于空数组,其数组长度为4,每一个元素的大小为4
其elements大小 = 数组长度*元素大小 + 8
至于为什么需要加8我也不太清楚,我是看了上百张内存快照发现的,如果有知道的大佬还请告知

function A(){
    let arr = new Array(4).fill(1)
    this.arr =arr
}
let a = new A()

我们先定义一个占用4个元素空间的数组,然后对其初始化为1

new Array(len) 或者 Array(len) 方式分配的数组其内存空间个数就是 len 个。 [] 方式分配的数组内存占用4,[1,2,3,...]方式分配的数组占用的内存个数和数组长度一致

此时数组再内存中占用的空间为

4*4 + 8 = 24

我们可以打一个 snapshot 进行观察,结果符合我们的期望

image.png

当我们再往其中push一个元素时,由于当前数组内存中已经全部占满,所以会触发扩容机制重新创建一个更大的数组。通过扩容机制技术出来扩容后的长度为:

4 + 4/2 + 16 = 22

所以其占用的内存空间应该为:

22 * 4 + 8 = 96

function A(){
    let arr = new Array(4).fill(1)
+   arr.push(1) // 触发扩容机制
    this.arr =arr
}
let a = new A()

image.png

结果发现占用的内存为 100,也就是说比我们上面计算的结果 96 多扩容了一位,变成了 23 位。 当设置 arr[23] = 1 发现被扩容为了 52 位,与计算的结果(23 + 11 + 16)=50又多了两位

于是我觉得 len+1 + (len+1)/2 + 16 的扩容公式是更加合理,但是 V8 的源码中就是 len + len/2 + 16 这又让我觉得我的浏览器 V8 引擎是不是被修改过了,哈哈哈

于是我对这个公式的下两个扩容结果继续验证

得到的下两个扩容位置果然是 95 和 160

如果有人知道为什么会与 V8源码中的扩容机制不同吗?请评论区留言

收缩机制

// v8/src/elements.cc  783
// 如果容量大于等于 length * 2 + 16,则进行收缩容量调整
if (2 * length + JSObject::kMinAddedElementsCapacity <= capacity) {
    // If more than half the elements won't be used, trim the array.
    // Do not trim from short arrays to prevent frequent trimming on
    // repeated pop operations.
    // Leave some space to allow for subsequent push operations.
    int elements_to_trim = length + 1 == old_length
                                ? (capacity - length) / 2
                                : capacity - length;
    isolate->heap()->RightTrimFixedArray(*backing_store, elements_to_trim);
    // Fill the non-trimmed elements with holes.
    BackingStore::cast(*backing_store)
        ->FillWithHoles(length,
                        std::min(old_length, capacity - elements_to_trim));
} else {
    // Otherwise, fill the unused tail with holes.(否则用 HOLES 对象填充未被初始化的位置)
    BackingStore::cast(*backing_store)->FillWithHoles(length, old_length);
}

当数组执行 pop 操作时,会判断 pop 后数组的容量,是否需要进行减容,如果容量大于等于 length * 2 + 16,则进行收缩容量调整,否则用 HOLES 对象填充未被初始化的位置elements_to_trim 是要裁剪的大小,需要根据 length + 1 和 old_length 判断是将空出的空间全部收缩掉还是只收缩一半

这个我倒是没有验证(懒),知道就行了,相差应该也不会太大吧。哈哈哈

慢数组HashTable

const arr = [1, 2, 3]
arr[1999] = 1999
// arr 会如何存储?

这个例子中,在行 1 声明完毕后 arr 是一个全填充的数组,但在行 2 马上又定义索引 1999 处值为 1999,此时如果为 arr 创建一个长度为 2000 的完整数组来存储这样的稀疏数据将会非常占用内存,为了应对这种情况,V8 会将数组降级为慢数组,创建一个字典来存储「键、值、描述符」key、value、descriptor) 三元组。这就是 Object.defineProperty(object, key, descriptor) API 同样会做的事情。

鉴于我们没有办法在 JavaScript 的 API 层面让 V8 找到 HiddenClass 并存储对应的 descriptor 信息,所以当使用 Object.defineProperty 自定义 key、value、descriptor 时,V8 都会使用慢属性,对应到数组中就是慢数组。

Object.defineProperty 是 Vue 2 的核心 API,当对象或数组很庞大时,不可避免地导致访问速度下降,这是底层原理决定的。

快数组何时转化为慢数组

V8 的源码在这看 source.chromium.org/chromium/ch…

// 摘自:`src/objects/js-objects.h`
static const uint32_t kMaxGap = 1024;

// 摘自:`src/objects/dictionary.h`
// JSObjects prefer dictionary elements if the dictionary saves this much
// memory compared to a fast elements backing store.
static const uint32_t kPreferFastElementsSizeFactor = 3;

// ...
class NumberDictionaryShape : public NumberDictionaryBaseShape {
 public:
  static const int kPrefixSize = 1;
  static const int kEntrySize = 3;
};


// 摘自:`src/objects/js-objects-inl.h`
// If the fast-case backing storage takes up much more memory than a dictionary
// backing storage would, the object should have slow elements.
// static
static inline bool Shoul``dConvertToSlowElements(uint32_t used_elements,
                                               uint32_t new_capacity) {
  uint32_t size_threshold = NumberDictionary::kPreferFastElementsSizeFactor *
                            NumberDictionary::ComputeCapacity (used_elements) *
                            NumberDictionary::kEntrySize;
  return size_threshold <= new_capacity;
}

static inline bool ShouldConvertToSlowElements(JSObject object,
                                               uint32_t capacity,
                                               uint32_t index,
                                               uint32_t* new_capacity) {
  STATIC_ASSERT(JSObject::kMaxUncheckedOldFastElementsLength <=
                JSObject::kMaxUncheckedFastElementsLength);
  if (index < capacity) {
    *new_capacity = capacity;
    return false;
  }
  if (index - capacity >= JSObject::kMaxGap) return true;
  *new_capacity = JSObject::NewElementsCapacity(index + 1);
  DCHECK_LT(index, *new_capacity);
  // TODO(ulan): Check if it works with young large objects.
  if (*new_capacity <= JSObject::kMaxUncheckedOldFastElementsLength ||
      (*new_capacity <= JSObject::kMaxUncheckedFastElementsLength &&
       ObjectInYoungGeneration(object))) {
    return false;
  }
  return ShouldConvertToSlowElements(object.GetFastElementsUsage(),
                                     *new_capacity);
}

通过看源码得出的结论是以下两种情况会将快数组转换为慢数组

  • 如果快数组扩容后的容量是原来的 9 倍以上,意味着它比 HashTable 形式存储占用更大的内存,快数组会转换为慢数组
  • 如果快数组新增的索引与原来最大索引的差值大于 1024,快数组会被转换会慢数组

所以,前面的例子:

const arr = [1, 2, 3]
arr[1999] = 1999

1999 - 2 > 1024,arr 从快数组转换为哈希形式存储的慢数组。

但是对于第一种情况我的测试结果视乎并不一样

let arr = new Array(4).fill(1)
arr[500] = 1

如果按照源码中来看, arr 扩容后的长度为 22 或者 23, 500 远远大于 23*9。但是依然没有被转化为慢数组,如果有知道为什么的大佬欢迎评论。

我是怎么看它是快数组还是慢数组的? 通过内存快照查看elements占用的内存就能发现了,后面会介绍。

慢数组何时转化为快数组

V8的源码中表明,如果一个慢数组没有被明确标识为不可转化为快数组的情况下

  • 当慢数组转换成快数组能节省不少于 50%  的空间时,才会将其转换。

源码我就不贴了

这个视乎也没有模拟超过

let arr = []
arr[0] = 1
arr[2] = 1
arr[3] = 1
arr[10000] = 1
// 120
arr[1] = 1
delete arr[10000]

代码中arr始终是一个慢数组,如果其转化为 快数组, 其 elements 的大小应该只有 24。 明显比120占用的空间减少不止一半,但是它视乎并没有因此被转化为慢数组。让我很疑惑,有知道的欢迎评论。

V8对数字的分类

ECMAScript 中的 Number

相信很多人都会被问到这样一个面试的问题

0.2+0.1为什么不等于0.3?

我在文章 0.1+0.2!=0.3?揭秘其中的奥妙。 讲的很详细,这里就不做过多解释。不然写到后天也写不完啊,呜呜~~

那么,这里就有一个问题了

js中是64位来表示数字,那么在V8引擎层面是否也是使用64位来表示数字呢?

为什么会这么问?
因为我们知道,数字在内存中的表示可以有多种(如下),而64位,显然是最慢的

representationbits
8位二进制补码0010 1001
32位二进制补码0000 0000 0000 0000 0000 0000 0010 1010
二进制编码的十进数码0100 0010
32位 IEEE-754 单精度浮点0100 0010 0010 1000 0000 0000 0000 0000
64位 IEEE-754 双精度浮点0100 0000 0100 0101 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000 0000

我们在使用js这门语言来编程的时候,其实使用的大部分是32位就可以表示的数,因此,引擎做了这样一个优化

ECMAScript 标准约定number数字需要被当成 64 位双精度浮点数处理,但事实上,一直使用 64 位去存储任何数字实际是非常低效的,所以 JavaScript 引擎并不总会使用 64 位去存储数字,引擎在内部采用其他内存表示方式(如 32 位),只要保证数字外部所有能被监测到的特性对齐 64 位的表现就行。

V8中的Smi和HeapNumber

不仅仅是使用32位来表示数字那么简单,V8还对数字进行了分类,将数字分为了Smi 和 HeapNumber

注意:这个仅仅是引擎层面的处理,js内部只认识数字,不区分整数和浮点数

//32位平台是 2的30次方
//64位平台是 2的31次方
 -Infinity // HeapNumber
-(2**30)-1 // HeapNumber
  -(2**30) // Smi
       -42 // Smi
        -0 // HeapNumber
         0 // Smi
       4.2 // HeapNumber
        42 // Smi
   2**30-1 // Smi
     2**30 // HeapNumber
  Infinity // HeapNumber
       NaN // HeapNumber

可以从上面看出,Smi代表的是小整数,而HeapNumber则代表了一些浮点数以及无法用32位表示的数,比如NaN,Infinity,-0

为什么要区分这两种?
原因还是之前说的,因为小整数在我们的编码过程中太常见了,所以,V8专门把它领出来,并且对其进行了优化操作,它可以进行快速整型操作

那么大概是怎么优化处理呢?
如图所示我们声明了一个对象,对象的x值是一个Smi,而y值是一个HeapNumber,v8给HeapNumber专门分配了一个内存对象来存储值,并将o.y的对象指针指向该内存实体。
你可能不知道的V8数组优化_第1张图片

当我们更新他们的值的时候,Smi的值会原地更新,而HeapNumber由于它不可变的特性,V8会开辟一个新的内存实体用来储存新的值,然后将o.y的对象指针指向该内存实体。
你可能不知道的V8数组优化_第2张图片

如果我们需要频繁更新HeapNumber的值,执行效率会比Smi慢得多:
在这个短暂的循环中,引擎不得不创建 6 个HeapNumber实例,0.11.12.13.14.15.1,而等到循环结束,其中 5 个实例都会成为垃圾。
你可能不知道的V8数组优化_第3张图片

为了防止这个问题,V8 提供了一种优化方式去原地更新非Smi的值:当一个数字内存区域拥有一个非Smi范围内的数值时,V8 会将这块区域标志为Double区域,并会为其分配一个用 64 位浮点表示的MutableHeapNumber实例。
你可能不知道的V8数组优化_第4张图片

此后当你再次更新这块区域,V8 就不再需要创建一个新的HeapNumber实例,而可以直接在MutableNumber实例中进行更新了。
你可能不知道的V8数组优化_第5张图片

前面说到,HeapNumberMutableNumber都是使用指针引用的方式指向内存实体,而MutableNumber是可变的,如果此时你将属于MutableNumber的值o.x赋值给其他变量y,你一定不希望你下次改变o.x时,y也跟着改变。 为了防止这种情况,当o.x被共享时,o.x内的MutableHeapNumber需要被重新封装成HeapNumber传递给y
你可能不知道的V8数组优化_第6张图片

数组的全填充 和 带孔

const o = ['a', 'b', 'c']
console.log(o[1])          // 'b'.

delete o[1]
console.log(o[1])          // undefined
o.__proto__ = { 1: 'B' }
console.log(o[0])          // 'a'.
console.log(o[1])          // 'B'. 但如何确定要访问原型链?
console.log(o[2])          // 'c'.
console.log(o[3])          // undefined

如果一个数组中所有位置均有值,我们称之为全填充Packed)数组,若某些位置在初始化时未定义(如 const arr = [1, , 3] 中的 arr[1]),或定义后被删除(delete,如上述例子),称之为带孔Holey)数组。

该例子在 V8 的访问可以通过下图解释:

image.png

一开始数组 o 是 packed 的,所以访问 o[1] 时可以直接获取值,而不需要访问原型。而行 4:delete o[1] 为数组引入了一个孔洞(the_hole),用于标记不存在的属性,同时又行 6 为 o 定义了原型上的 1 属性,当再次获取 o[1] 时会穿孔进而继续往原型链上查询。原型链上的查询是昂贵的,可以根据是否有 the_hole 来降低这部分查询开销。

V8对数组elements的分类

V8 目前区分了 21 种不同的 elements 类型,每一种都可能有一堆优化,常见有下面6种

  • PACKED_SMI_ELEMENTS 种类: 代表数组中所有元素都是由 SMI 类型的全填充数组
  • HOLY_SMI_ELEMENTS 种类: 代表数组中元素都是由 SMI 类型的带孔数组
  • PACKED_DOUBLE_ELEMENTS 种类: 代表数组中的元素由 HeapNumber 类型的全填充数组
  • HOLY_DOUBLE_ELEMENTS 种类: 代表数组中元素由 HeapNumber 类型的带孔数组
  • PACKED_ELEMENTS 种类: 用于元素不能表示为SMIDOUBLE类型的全填充数组
  • HOLY_ELEMENTS 种类: 用于元素不能表示为SMIDOUBLE类型的带孔数组

当我们定义一个数组时:

var a = [0,1,2]; // PACKED_SMI_ELEMENTS

JS engine给它的种类是 PACKED_SMI_ELEMENTS,代表数组中的元素类型都是 SMI (small Integers,小整型);

a.push(3.45);      // PACKED_DOUBLE_ELEMETNS

然后,为a中添加一个浮点型元素 3.45,那么数组a在JS engine中的种类就变成了 PACKED_DOUBLE_ELEMENTS ,用于浮点数和不能表示为SMI的整数;

a.push("a");       // PACKED_ELEMENTS

最后,添加一个字符串类型“a”,数组a在JS engine中的种类就转换成了 PACKED_ELEMENTS ,用于不能表示为 SMIDOUBLE 的值。(注意,这里转换成 PACKED_ELEMENTS 的时候,因为要转换数组内元素的类型,浮点型3.45,会导致额外的装箱操作)。

下图中表示了数组a的种类变化。

image.png

那么如果我将数组a的第4位元素重新设成浮点型,a的种类会不会变回 PACKED_DOUBLE_ELEMETNS 呢?

image.png

答案是,不能。数组种类有着严格的等级制度,就像从跳楼一样,可以从10层到9层,但是没办法从9层回到10层,因为武当登云梯根本不存在。

继续,我们给数组赋值,这个时候,我们给它的第6位赋值为6;

a[6] = 6;

可以在浏览器控制台上看到数组a的值;

console.log(a) // [0, 1, 2, 3.45, 'a', empty, 6]

赋值操作跳过了下标5,那么对应下标为5的值就变成了一个 , 所以这个时候数组的种类变成了 HOLY_ELEMENTS,通过图示,更直观的表达出数组内容的变化过程:

image.png

那这个时候我们给第五位赋值为1,能不能让数组变回 PACKED_ELEMENTS 呢?

答案是: 武当登云梯根本不存在

image.png

js数组如何存储不同数据类型

这个问题,上面已经解释的七七八八了,会根据数据类型的不同来调整数组elements的类型。例如

let arr = new Array(100).fill(1) // PACKED_SMI_ELEMENTS  elements大小为408字节

arr[0] = 0.1 // PACKED_DOUBLE_ELEMETNS  elements大小为808字节

当数组 elements 类型从 PACKED_SMI_ELEMENTS 变为 PACKED_DOUBLE_ELEMETNS 时,数组也会重新分配空间,PACKED_DOUBLE_ELEMETNS 类型的数组每一个元素占用8个字节,PACKED_SMI_ELEMENTS 只占用4个字节。所以这里 arr 的内存占用空间还会翻倍。

let a = [1, "hello", true, function () {
  return 1;
}];

当出现其他不是数字的类型时变为 PACKED_ELEMENTS 类型,但是 PACKED_ELEMENTS 中每个元素占用的字节数取决于是否存在 DOUBLE 数据,如果存在则占用8字节,不存在则占用4字节

因为 除浮点数以外,其他数据再数组中的存储都是存储内存地址,内存地址只占用4字节。

image.png

注意: 只有数组的 elements 类型变化时,才会试图调整每一个元素占用的字节数

例如:

let arr = new Array(100).fill(1) // PACKED_SMI_ELEMENTS 占用4字节
arr[0] = 0.1 // PACKED_DOUBLE_ELEMETNS 占用8字节
arr[0] = 1 // PACKED_DOUBLE_ELEMETNS elements类型无变化,还是占用8字节

但是下面这样呢?

let arr = new Array(100).fill(1) // PACKED_SMI_ELEMENTS 占用4字节
arr[0] = 0.1 // PACKED_DOUBLE_ELEMETNS 占用8字节
arr[0] = {} // PACKED_ELEMETNS 占用4字节,因为elements发生变化且元素中没有 DOUBLE 数据项

所以只有数组的 elements 类型变化时,才会试图调整每一个元素占用的字节数

总结

完了完了太多了,我从早上9点写到下午5点才写完。

数组分为慢数组和快数组,慢数组索引线性连续存储,慢数组使用HashTable存储。

数组的 elements 被分为许多类型,其每一个元素占用的内存大小取决于 elements 类型和元素中是否有浮点数。如果有浮点数或者 elements 类型为 PACKED_ELEMETNS,那么每个占8字节,否则4字节

除数字以外的其他类型数据都会以指针的方式存储再数字中,所以除了一些 SMI 不能表示的数字,其他类型和 SMI 能表示的值都可以由32位(4字节)表示。

数组的扩容和收缩机制:如果给数组赋值超出容量的下标会触发扩容机制(不能相差太大,不然会转换位慢数组);如果容量大于等于 length * 2 + 16,会触发收缩机制。

快慢数组的性能很存储格式以及何时被转换

遗留的一些问题:

文章中有些地方的实际测试结果与 V8 源码并不一致,记录在此。欢迎各位大佬留言。

测试环境均为 Google Chrome 93.0.4577.63浏览器

问题1:扩容机制中测试的结果为何于源码中描述的不太一样? 源码中扩容机制为

function NewElementsCapacity(len){
    return len + (len >> 2) + 16
}

而我的测试结果却是这样的

function NewElementsCapacity(len){
    len++
    return len + (len >> 2) + 16
}

问题2:快数组 arr = [1,2,3,4]; arr[500] = 1;完全满足 长度 > 扩容后长度*9。为何没有被转化为慢数组?

问题3:下面代码中慢数组为何没有被转化为快数组?什么样的例子才会被转化为快数组呢?

let arr = [] 
arr[0] = 1 
arr[2] = 1 
arr[3] = 1 
arr[10000] = 1 // elements 占用 120B 
arr[1] = 1 
delete arr[10000]

如果转化为一个长度为4的慢数组, elements 空间只有24B,节省的空间远大于50%。

问题4:为什么快数组的 elements 会比实际元素占的空间多8字节呢?

参考文章

《JS代码优化--Array数组》

《[译] V8中的数组类型》

《V8 中的快慢属性与快慢数组》

《JS变量的内存分配你了解多少?》

《JS V8 | 深入理解 JS 数组 —— JS Array在内存上分配的空间是连续的吗?》

《探究JS V8引擎下的“数组”底层实现》

《Chromium Code Search》

《你可能不知道的V8数组优化》