深入理解Js數組


深入理解Js數組

Js中數組存在兩種形式,一種是與C/C++等相同的在連續內存中存放數據的快數組,另一種是HashTable結構的慢數組,是一種典型的字典形式。

描述

在本文中所有的測試都是基於V8引擎的,使用的瀏覽器版本為Chrome 83.0,當然直接使用Node也是可以的。通常創建數組一般用以下三種方式,當然對於直接更改length屬性的方式也可以達到改變數組長度的目的,從而實現創建指定長度的數組,只是並不常用。

var arr = [];
var arr = Array(100);
var arr = new Array(100);

對於上面三種方式,第一種使用字面量創建數組的方式是最常用的,第二種與第三種方式本質上是一樣的,Array內部實現會判斷this指針。在V8引擎中,直接創建數組默認的方式是創建快數組,會直接為數組開辟一定大小的內存,關於這一點可以直接在ChromeMemory選項卡下首先保存快照然后在Console執行如下代碼,可以看到內存增加了25MB左右,說明其開辟了一塊內存區域供數組使用,假如使用Node的話可以執行process.memoryUsage();來查看內存占用。

var LIMIT = 6 * 1024 * 1024;
var arr = new Array(LIMIT);

對於快數組,其開辟了一塊連續的內存區域用來提供數據存儲,在遍歷的效率上會高得多。對於慢數組,是HashTable結構,可以認為其就是一個對象,只不過索引的值只能為數字,在實際使用中這個數字索引會被強制轉為字符串,在遍歷的效率上會慢的多,但是對於一個數組是慢數組且為稀疏數組的情況下,可以節省大量內存區域。
對於快數組,直接賦值,可以看到完成操作需要27ms

var LIMIT = 6 * 1024 * 1024;
var arr = new Array(LIMIT);
console.time("Array");
for(var i=0; i<LIMIT; i++) arr[i]=i;
console.timeEnd("Array");
// Array: 27.64697265625ms

對於慢數組,本例首先push一個值用來進行擴容操作,引擎會自動將該數組轉換為慢數組,關於為什么本次擴容操作會引起快慢數組的轉換會在下邊講到,其他操作與快數組類似,可以看到完成操作需要627ms

var LIMIT = 6 * 1024 * 1024;
var arr = new Array(LIMIT);
arr.push(1); // 為了將快數組轉換為慢數組
console.time("Array");
for(var i=0; i<LIMIT; i++) arr[i]=i;
console.timeEnd("Array");
// Array: 627.759033203125ms

如果在快數組中並不連續插入數據,而是作為稀疏數組去使用,在稀疏的程度不高的時候依舊是快數組的形式,並不會觸發轉換為慢數組的操作。

var LIMIT = 6 * 1024 * 1024;
var arr = new Array(LIMIT);
console.time("Array");
for(var i=0; i<LIMIT; i += 2) arr[i]=i; // 循環的i為 i += 2
console.timeEnd("Array");
// Array: 15.27001953125ms

在數組中插入不同類型的數據並不一定會引起快慢數組的轉換,例如下面這個例子中插入了字符串、數值、布爾類型的值以及對象的引用,在插入效率上並不低。

var LIMIT = 6 * 1024 * 1024;
var arr = new Array(LIMIT);
var obj = {};
console.time("Array");
for(var i=1; i<LIMIT; i++) {
    if(i < 100) arr[i] = i;
    else if(i < 1000) arr[i] = "T";
    else if(i < 10000) arr[i] = true;
    else arr[i] = obj;
}
console.timeEnd("Array");
// Array: 32.123046875ms

關於稀疏數組中的empty,是一個空的對象引用,在ES6的文檔中規定了empty就是等於undefined的,在任何情況下都應該這樣對待empty,在indexOffilterforEach中會自動忽略掉empty,在includes中會認為其等於undefinedmap中則會保留empty

var arr = new Array(3);
arr[0] = 1;
console.log(arr); // (3) [1, empty × 2]
console.log(arr[1] === undefined); // true
console.log(arr.indexOf(undefined)); // -1
console.log(arr.filter(v => v)); // [1]
arr.forEach( v => console.log(v)); // 1
console.log(arr.includes(undefined)); // true
console.log(arr.map(v => v)); // [1, empty × 2]

如果必須要開辟一個密集數組,也就是不存在empty的情況,可以使用下面的方式去開辟。

[...new Array(3)]; // (3) [undefined, undefined, undefined]
Array.apply(null, new Array(3)); // (3) [undefined, undefined, undefined]
Array.from(new Array(3)); // (3) [undefined, undefined, undefined]

Js中還存在類型化數組,ArrayBuffer是一種數據類型,用來表示一個通用的、固定長度的二進制數據緩沖區,不能直接操縱一個ArrayBuffer中的內容,需要創建一個類型化數組的視圖或一個描述緩沖數據格式的DataView,使用它們來讀寫緩沖區中的內容。簡單來說就是一塊大的連續的內存區域,可以用它來做一些高效的存取操作等。

