精讀《JS 陣列的內部實現》

黃子毅發表於2022-05-09

每個 JS 執行引擎都有自己的實現,我們這次關注 V8 引擎是如何實現陣列的。

本週主要精讀的文章是 How JavaScript Array Works Internally?,比較簡略的介紹了 V8 引擎的陣列實現機制,筆者也會參考部分其他文章與原始碼結合進行講解。

概述

JS 陣列的內部型別有很多模式,如:

  • PACKED_SMI_ELEMENTS
  • PACKED_DOUBLE_ELEMENTS
  • PACKED_ELEMENTS
  • HOLEY_SMI_ELEMENTS
  • HOLEY_DOUBLE_ELEMENTS
  • HOLEY_ELEMENTS

PACKED 翻譯為打包,實際意思是 “連續有值的陣列”;HOLEY 翻譯為孔洞,表示這個陣列有很多孔洞一樣的無效項,實際意思是 “中間有孔洞的陣列”,這兩個名詞是互斥的。

SMI 表示資料型別為 32 位整型,DOUBLE 表示浮點型別,而什麼型別都不寫,表示陣列的型別還雜糅了字串、函式等,這個位置上的描述也是互斥的。

所以可以這麼去看陣列的內部型別:[PACKED, HOLEY]_[SMI, DOUBLE, '']_ELEMENTS

最高效的型別 PACKED_SMI_ELEMENTS

一個最簡單的空陣列型別預設為 PACKED_SMI_ELEMENTS:

const arr = [] // PACKED_SMI_ELEMENTS

PACKED_SMI_ELEMENTS 型別是效能最好的模式,儲存的型別預設是連續的整型。當我們插入整型時,V8 會給陣列自動擴容,此時型別還是 PACKED_SMI_ELEMENTS:

const arr = [] // PACKED_SMI_ELEMENTS
arr.push(1) // PACKED_SMI_ELEMENTS

或者直接建立有內容的陣列,也是這個型別:

const arr = [1, 2, 3] // PACKED_SMI_ELEMENTS

自動降級

當我們對陣列使用騷操作時,V8 會默默的進行型別降級。比如突然訪問到第 100 項:

const arr = [1, 2, 3] // PACKED_SMI_ELEMENTS
arr[100] = 4 // HOLEY_SMI_ELEMENTS

如果突然插入一個浮點型別,會降級到 DOUBLE:

const arr = [1, 2, 3] // PACKED_SMI_ELEMENTS
arr.push(4.1) // PACKED_DOUBLE_ELEMENTS

當然如果兩個騷操作一結合,HOLEY_DOUBLE_ELEMENTS 就成功被你造出來了:

const arr = [1, 2, 3] // PACKED_SMI_ELEMENTS
arr[100] = 4.1 // HOLEY_DOUBLE_ELEMENTS

再狠一點,插入個字串或者函式,那就到了最最兜底型別,HOLEY_ELEMENTS:

const arr = [1, 2, 3] // PACKED_SMI_ELEMENTS
arr[100] = '4' // HOLEY_ELEMENTS

從是否有 Empty 情況來看,PACKED > HOLEY 的效能,Benchmark 測試結果大概快 23%。

從型別來看,SMI > DOUBLE > 空型別。原因是型別決定了陣列每項的長度,DOUBLE 型別是指每一項可能為 SMI 也可能為 DOUBLE,而空型別的每一項型別完全不可確認,在長度確認上會花費額外開銷。

因此,HOLEY_ELEMENTS 是效能最差的兜底型別。

降級的不可逆性

文中提到一個重點,表示降級是不可逆的,具體可以看下圖:

<img width=500 src="https://s1.ax1x.com/2022/05/08/O3nzsf.png">

其實要表達的規律很簡單,即 PACKED 只會變成更糟的 HOLEY,SMI 只會往更糟的 DOUBLE 和空型別變,且這兩種變化都不可逆。

精讀

為了驗證文章的猜想,筆者使用 v8-debug 除錯了一番。

使用 v8-debug 除錯

先介紹一下 v8-debug,它是一個 v8 引擎除錯工具,首先執行下面的命令列安裝 jsvu

npm i -g jsvu

然後執行 jsvu,根據引導選擇自己的系統型別,第二步選擇要安裝的 js 引擎,選擇 v8v8-debug

jsvu
// 選擇 macos
// 選擇 v8,v8-debug

然後隨便建立一個 js 檔案,比如 test.js,再通過 ~/.jsvu/v8-debug ./test.js 就可以執行除錯了。預設是不輸出任何除錯內容的,我們根據需求新增引數來輸出要除錯的資訊,比如:

~/.jsvu/v8-debug ./test.js --print-ast

這樣就會把 test.js 檔案的語法樹列印出來。

使用 v8-debug 除錯陣列的內部實現

為了觀察陣列的內部實現,使用 console.log(arr) 顯然不行,我們需要用 %DebugPrint(arr) 以 debug 模式列印陣列,而這個 %DebugPrint 函式式 V8 提供的 Native API,在普通 js 指令碼是不識別的,因此我們要在執行時新增引數 --allow-natives-syntax

~/.jsvu/v8-debug ./test.js --allow-natives-syntax

同時,在 test.js 裡使用 %DebugPrint 列印我們要除錯的陣列,如:

const arr = []
%DebugPrint(arr)

輸出結果為:

DebugPrint: 0x120d000ca0b9: [JSArray]
 - map: 0x120d00283a71 <Map(PACKED_SMI_ELEMENTS)> [FastProperties]

