深入理解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
引擎中,直接創建數組默認的方式是創建快數組,會直接為數組開辟一定大小的內存,關於這一點可以直接在Chrome
的Memory
選項卡下首先保存快照然后在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
,在indexOf
、filter
、forEach
中會自動忽略掉empty
,在includes
中會認為其等於undefined
,map
中則會保留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 ID
為db4822d
。通過在V8
數組的定義可以了解到,數組可以處於兩種模式,Fast
模式的存儲結構是FixedArray
並且長度小於等於elements.length
,可以通過push
和pop
增加和縮小數組。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 + 1
和old_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
,這樣在訪問這些位置時就可以得到undefined
。Fast 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
都可以過渡到HOLEY
。PACKED_SMI_ELEMENTS
可以轉換為HOLEY_SMI_ELEMENTS
,PACKED_DOUBLE_ELEMENTS
可以轉換為HOLEY_DOUBLE_ELEMENTS
,PACKED_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
時,也就是至少有1024
個HOLEY
時,即會轉為慢數組,例如定義一個長度為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