var LIMIT = 6 * 1024 * 1024;
var buffer = new ArrayBuffer(LIMIT);
var arr = new Int32Array(buffer);
console.time("Array");
for(var i=0; i<LIMIT; i++) arr[i]=i;
console.timeEnd("Array");
// Array: 30.139892578125ms

對於快慢數組,兩者的也有各自的特點,在實際使用的過程中是存在相互轉換的,在存儲方式、內存使用、遍歷效率方面有如下總結:

  • 存儲方式方面:快數組內存中是連續的,慢數組在內存中是零散分配的。
  • 內存使用方面:由於快數組內存是連續的,可能需要開辟一大塊供其使用,其中還可能有很多空洞,是比較費內存的。慢數組不會有空洞的情況,且都是零散的內存,比較節省內存空間。
  • 遍歷效率方面:快數組由於是空間連續的,遍歷速度很快,而慢數組每次都要尋找key 的位置,遍歷效率會差一些。

源碼分析

簡單分析V8引擎的數組方面的內容,COMMIT IDdb4822d。通過在V8數組的定義可以了解到,數組可以處於兩種模式,Fast模式的存儲結構是FixedArray並且長度小於等於elements.length,可以通過pushpop增加和縮小數組。slow模式的存儲結構是一個以數字為鍵的HashTable

// v8/src/objects/js-array.h // line 19
// 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)
 
 //...

快數組

快數組是一種線性的存儲方式,內部存儲是連續的內存,新創建的空數組,默認的存儲方式是快數組,快數組長度是可變的,可以根據元素的增加和刪除來動態調整存儲空間大小,內部是通過擴容和收縮機制實現。首先來分析以下擴容機制,默認的空數組預分配的大小為4,當數組進行擴充操作例如push時,數組的內存若不夠則將進行擴容,最小的擴容容量為16,擴容的公式為new_capacity = old_capacity + old_capacity /2 + 16,即申請一塊原容量1.5倍加16這樣大小的內存,將原數據拷貝到新內存,然后length + 1,並返回length

// v8/src/objects/js-array.h // line 105
// Number of element slots to pre-allocate for an empty array.
static const int kPreallocatedArrayElements = 4;

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

// v8/src/objects/js-objects.h // line 540 // 計算擴容后的容量
// Computes the new capacity when expanding the elements of a JSObject.
static uint32_t NewElementsCapacity(uint32_t old_capacity) {
    // (old_capacity + 50%) + kMinAddedElementsCapacity
    return old_capacity + (old_capacity >> 1) + kMinAddedElementsCapacity;
}

// v8/src/code-stub-assembler.cc // line 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 // line 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);

當數組執行pop操作時,會判斷pop后數組的容量,是否需要進行減容,如果容量大於等於length * 2 + 16,則進行收縮容量調整,否則用HOLES對象填充未被初始化的位置,elements_to_trim就是要裁剪的大小,需要根據length + 1old_length判斷是將空出的空間全部收縮掉還是只收縮一半。

// v8/src/elements.cc // line 783
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);
}

上邊提到的HOLES對象指的是數組中分配了空間,但是沒有存放元素的位置,對於HOLES,在Fast Elements模式中有一個擴展,稱為Fast Holey Elements模式。Fast Holey Elements模式適合於數組中的有空洞情況,即只有某些索引存有數據,而其他的索引都沒有賦值的情況,此時沒有賦值的數組索引將會存儲一個特殊的值empty,這樣在訪問這些位置時就可以得到undefinedFast Holey Elements模式與Fast Elements模式一樣,會動態分配連續的存儲空間,分配空間的大小由最大的索引值決定。定義數組時,如果沒有設置容量,V8會默認使用Fast Elements模式實現,如果定義數組時進行了容量的指定,如上文中的new Array(100),就會以Fast Holey Elements模式實現。
Fast Elements模式下V8引擎還根據元素類型對數組類型做了細分用以優化數組,當全部元素都為整數型的話,那么這個數組的類型就被標記為PACKED_SMI_ELEMENTS。如果只存在整數型和浮點型的元素類型,那么這個數組的類型為PACKED_DOUBLE_ELEMENTS。除此以外,一個數組包含其它的元素,都被標記為PACKED_ELEMENTS。而這些數組類型並非一成不變,而是在運行時隨時更改的,但是數組的類型只能從特定種類變更為普通種類。即初始為PACKED_SMI_ELEMENTS的數組,只能過渡為PACKED_DOUBLE_ELEMENTS或者PACKED_ELEMENTS。而PACKED_DOUBLE_ELEMENTS只能過渡為PACKED_ELEMENTS。至於初始就是PACKED_ELEMENTS類型的數組,就無法再過渡了,無法逆向過渡。而上述的這三種類型,都屬於密集數組,與之相對應的,是稀疏數組,標記為HOLEY_ELEMENTS,稀疏數組同樣具有三種類型,任何一種PACKED都可以過渡到HOLEYPACKED_SMI_ELEMENTS可以轉換為HOLEY_SMI_ELEMENTSPACKED_DOUBLE_ELEMENTS可以轉換為HOLEY_DOUBLE_ELEMENTSPACKED_ELEMENTS可以轉換為HOLEY_ELEMENTS。需要注意的是,雖然可以將數組轉換為HOLEY模式,但是並不一定就代表着這個數組被轉換為慢數組。

