JavaScript中array的内存分配

864 阅读8分钟

JavaScript中array的内存分配

这是我在掘金上面发布的第一篇文章,文笔可能写的不是很好,如果有错误欢迎在评论区指正,接下来的内容是纯理论,我会从V8引擎运行的角度浅析一下JavaScript中数组,希望能让大家对JavaScript的数组以及运行的优化能有更深层次的理解

什么是数组

在讲JavaScript的数组之前我们先来了解一下常规理解下的array内存分配。

数组指的是在内存中用一串连续且固定大小的空间,来实现存放相同的数据结构。其中连续、固定的大小(长度)以及相同的数据类型是关键

  • 连续:各个数组元素在空间上是相邻的,线性的
  • 固定大小:数组的长度是和所占用的内存大小不可变的
  • 相同的数据结构类型:这就意味着每个元素的大小是固定的

综上所述:我们得出结论,在array中我们取第n个元素的值这个操作的时间复杂度为o(1)。取值时只需要将地址指针移动到 第一个元素的起始地址 + (n - 1) * 每一个元素的内存大小。

JavaScript中数组的特殊性

JavaScript中的数组是十分特殊的存在,针对上面三点我们来分析一下

先来看两段代码

const array = [100, 0.888, 'string', {obj: 'obj'}]
const array = [100, 0.888, 'string', {obj: 'obj'}]
console.log(array.length) // 4
array.push('bit')
console.log(array.length) // 5
array.pop()
console.log(array.length) // 4

第一段代码,我们在这个array中一次性塞入了三个不同的数据结构包括int整数、float浮点数、以及一个JavaScript对象,这在计算机的存储中每个元素所占用的内存空间是不一样的

第二段代码告诉我们:显然,数组的长度是可变的。自然,数组所占用的内存大小也是可变的。

最后,我们来看第一点连续,当数组长度固定的时候,内存中是可以分配一块连续的空间来存储数组的,可是当数组长度不固定时,比如发生了数组元素的增删那么就要重新开辟一块新的内存来存储新的数组,这时候开销是很大的,不过JavaScript中array具体的存储方式十分特殊,我们接下来详细讲讲。

从V8来看js数组底层的实现

为了满足数组存取速度以及能够存储多种数据结构的要求,js数组采用了两种数据存储方式,一种是快数组,可以理解为普通数组和其他语言的数组存储方式一致,另一种是慢数组是利用hashmap的形式,以key-value的形式存储数组元素,所以说js中的数组是一种特殊的对象

快数组

快数组和我们常规理解中的数组接近,内部存储是一段连续的内存空间(在JavaScript中新建空数组默认会采取快数组的存储方式),和常规数组不同的是,快数组是可以实现扩容的,通过扩容机制来实现这里我们简单提一下,深入了解可以看这篇文章

扩容机制: 当使用push往数组中推入一个元素的时候会触发v8的扩容机制,会开辟一个新的内存空间大小为new_capacity = old_capacity + old_capacity / 2 + 16,即新内存等于旧内存的1.5倍+16,并使数组length+1,多余的未填充空间用the_hole填充(可以先理解为‘空’用于标记未占用的空间,详细了解the_hole可以查看这篇文章 Fast properties in V8

// 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  
// 返回1.5旧容量+16
return old_capacity + (old_capacity >> 1) + kMinAddedElementsCapacity; 
}

缩容机制: 当数组元素数量减少时,比如使用了pop或者shift方法,如果数组容量此时大于两倍的length则进行缩容,使用 RightTrimFixedArray 函数,计算出需要释放的空间大小,做好标记,等待 GC 回收,如果数组容量小于 length 的 2 倍,则用the_hole(holes对象)填充。

// path:src/objects/elements.cc
 
// line:750
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.
  BackingStore::cast(*backing_store).FillWithHoles(length, old_length);
}

慢数组

我们先来看一个例子:

const array = [1, 2, 3]
array[8848] = 1

对于这样一个数组,如果我们采用传统的快数组存储方式,显然我们需要占用大量的空间,并在没有数组元素的地方填充the_hole。于是v8对这样的数组进行了优化把他转变成慢数组,使用HashTable的形式来存储该数组,也就是键值对,键是数组下标,而值是内存地址(这里很好体现了Array是一种特殊的对象,他继承JsObject)。使用这种模式可以使数组中元素在内存中零散分配,从而就不用开辟出连续的空间,只要维护HashTable即可,当然使用HashTable查找效率要比直接访问数组元素低,所以它被称为慢数组。

使用jsvu进行调试

const arr = [1, 2, 3];
arr[8848] = 1999;
%DebugPrint(arr);

image.png

我们看到这个数组的类型是NumberDictionary也就是数字字典

再来看快数组

const arr = [1, 2, 3];
%DebugPrint(arr);

image.png

可以发现存储方式变成了FixedArray也就是快数组

快慢数组的区别

  1. 内存的存储方式:快数组在内存中连续存储,慢数组使用HashTable(NumberDictionary),元素存储在内存是分散的
  2. 内存占用:由于快数组是连续的,会开辟大量空间进行存储,其中可能包括大量holes浪费空间,而HashTable在不需要连续的空间,不存在holes的情况相对来说比较节约空间
  3. 效率:快数组空间是连续的,所以遍历速度很快,但是慢数组需要HashTable查找key的位置相对来说会慢一点

快慢数组的转换

