V8中的快速屬性
在這篇博客文章中,我們想解釋V8如何在內部處理JavaScript屬性。從JavaScript的角度看,屬性只需要少量的區分。JavaScript對象主要表現為字典形式,具有字符串鍵和任意對象作為值。不過,規範確實會在迭代過程中將整數索引的屬性與其他屬性區分開來。除此之外,無論是整數索引還是非整數索引的屬性,行為大致相同。
然而,在底層,V8確實依賴於幾種不同的屬性表示方式來提高性能並節省內存。在這篇博客文章中,我們將解釋V8如何在處理動態添加的屬性時提供快速的屬性訪問。了解屬性如何運作是理解像內聯緩存這樣的優化在V8中的工作原理的關鍵。
本文將解釋處理整數索引屬性和命名屬性之間的差異。之後我們將展示V8在添加命名屬性時如何維護HiddenClass,從而提供一種快速辨析對象形狀的方法。接著我們會繼續深入講解命名屬性如何根據使用方式進行快速訪問或快速修改的優化處理。在最後一節中,我們將提供有關V8如何處理整數索引屬性或數組索引的詳細信息。
命名屬性與元素屬性
讓我們先分析一個非常簡單的對象,比如{a: "foo", b: "bar"}
。這個對象有兩個命名屬性,"a"
和 "b"
。它的屬性名稱並沒有整數索引。數組索引屬性,更常見於數組中。例如數組 ["foo", "bar"]
有兩個數組索引屬性:0,其值為 "foo",以及1,其值為 "bar"。這是V8總體處理屬性時的第一個主要區分。
下面的圖表顯示了一個基本的JavaScript對象在內存中的樣子。
元素和屬性存儲於兩個獨立的數據結構中,這使得根據不同的使用模式添加和訪問屬性或元素更為高效。
元素主要用於各種Array.prototype
方法中,比如 pop
或 slice
。鑑於這些函數按連續範圍訪問屬性,V8也通常將它們表示為簡單的數組。然而,我們將在文章後面解釋如何在某些時候切換到基於稀疏字典的表示方式以節省內存。
命名屬性則是以類似的方式存儲於另一個獨立的數組中。然而,不像元素屬性一樣,我們不能僅僅使用鍵來推導其在屬性數組中的位置;我們需要一些額外的元數據。在V8中,每個JavaScript對象都關聯著一個HiddenClass。HiddenClass存儲有關對象形狀的信息,包括屬性名稱到屬性數組索引的映射。此外,有時我們使用字典代替簡單數組作為存儲屬性的方法,我們將在專門的章節中更詳細地解釋這一點。
本節要點:
- 數組索引屬性存儲於一個獨立的元素存儲中。
- 命名屬性存儲於屬性存儲中。
- 元素和屬性可以是數組或字典。
- 每個JavaScript對象都有關聯的HiddenClass,其保持有關對象形狀的信息。
HiddenClasses與DescriptorArrays
在解釋了元素與命名屬性的基本區分之後,我們需要看看隱藏類 (HiddenClasses) 如何在 V8 中運作。隱藏類存儲關於物件的元資訊,包括物件上的屬性數量及物件原型的引用。隱藏類在概念上類似於典型面向物件程式設計語言中的類別。然而,在像 JavaScript 這樣的基於原型的語言中,通常無法事先知道類別。因此,在該情況下,V8 中的隱藏類是在執行時動態創建及更新的。隱藏類作為物件形狀的識別符,對 V8 的優化編譯器和內聯快取而言至關重要。例如,優化編譯器可以在確保通過隱藏類的相容物件結構時直接內聯屬性存取。
讓我們來看看隱藏類的重要部分。
在 V8 中,JavaScript 物件的第一個欄位指向一個隱藏類。(實際上,這對於任何位於 V8 堆上的且由垃圾回收器管理的物件都是如此。)在屬性方面,最重要的資訊是第三位欄位,它儲存屬性數量和指向描述符陣列的指標。描述符陣列包含關於命名屬性的資訊,例如名稱本身和儲存值的位置。需要注意的是,我們並不在此跟蹤整數索引屬性,因此描述符陣列中沒有相關條目。
有關隱藏類的基本假設是,具有相同結構的物件 (例如相同順序的命名屬性) 共享相同的隱藏類。為此,當屬性被新增到物件時,我們使用不同的隱藏類。在下列範例中,我們從一個空物件開始,並新增三個命名屬性。
每次新增新屬性,物件的隱藏類便會更改。在背景中,V8 創建了一個過渡樹,將隱藏類串聯在一起。當您向空物件新增,例如屬性 "a" 時,V8 知道應採用哪一個隱藏類。這個過渡樹確保如果以相同順序新增相同的屬性,最終會得到相同的隱藏類。下列範例顯示,即使在中間新增簡單的索引屬性,我們也會遵循相同的過渡樹。
但是,如果我們創建一個新物件並新增了不同的屬性,在此情況下是屬性 "d"
,V8 將針對新的隱藏類創建一個單獨的分支。
本節要點:
- 具有相同結構(以相同順序具有相同屬性)的物件具有相同的隱藏類。
- 預設情況下,每新增一個命名屬性都會創建一個新的隱藏類。
- 新增陣列索引屬性不會創建新的隱藏類。
三種不同的命名屬性類型
在概述了 V8 如何使用隱藏類來追蹤物件形狀之後,讓我們深入探討這些屬性實際上是如何儲存的。正如上文介紹所述,屬性主要分為兩大類:命名屬性和索引屬性。以下部分將涵蓋命名屬性。
像 {a: 1, b: 2}
這樣的簡單物件可以在 V8 中有多種內部表示方式。儘管從外部看,JavaScript 物件的行為更像是簡單字典,V8 嘗試避免使用字典,因為字典會妨礙一些優化,例如 內聯快取(我們將在另一篇文章中解釋其內容)。
物件內部 vs 普通屬性: V8 支援所謂的物件內部屬性,這些屬性儲存在物件本身內部。這是 V8 中最高效的屬性,因為它們可以無需任何中間代碼直接存取。物件內部屬性的數量由物件的初始大小決定。如果新增了超出物件空間的屬性,它們將儲存在屬性存儲中。屬性存儲增添了一層間接配置,但可以獨立增長。
快速屬性 vs 慢速屬性: 下一個重要區分是快速屬性與慢速屬性之間的差別。通常,我們將儲存在線性屬性存儲中的屬性定義為 "快速屬性"。快速屬性可以通過存儲中的索引直接存取。要從屬性的名稱獲取到屬性存儲中的實際位置,我們需要參考隱藏類上的描述符陣列,如之前所述。
然而,如果物件中新增和刪除了許多屬性,那麼維護描述符陣列和隱藏類可能會產生大量的時間和記憶體開銷。因此,V8 也支援所謂的慢速屬性。具有慢速屬性的物件擁有一個內置字典作為屬性存儲。所有屬性元資訊不再儲存在隱藏類的描述符陣列中,而是直接儲存在屬性字典中。因此,可以在不更新隱藏類的情況下新增和移除屬性。由於內聯快取無法與字典屬性一同運作,因此字典屬性通常比快速屬性慢。
本節要點:
- 有三種類型的命名屬性:物件內部、快速和慢速/字典。
- 物件內部屬性直接儲存在物件本身內部,提供了最快的存取速度。
- 快速屬性存儲在屬性存儲區,所有的元信息存儲在 HiddenClass 的描述符數組中。
- 慢速屬性存儲在一個自包含的屬性字典中,元信息不再通過 HiddenClass 共享。
- 慢速屬性允許高效地移除和添加屬性,但訪問速度比其他兩種類型慢。
元素或數組索引屬性
到目前為止,我們已經看過了命名屬性,而忽略了通常用於數組的整數索引屬性。處理整數索引屬性的複雜性並不亞於命名屬性。即使所有的索引屬性總是分開存儲在元素存儲區中,但仍然有20種不同的元素類型!
Packed 或 Holey 元素: V8 做出的第一個主要區分是,支持存儲的元素是密集(Packed)的還是包含孔洞(Holey)的。如果你刪除了一個索引元素,或例如你沒有定義它,你便會得到一個有孔洞的支持存儲區。一個簡單的例子是 [1,,3]
,其中第二個條目是一個孔洞。以下例子說明了這個問題:
const o = ['a', 'b', 'c'];
console.log(o[1]); // 輸出 'b'。
delete o[1]; // 在元素存儲區中引入了一個孔洞。
console.log(o[1]); // 輸出 'undefined';屬性1不存在。
o.__proto__ = {1: 'B'}; // 在原型上定義屬性1。
console.log(o[0]); // 輸出 'a'。
console.log(o[1]); // 輸出 'B'。
console.log(o[2]); // 輸出 'c'。
console.log(o[3]); // 輸出 undefined。
簡而言之,如果接收對象上不存在某個屬性,我們不得不沿著原型鏈繼續查找。由於元素是自包含的,例如我們不在 HiddenClass 中存儲有關存在索引屬性的信息,因此我們需要一個特殊的值,稱為 _hole,以標記不存在的屬性。這對於數組函數的性能至關重要。如果我們確定沒有孔洞,也就是說元素存儲區是密集的,我們可以執行本地操作,而不需要昂貴的原型鏈查找。
快速或字典元素: 第二個在元素上進行的主要區分是,它們是快速的還是字典模式的。快速元素是簡單的 VM 內部數組,其中屬性索引映射到元素存儲區中的索引。然而,這種簡單的表示方式對於非常大的稀疏/有孔洞數組來說是相當浪費的,因為僅有少數條目被佔用。在這種情況下,我們使用基於字典的表示來節省記憶體,代價是訪問速度稍慢一些:
const sparseArray = [];
sparseArray[9999] = 'foo'; // 創建了一個有字典元素的數組。
在這個例子中,分配一個有10000個條目的完整數組將是相當浪費的。相反,V8 創建了一個字典,該字典中我們存儲鍵值-描述符三元組。在這種情況下,鍵將是 '9999'
,值是 'foo'
,並使用默認描述符。鑑於我們沒有辦法在 HiddenClass 上存儲描述符的細節,當你使用自定義的描述符定義索引屬性時,V8 會改用慢速元素:
const array = [];
Object.defineProperty(array, 0, {value: 'fixed', configurable: false});
console.log(array[0]); // 輸出 'fixed'。
array[0] = 'other value'; // 無法覆蓋索引0。
console.log(array[0]); // 仍輸出 'fixed'。
在這個例子中,我們在數組上添加了一個不可配置的屬性。此信息存儲在慢速元素字典三元組的描述符部分。需要注意的是,數組函數在具有慢速元素的對象上執行速度明顯較慢。
Smi 和 Double 元素: 對於快速元素,V8 還有另一個重要區分。例如,如果你僅在數組中存儲整數(這是一種常見的用例),GC 不需要查看數組,因為整數直接以所謂的小整數(Smis)形式就地編碼。另一種特殊情況是僅包含雙精度數字的數組。與 Smis 不同,浮點數通常表示為占用幾個字的完整對象。然而,V8 將原始雙精度數字存儲於純雙精度數組中,以避免記憶體和性能的開銷。以下例子列出了4個 Smi 和 Double 元素的例子:
const a1 = [1, 2, 3]; // Smi Packed
const a2 = [1, , 3]; // Smi Holey, a2[1] 從原型中讀取
const b1 = [1.1, 2, 3]; // Double Packed
const b2 = [1.1, , 3]; // Double Holey, b2[1] 從原型中讀取
特殊元素: 目前為止的內容涵蓋了20種不同元素類型中的7種。為了簡單起見,我們排除了 9 種 TypedArrays 的元素類型,2 種字串包裝器的類型,以及最後但同樣重要的,2 種用於 arguments 對象的特殊元素類型。
元素存取器(ElementsAccessor): 如你所想,我們不會願意在 C++ 中將 Array 函數重覆寫 20 次,對於每一種元素類型一次。這時候一些 C++ 的魔法就派上用場了。與其一次又一次地實現 Array 函數,我們構建了 ElementsAccessor
,在其中我們主要只需實現一些簡單的函數來從後備存儲中存取元素。ElementsAccessor
依賴 CRTP 來為每個 Array 函數創建專門版本。所以當你在數組上調用類似 slice
的操作時,V8 內部調用 C++ 編寫的內建函數並通過 ElementsAccessor
分派到函數的專門版本:
本節要點:
- 存在快速和字典模式的索引屬性及元素。
- 快速屬性可以是填充的,也可以包含孔洞,這表示某個索引屬性已被刪除。
- 元素根據其內容被專業化以加速 Array 函數並減少 GC 開銷。
了解屬性如何工作是 V8 許多優化的關鍵。對於 JavaScript 開發者而言,這些內部決策大多直接不可見,但它們解釋了為什麼某些代碼模式比其他模式更快。更改屬性或元素類型通常會導致 V8 創建不同的 HiddenClass,這可能導致類型污染並阻止 V8 生成最佳代碼。敬請期待後續文章,介紹 V8 的虛擬機器內部運作原理。