慢數組

慢數組是一種字典的內存形式。不用開辟大塊連續的存儲空間,節省了內存,但是由於需要維護這樣一個HashTable,其效率會比快數組低,V8中是以Dictionary的結構實現的慢數組。

// v8/src/objects/dictionary.h // line 27
class Dictionary : public HashTable<Derived, Shape> {
    typedef HashTable<Derived, Shape> DerivedHashTable;

    public:
    typedef typename Shape::Key Key;
    // Returns the value at entry.
    Object ValueAt(int entry) {
        return this->get(DerivedHashTable::EntryToIndex(entry) + 1);
    }

    // Set the value for entry.
    void ValueAtPut(int entry, Object value) {
       this->set(DerivedHashTable::EntryToIndex(entry) + 1, value);
    }
    
    // Returns the property details for the property at entry.
    PropertyDetails DetailsAt(int entry) {
       return Shape::DetailsAt(Derived::cast(*this), entry);
    }

    // ...

}

類型轉換

快數組轉慢數組

快數組轉換為慢數組主要有以下兩種情況:

  • 當新容量大於等於3 * 3倍的擴容后的容量,會轉變為慢數組。
  • 當加入的索引值index比當前容量capacity差值大於等於1024 時,也就是至少有1024HOLEY時,即會轉為慢數組,例如定義一個長度為1的數組arr然后使用arr[2000]=1賦值,此時數組就會被轉換為慢數組。
// v8/src/objects/js-objects-inl.h // line 992
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;
  }
  // If the fast-case backing storage takes up much more memory than a
  // dictionary backing storage would, the object should have slow elements.
  int used_elements = object->GetFastElementsUsage();
  uint32_t size_threshold = NumberDictionary::kPreferFastElementsSizeFactor *
                            NumberDictionary::ComputeCapacity(used_elements) *
                            NumberDictionary::kEntrySize;
  return size_threshold <= *new_capacity; // 第一種轉換
}

// v8/src/objects/js-objects.h // line 738
// JSObject::kMaxGap 常量
// Maximal gap that can be introduced by adding an element beyond
// the current elements length.
static const uint32_t kMaxGap = 1024;

// v8/src/objects/dictionary.h // line 362
// NumberDictionary::kPreferFastElementsSizeFactor 常量
// JSObjects prefer dictionary elements if the dictionary saves this much
// memory compared to a fast elements backing store.
static const uint32_t kPreferFastElementsSizeFactor = 3;

// v8/src/objects/hash-table-inl.h // line 76
// NumberDictionary::ComputeCapacity(used_elements)
// NumberDictionary 繼承於 Dictionary 再繼承於 HashTable
// static
int HashTableBase::ComputeCapacity(int at_least_space_for) {
  // Add 50% slack to make slot collisions sufficiently unlikely.
  // See matching computation in HashTable::HasSufficientCapacityToAdd().
  // Must be kept in sync with CodeStubAssembler::HashTableComputeCapacity().
  int raw_cap = at_least_space_for + (at_least_space_for >> 1);
  int capacity = base::bits::RoundUpToPowerOfTwo32(raw_cap);
  return Max(capacity, kMinCapacity);
}

// v8/src/objects/dictionary.h // line 260
// NumberDictionary::kEntrySize 常量
// NumberDictionary 繼承 Dictionary 傳入 NumberDictionaryShape作為Shape 繼承HashTable 
// HashTable 中定義 static const int kEntrySize = Shape::kEntrySize;
static const int kEntrySize = 3;

慢數組轉快數組

當慢數組的元素可存放在快數組中且長度小於Smi::kMaxValue且對於快數組僅節省了50%的空間,則會轉變為快數組。

// v8/src/objects/js-objects.cc // line 4523
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->IsJSSloppyArgumentsObject()) {
    return false;
  } else {
    *new_capacity = dictionary->max_number_key() + 1;
  }
  *new_capacity = 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;
}

// v8/src/objects/smi.h // line 106
static constexpr int kMaxValue = kSmiMaxValue;

// v8/include/v8-internal.h // line 87
static constexpr intptr_t kSmiMaxValue = -(kSmiMinValue + 1);

每日一題

https://github.com/WindrunnerMax/EveryDay

參考

https://v8.js.cn/blog/elements-kinds/
https://github.com/JunreyCen/blog/issues/10
https://juejin.im/post/5e1d919f5188254c3c275145
https://juejin.im/post/5df1e21bf265da33c24fe9f4
https://juejin.im/entry/5a9c0b606fb9a028d663a491
https://juejin.im/entry/59ae664d518825244d207196
https://blog.csdn.net/github_34708151/article/details/105463108
https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Typed_arrays
https://developer.mozilla.org/zh-CN/docs/Web/JavaScript/Reference/Global_Objects/ArrayBuffer
https://stackoverflow.com/questions/46526520/why-are-we-allowed-to-create-sparse-arrays-in-javascript


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM