前端進階算法2:從Chrome V8源碼看JavaScript數組(附贈騰訊面試題).md

簡介

數組、鏈表、棧、隊列都是線性表,它表示的結構都是一段線性的結構,與之對應的就是非線性表,例如樹、圖、堆等,它表示的結構都非線性。前端

本節主要介紹 JavaScript 數組,在開始本章節前,思考一個問題:c++

咱們知道在 JavaScript 中,能夠在數組中保存不一樣類型值,而且數組能夠動態增加,不像其它語言,例如 C,建立的時候要決定數組的大小,若是數組滿了,就要從新申請內存空間。這是爲何喃?git

本節從 Chrome v8 源碼角度回答這個問題,分爲四個方面:github

  • 數組基礎入門
  • JavaScript 中,數組爲何能夠保存不一樣類型?
  • JavaScript 中,數組是如何存儲的喃?
  • JavaScript 中,數組的動態擴容與減容( FastElements

下面進入正題吧!(文末有驚喜)😊面試

想要更多更快的學習本系列,能夠關注公衆號「前端瓶子君」和個人「Github(點擊查看)算法

1、數組(基礎)

一種最基礎的數據結構,每種編程語言都有,它編號從 0 開始,表明一組連續的儲存結構,用來儲存同一種類型的數據。編程

let arr = [1, 2, 3]

它的這種特定的存儲結構(連續存儲空間存儲同一類型數據)決定了:數組

優勢數據結構

  • 隨機訪問:能夠經過下標隨機訪問數組中的任意位置上的數據

缺點編程語言

  • 對數據的刪除和插入不是很友好

查找: 根據下標隨機訪問的時間複雜度爲 O(1);

插入或刪除: 時間複雜度爲 O(n);

在 JavaScript 中的數組幾乎是萬能的,它不光能夠做爲一個普通的數組使用,能夠做爲棧或隊列使用。

數組:

let array = [1, 2, 3]

棧:

let stack = [1, 2, 3]
// 進棧
stack.push(4)
// 出棧
stcak.pop()

隊列:

let queue = [1, 2, 3]
// 進隊
queue.push(4)
// 出隊
queue.shift()

2、JavaScript 中,數組能夠保存不一樣類型值

看一下 Chrome v8 源碼:

// 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)
    
  // ...
   
  // Number of element slots to pre-allocate for an empty array.
  static const int kPreallocatedArrayElements = 4;
};

咱們能夠看到 JSArray 是繼承自 JSObject 的,因此在 JavaScript 中,數組能夠是一個特殊的對象,內部也是以 key-value 形式存儲數據,因此 JavaScript 中的數組能夠存放不一樣類型的值。

3、JavaScript 中,數組的存儲

// 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)
    
  // ...
   
  // Number of element slots to pre-allocate for an empty array.
  static const int kPreallocatedArrayElements = 4;
};

JSArray 繼承於 JSObject ,從註釋上看,它有兩種存儲方式:

  • fast:存儲結構是 FixedArray ,而且數組長度 <= elements.length()pushpop 時可能會伴隨着動態擴容或減容
  • slow:存儲結構是 HashTable(哈希表),而且數組下標做爲 key

fast 模式下數組在源碼裏面叫 FastElements ,而 slow 模式下的叫作 SlowElements

1. 快數組(FastElements)

FixedArray 是 V8 實現的一個相似於數組的類,它表示一段連續的內存,可使用索引直接定位。新建立的空數組默認就是快數組。當數組滿(數組的長度達到數組在內存中申請的內存容量最大值)的時候,繼續 push 時, JSArray 會進行動態的擴容,以存儲更多的元素。

2. 慢數組(SlowElements)

慢數組以哈希表的形式存儲在內存空間裏,它不須要開闢連續的存儲空間,但須要額外維護一個哈希表,與快數組相比,性能相對較差。

// src/objects/dictionary.h
class EXPORT_TEMPLATE_DECLARE(V8_EXPORT_PRIVATE) Dictionary
    : public HashTable<Derived, Shape> {
  using DerivedHashTable = HashTable<Derived, Shape>;

 public:
  using Key = typename Shape::Key;
  // Returns the value at entry.
  inline Object ValueAt(InternalIndex entry);
  inline Object ValueAt(const Isolate* isolate, InternalIndex entry);
  
  // ...
};

從源碼中能夠看出,它的內部就是一個 HashTable。

3. 何時會從 fast 轉變爲 slow 喃?

從 Chrome V8 源碼上看,

// 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;

// 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 ShouldConvertToSlowElements(uint32_t used_elements,
                                               uint32_t new_capacity) {
  uint32_t size_threshold = NumberDictionary::kPreferFastElementsSizeFactor *
                            NumberDictionary::ComputeCapacity(used_elements) *
                            NumberDictionary::kEntrySize;
  // 快數組新容量是擴容後的容量3倍之多時,也會被轉成慢數組
  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;
  }
  // 當加入的索引值(例如例3中的2000)比當前容量capacity 大於等於 1024時,
  // 返回true,轉爲慢數組
  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);
}

因此,當處於如下狀況時,快數組會被轉變爲慢數組:

  • 當加入的索引值 index 比當前容量 capacity 差值大於等於 1024 時(index - capacity >= 1024)
  • 快數組新容量是擴容後的容量 3 倍之多時

例如:向快數組裏增長一個大索引同類型值

var arr = [1, 2, 3]
arr[2000] = 10;