快数组 -> 慢数组

  1. 新容量 >= 3 * 扩容后的容量 * 2 ,会转变为慢数组。此时使用慢数组可以节约更多空间

  2. 快数组新增的索引与原来最大索引的差值大于 1024(也就是至少有了 1024 个空洞),会转变为慢数组。

V8源码


// 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 ShouldConvertToSlowElements(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);
}

ShouldConvertToSlowElements返回一个Boolean值来表示是否需要转换成慢数组 第一个函数表示情况一,第二个函数重载表示情况二

我们来验证第二种情况

const arr = [1];
arr[1025] = 2;

%DebugPrint(arr)

image.png

const arr = [1];
arr[1024] = 2;

%DebugPrint(arr)

image.png

当空洞达到1024的时候就会被转换成慢数组

至于第一种情况验证稍微有点难度,因为关联到了一些其他判断,比较复杂,这里暂时不做研究(因为我也还在解读源码)感兴趣的同学可以自己尝试一下

慢数组->快数组

当慢数组的元素可存放在快数组中且长度在 smi 之间且仅节省了50%的空间,则会转变为快数组

static bool ShouldConvertToFastElements(
  JSObject object,
  NumberDictionary dictionary,
  uint32_t index,
  uint32_t* new_capacity
) {
  // If properties with non-standard attributes or accessors were added, we
  // cannot go back to fast elements.
  if (dictionary.requires_slow_elements()) return false;

  // Adding a property with this index will require slow elements.
  if (index >= static_cast<uint32_t>(Smi::kMaxValue)) return false;

  if (object.IsJSArray()) {
    Object length = JSArray::cast(object).length();
    if (!length.IsSmi()) return false;
    *new_capacity = static_cast<uint32_t>(Smi::ToInt(length));
  } else if (object.IsJSArgumentsObject()) {
    return false;
  } else {
    *new_capacity = dictionary.max_number_key() + 1;
  }
  *new_capacity = std::max(index + 1, *new_capacity);

  uint32_t dictionary_size = static_cast<uint32_t>(dictionary.Capacity()) *
                             NumberDictionary::kEntrySize;

  // Turn fast if the dictionary only saves 50% space.
  return 2 * dictionary_size >= *new_capacity;
}

验证一下:

const array = [1, 2];
array[1030] = 1;
for (let i = 200; i < 1030; i++) {
    array[i] = i;
};

%DebugPrint(array)

image.png

一开始先让array变成慢数组然后再填充空洞使快数组节省50%的空间从而转变成了慢数组

js数组如何存储不同类型元素的呢

对于慢数组我们很好理解,因为是HashTable模式,那么对数组元素的大小以及内存是否连续没有要求自然对类型可以没有限制,但是对于快数组而言情况就复杂了很多

快数组常见有6种类型来存储不同的数据结构,当数组中元素的数据类型发生改变或新增了不同的数据类型的时候js数组类型会发生动态改变

image.png

  • PACKED_SMI_ELEMENTS 种类: 代表数组中所有元素都是由 SMI 类型的全填充数组

  • HOLY_SMI_ELEMENTS 种类: 代表数组中元素都是由 SMI 类型的带孔数组

  • PACKED_DOUBLE_ELEMENTS 种类: 代表数组中的元素由 HeapNumber 类型的全填充数组

  • HOLY_DOUBLE_ELEMENTS 种类: 代表数组中元素由 HeapNumber 类型的带孔数组

前四种存储的全是数字,可以理解为number类型的数组array<number>

  • PACKED_ELEMENTS 种类: 用于元素不能表示为SMIDOUBLE类型的全填充数组

  • HOLY_ELEMENTS 种类: 用于元素不能表示为SMIDOUBLE类型的带孔数组

后面这两个则存储除number以外的其他基本数据类型或者引用数据类型,他每个元素的存储长度取决于是否含有DOUBLE类型的元素

SMI可以理解为存储的元素是32位的,如int类型。DOUBLE表示存储的元素是64位的比如双精度浮点数

对于HOLY这种类型则是带孔数组,他的效率要低于PACKED类型

数组元素的类型转换优先级 PACKED_ELEMENTS | HOLY_ELEMENTS > PACKED_DOUBLE_ELEMENTS | HOLY_DOUBLE_ELEMENTS > PACKED_SMI_ELEMENTS | HOLY_SMI_ELEMENTS

let arr = new Array(100).fill(1) // PACKED_SMI_ELEMENTS  

arr[0] = 0.1 // PACKED_DOUBLE_ELEMETNS  
let arr = new Array(100).fill(0.1) // PACKED_DOUBLE_ELEMETNS    

arr[0] = 1 // PACKED_SMI_ELEMENTS 
let arr = new Array(100).fill(0.1) // PACKED_DOUBLE_ELEMETNS    

arr[0] = 'aaaa' // PACKED_ELEMENTS 
let arr = [1, 0.1, {a: 1}] // PACKED_ELEMENTS 

应用

在平常编码中如果对性能有高要求的情况下,特别是一些webGL的项目,可以做出的优化有

  1. 避免创建HOLY类型的数组
  2. 避免数据转换当数组内部有多个数据类型时有限把可能引起数组类型转换的数据放入数组,比如一个数组中既要存储字符串又要存储数字,那么优先放入字符串
  3. 尽量少使用会改变数组长度的方式来给数组赋值比如push或者pop,因为每次赋值都会引起扩容浪费性能

参考文章

V8

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

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