每個 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 引擎,選擇 v8
和 v8-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,所以要看轉化規則,主要就看兩個函式:ShouldConvertToSlowElements
和 ShouldConvertToFastElements
。
下面是 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 許可證)