也就是說,arr = [] 建立的陣列的內部型別為 PACKED_SMI_ELEMENTS,符合預期。

驗證不可逆轉換

不看原始碼的話,姑且相信原文說的型別轉換不可逆,那麼我們做一個測試:

const arr = [1, 2, 3]
arr.push(4.1)

console.log(arr);
%DebugPrint(arr)

arr.pop()

console.log(arr);
%DebugPrint(arr)

列印核心結果為:

1,2,3,4.1
DebugPrint: 0xf91000ca195: [JSArray]
 - map: 0x0f9100283b11 <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties]

1,2,3
DebugPrint: 0xf91000ca195: [JSArray]
 - map: 0x0f9100283b11 <Map(PACKED_DOUBLE_ELEMENTS)> [FastProperties]

可以看到,即便 pop 後將原陣列回退到完全整型的情況,DOUBLE 也不會優化為 SMI。

再看下長度的測試:

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

console.log(arr);
%DebugPrint(arr)

arr.pop()
arr.pop()

console.log(arr);
%DebugPrint(arr)

列印核心結果為:

1,2,3,,4
DebugPrint: 0x338b000ca175: [JSArray]
 - map: 0x338b00283ae9 <Map(HOLEY_SMI_ELEMENTS)> [FastProperties]

1,2,3
DebugPrint: 0x338b000ca175: [JSArray]
 - map: 0x338b00283ae9 <Map(HOLEY_SMI_ELEMENTS)> [FastProperties]

也證明了 PACKED 到 HOLEY 的不可逆。

字典模式

陣列還有一種內部實現是 Dictionary Elements,它用 HashTable 作為底層結構模擬陣列的操作。

這種模式用於陣列長度非常大的時候,不需要連續開闢記憶體空間,而是用一個個零散的記憶體空間通過一個 HashTable 定址來處理資料的儲存,這種模式在資料量大時節省了儲存空間,但帶來了額外的查詢開銷。

當對陣列的賦值遠大於當前陣列大小時,V8 會考慮將陣列轉化為 Dictionary Elements 儲存以節省儲存空間。

做一個測試:

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

arr[3000] = 4;
%DebugPrint(arr);

主要輸出結果為:

DebugPrint: 0x209d000ca115: [JSArray]
 - map: 0x209d00283a71 <Map(PACKED_SMI_ELEMENTS)> [FastProperties]

DebugPrint: 0x209d000ca115: [JSArray]
 - map: 0x209d00287d29 <Map(DICTIONARY_ELEMENTS)> [FastProperties]

可以看到,佔用了太多空間會導致陣列的內部實現切換為 DICTIONARY_ELEMENTS 模式。

實際上這兩種模式是根據固定規則相互轉化的,具體查了下 V8 原始碼:

字典模式在 V8 程式碼裡叫 SlowElements,反之則叫 FastElements,所以要看轉化規則,主要就看兩個函式:ShouldConvertToSlowElementsShouldConvertToFastElements

下面是 ShouldConvertToSlowElements 程式碼,即什麼時候轉化為字典模式:

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);
  if (*new_capacity <= JSObject::kMaxUncheckedOldFastElementsLength ||
      (*new_capacity <= JSObject::kMaxUncheckedFastElementsLength &&
       ObjectInYoungGeneration(object))) {
    return false;
  }
  return ShouldConvertToSlowElements(object.GetFastElementsUsage(),
                                     *new_capacity);
}

ShouldConvertToSlowElements 函式被過載了兩次,所以有兩個判斷邏輯。第一處 new_capacity > size_threshold 則變成字典模式,new_capacity 表示新尺寸,而 size_threshold 是根據 3 已有尺寸 2 計算出來的。

第二處 index - capacity >= JSObject::kMaxGap 時變成字典模式,其中 kMaxGap 是常量 1024,也就是新加入的 HOLEY(孔洞) 大於 1024,則轉化為字典模式。

而由字典模式轉化為普通模式的函式是 ShouldConvertToFastElements

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

重點是最後一行 return 2 * dictionary_size >= *new_capacity 表示字典模式僅節省了 50% 空間時,不如切換為普通模式(fast mode)。

具體就不測試了,感興趣同學可以用上面介紹的方法使用 v8-debug 測試一下。

總結

JS 陣列使用方法非常靈活,但 V8 使用 C++ 實現時,必須轉化為更底層的型別,所以為了兼顧效能,就做了快慢模式,而快模式又分了 SMI、DOUBLE;PACKED、HOLEY 模式分別處理來儘可能提升速度。

也就是說,我們在隨意建立陣列的時候,V8 會分析陣列的元素構成與長度變化,自動分發到各種不同的子模式處理,以最大化提升效能。

這種模式使 JS 開發者獲得了更好的開發者體驗,而實際上執行效能也和 C++ 原生優化相差無幾,所以從這個角度來看,JS 是一種更高封裝層次的語言,極大降低了開發者學習門檻。

當然 JS 還提供了一些相對原生的語法比如 ArrayBuffer,或者 WASM 讓開發者直接操作更底層的特性,這可以使效能控制更精確,但帶來了更大的學習和維護成本,需要開發者根據實際情況權衡。

討論地址是:精讀《JS 陣列的內部實現》· Issue #414 · dt-fe/weekly

如果你想參與討論,請 點選這裡,每週都有新的主題,週末或週一釋出。前端精讀 - 幫你篩選靠譜的內容。

關注 前端精讀微信公眾號

<img width=200 src="https://img.alicdn.com/tfs/TB165W0MCzqK1RjSZFLXXcn2XXa-258-258.jpg">

版權宣告:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證

相關文章