當往 arr 增長一個 2000 的索引時,arr 被轉成慢數組。節省了大量的內存空間(從索引爲 2 到索引爲 2000)。

4. 何時會從 slow 轉變爲 fast 喃?

咱們已經知道在何時會出現由快變慢,那由慢變快就很簡單了

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 = 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;
}

當慢數組的元素可存放在快數組中且長度在 smi 之間且僅節省了50%的空間,則會轉變爲快數組

4、JavaScript 中,數組的動態擴容與減容(FastElements)

默認空數組初始化大小爲 4 :

// Number of element slots to pre-allocate for an empty array.
static const int kPreallocatedArrayElements = 4;

在 JavaScript 中,當數組執行 push 操做時,一旦發現數組內存不足,將進行擴容。

在 Chrome 源碼中, push 的操做是用匯編實現的,在 c++ 裏嵌入的彙編,以提升執行效率,而且在彙編的基礎上用 c++ 封裝了一層,在編譯執行的時候,會將這些 c++ 代碼轉成彙編代碼。

計算新容量的函數:

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

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

因此擴容後新容量計公式爲:

new_capacity = old_capacity /2 + old_capacity + 16

即老的容量的 1.5 倍加上 16 。初始化爲 4 個,當 push 第 5 個的時候,容量將會變成:

new_capacity = 4 / 2 + 4 + 16 = 22

接着申請一塊這麼大的內存,把老的數據拷過去,把新元素放在當前 length 位置,而後將數組的 length + 1,並返回 length。

因此,擴容能夠分爲如下幾步:

  • push 操做時,發現數組內存不足
  • 申請 new_capacity = old_capacity /2 + old_capacity + 16 那麼長度的內存空間
  • 將數組拷貝到新內存中
  • 把新元素放在當前 length 位置
  • 數組的 length + 1
  • 返回 length

整個過程,用戶是無感知的,不像 C,需用用戶手動申請內存空間。

當數組執行 pop 操做時,會判斷 pop 後數組的容量,是否須要進行減容。

不一樣於數組的 push 使用匯編實現的, pop 使用 c++ 實現的。

判斷是否進行減容:

if (2 * length <= capacity) {
  // If more than half the elements won't be used, trim the array.
  isolate->heap()->RightTrimFixedArray(*backing_store, capacity - length);
} else {
  // Otherwise, fill the unused tail with holes.
  BackingStore::cast(*backing_store)->FillWithHoles(length, old_length);
}

因此,當數組 pop 後,若是數組容量大於等於 length 的 2 倍,則進行容量調整,使用 RightTrimFixedArray 函數,計算出須要釋放的空間大小,作好標記,等待 GC 回收;若是數組容量小於 length 的 2 倍,則用 holes 對象填充。

因此,減容能夠分爲如下幾步:

  • pop 操做時,獲取數組 length
  • 獲取 length - 1 上的元素(要刪除的元素)
  • 數組 length - 1
  • 判斷數組的總容量是否大於等於 length - 1 的 2 倍
  • 是的話,使用 RightTrimFixedArray 函數,計算出須要釋放的空間大小,並作好標記,等待 GC 回收
  • 不是的話,用 holes 對象填充
  • 返回要刪除的元素

5、解答開篇問題

JavaScript 中, JSArray 繼承自 JSObject ,或者說它就是一個特殊的對象,內部是以 key-value 形式存儲數據,因此 JavaScript 中的數組能夠存放不一樣類型的值。它有兩種存儲方式,快數組與慢數組,初始化空數組時,使用快數組,快數組使用連續的內存空間,當數組長度達到最大時,JSArray 會進行動態的擴容,以存儲更多的元素,相對慢數組,性能要好得多。當數組中 hole 太多時,會轉變成慢數組,即以哈希表的方式( key-value 的形式)存儲數據,以節省內存空間。

6、最後附贈一道前端面試題(騰訊):數組扁平化、去重、排序

關於 Array 的屬性、方法這裏再也不作介紹,詳看 MDN Array

面試題:

已知以下數組:var arr = [ [1, 2, 2], [3, 4, 5, 5], [6, 7, 8, 9, [11, 12, [12, 13, [14] ] ] ], 10];

編寫一個程序將數組扁平化去併除其中重複部分數據,最終獲得一個升序且不重複的數組

答案:

var arr = [ [1, 2, 2], [3, 4, 5, 5], [6, 7, 8, 9, [11, 12, [12, 13, [14] ] ] ], 10]
// 扁平化
let flatArr = arr.flat(4)
// 去重
let disArr = Array.from(new Set(flatArr))
// 排序
let result = disArr.sort(function(a, b) {
    return a-b
})
console.log(result)
// [1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14]

關於 Set 請查閱 Set、WeakSet、Map及WeakMap

參考連接:

探究JS V8引擎下的「數組」底層實現

從Chrome源碼看JS Array的實現

7、認識更多的前端道友,一塊兒進階前端開發

前端算法集訓營第一期免費開營啦🎉🎉🎉,免費喲!

在這裏,你能夠和志同道合的前端朋友們一塊兒進階前端算法,從0到1構建完整的數據結構與算法體系。

在這裏,你能夠天天學習一道大廠算法題(阿里、騰訊、百度、字節等等)或 leetcode,瓶子君都會在次日解答喲!

關注公衆號「前端瓶子君」內回覆「算法」自動加入

相關文章
相關標籤/搜索