V8 中的快屬性
譯自: ofollow,noindex">Fast properties in V8 (2017-08-30)
本文我們將解釋 V8 內部如何處理 JavaScript 屬性(properties)。從 JavaScript 的角度來看屬性只有少數的區別。JavaScript 物件通常跟字典(dictionaris)差不多,以字串為鍵,任意物件為值。儘管規範確實對 迭代過程中 的整數索引屬性與其他屬性作了區分,但除此以外,不同屬性表現基本是一致的,不管是不是整數索引。
然而,在底層 V8 的確依賴了一些不同的屬性表示方式,這是出於效能和記憶體考慮的。本文我們將解釋 V8 如何在提供快屬性訪問的同時支援動態新增屬性。理解屬性如何工作對解釋 V8 內聯快取 之類的優化原理是必不可少的。
本文解釋了整數索引與命名屬性的不同處理方式。隨後我們展示了 V8 如何在新增命名屬性同時維護隱藏類(HiddenClasses),以提供快速的方式識別一個物件的形狀(shape)。接著我們繼續深入瞭解命名屬性是如何根據使用方式來進行優化,以支援快速訪問和快速修改。最後部分我們詳細介紹了 V8 如何處理整數索引屬性或陣列索引。
命名屬性與元素
首先我們來分析一個簡單的物件 {a: "foo", b: "bar"}
。這個物件有兩個命名屬性, "a"
和 "b"
,沒有任何整數索引作屬性名。陣列索引屬性(array-indexed properties),常叫元素(elements),在陣列中非常重要。比如陣列 ["foo", "bar"]
有兩個陣列索引屬性:0 與值 "foo" ,和 1 與值 "bar"。 這是 V8 如何處理屬性的第一個主要區別。
下圖展示了一個基本 JavaScript 物件在記憶體中是什麼樣的。

元素和屬性被儲存在兩個獨立的資料結構中。兩者的使用方式通常不一樣,故這能讓新增和訪問屬性或元素更加高效。
元素主要被用在各種 Array.prototype
方法 中,如 pop
或者 slice
。考慮到這些函式都是連續地訪問屬性,V8 內部還將它們表示為簡單的陣列,大多數情況下都是如此。本文後面還會解釋到有時我們如何切換到基於稀疏字典的表示來節省記憶體。
命名屬性也是通過類似的方式存在另外的陣列中。但是,跟元素不同,我們不能簡單地通過鍵來推斷出它們在屬性陣列中的位置,我們需要一些額外的元資料。在 V8 中每個 JavaScript 物件都關聯了一個隱藏類(HiddenClasses)。隱藏類儲存了物件的形狀資訊,以及從屬性名稱到屬性索引的對映。複雜的使用情況下我們有時會用字典來存屬性而不是簡單的陣列。我們會在專門的部分再作詳細地解釋。
本節要點:
- 陣列索引屬性被儲存在獨立的元素儲存中。
- 命名屬性被儲存在屬性儲存中。
- 元素和屬性可以是陣列或者字典。
- 每個 JavaScript 物件都有相應的隱藏類來記錄該物件的形狀資訊。
隱藏類和描述符陣列
在解釋了元素和命名屬性的一般區別之後,我們需要看看隱藏類在 V8 中是如何工作的。隱藏類儲存了與物件相關的元資訊,包括物件上的屬性數和物件原型的引用。隱藏類在概念上與典型面向物件程式語言中的類相似。但是,在基於原型的語言(如 JavaScript)中,通常不可能預先知道類。因此,在這種情況下,V8 的隱藏類是動態建立的,並隨著物件的變化而動態更新。隱藏類充當了物件形狀的識別符號,因此是 V8 優化編譯器和內聯快取非常重要的組成部分。比如優化編譯器可以直接內聯屬性訪問,如果它可以通過隱藏類來確保物件結構是相容的。
我們來看看隱藏類的重要部分。

在 V8 中,JavaScript 物件的第一個欄位指向隱藏類(事實上,任何在 V8 堆上且由垃圾收集器管理的物件都是這種情況)。在屬性中,最重要的資訊是第三位欄位,它儲存了屬性數以及描述符陣列的指標。描述符陣列包含了有關命名屬性的資訊,例如名稱本身以及值儲存的位置。注意我們不會在此處跟蹤整數索引屬性,故描述符陣列中沒有相關條目。
對於隱藏類的基本判斷標準是,具有相同結構的物件(如屬性命名相同且順序相同)共享相同的隱藏類。為了實現這一點,物件在新增屬性後我們將使用不同的隱藏類。在下面的示例中,我們從一個空物件開始並新增三個命名屬性。

每次新增新屬性時,物件的隱藏類都會被更改。在引擎的底層,V8 建立了一個將隱藏類連結在一起的轉換樹(transiton tree)。當你向一個空物件新增屬性(如“a”)時,V8 會知道要採用哪個隱藏類。如果以相同的順序新增相同的屬性,此轉換樹會確保最後得到的是相同的最終隱藏類。以下示例顯示,即使我們在其間添加了簡單的索引屬性,我們還是得到了相同的轉換樹。

然而,如果我們建立一個添加了不同屬性的新物件,如屬性 "d"
,則 V8 會為新的隱藏類建立一個單獨的分支。

本節要點:
- 具有相同結構的物件(相同順序相同屬性)具有相同的隱藏類。
- 預設情況下,每新增新的命名屬性都會導致一個新的隱藏類被建立。
- 新增陣列索引屬性不會建立新的隱藏類。
三種不同的命名屬性
在概述了 V8 如何使用隱藏類跟蹤物件的形狀之後,讓我們深入瞭解這些屬性的實際儲存方式。正如上面介紹中所解釋的,屬性有兩種基本型別:命名和索引。以下部分先介紹命名屬性。
一個簡單的物件如 {a:1,b:2}
在 V8 中可以有多種內部表示。雖然 JavaScript 物件在外部看來或多或少類似於簡單的字典,但 V8 試圖避免使用字典,因為它們妨礙了某些優化,如 內聯快取 ,我們會在其它文章中再作解釋。
物件與普通屬性:V8 支援所謂的物件內屬性(in-object properties),指這些屬性直接儲存在物件本身上。它們在 V8 可用的屬性中是最快的,因為它們不需要間接層就可以訪問。物件內屬性的數量由物件的初始大小預先確定。如果新增的屬性超出了物件分配的空間,則它們將被儲存在屬性儲存中。屬性儲存多了一層間接層,但可以自由地擴容。

快屬性與慢屬性:下一個重要區別是快屬性和慢屬性。通常,我們將儲存線上性屬性儲存中的屬性定義為“快”。只需通過屬性儲存中的索引即可訪問快屬性。要從屬性名稱獲取屬性儲存中的實際位置,我們必須檢視隱藏類上的描述符陣列,如前面所述。

但是,如果從物件中新增和刪除大量屬性,則可能會產生大量時間和記憶體開銷來維護描述符陣列和隱藏類。因此 V8 還支援所謂的慢屬性。帶慢屬性的物件內部會有獨立的詞典作為屬性儲存。所有的屬性元資訊不再儲存在隱藏類的描述符陣列中,而是直接儲存在屬性字典中。因此無需更新隱藏類即可新增和刪除屬性。由於內聯快取不適用於字典屬性,故後者通常比快屬性慢。
本節要點:
- 有三種不同的命名屬性型別:物件內屬性、快屬性和慢屬性(字典)。
- 物件屬性直接儲存在物件本身上,並提供最快的訪問。
- 快屬性儲存在屬性儲存中,所有元資訊都儲存在隱藏類的描述符陣列中。
- 慢屬性儲存在內部獨立的屬性字典中,不再通過隱藏類共享元資訊。
- 慢屬性允許高效的屬性刪除和新增,但訪問速度比其它兩種型別慢。
元素與陣列索引屬性
到目前為止,我們一直在討論命名屬性,而忽略了常用於陣列的整數索引屬性。處理整數索引屬性並不比命名屬性簡單。儘管所有索引屬性都是在元素儲存中單獨儲存,但不同型別的元素也有 20 種!
擠滿的還是帶空隙的元素:V8 做的第一個主要區分看是元素後備儲存(elements backing store)是擠滿的(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

簡而言之,如果接收方(receiver)上沒有發現屬性,我們就必須沿著原型鏈繼續查詢。鑑於元素是內部獨立的(比如,我們不會在隱藏類上儲存有關當前索引屬性的資訊),我們需要一個特殊值,稱為 _hole,來標記不存在的屬性。這對於陣列函式的效能是至關重要的。如果我們知道沒有空隙,即元素儲存是擠滿的,我們就可以直接在當前域執行操作而無需沿著原型鏈做昂貴的查詢。
快元素還是字典元素:V8 對元素做的第二個主要區分是看它們是在快速模式還是字典模式。快元素是簡單的虛擬機器內部陣列,其中的屬性索引對映到元素儲存中的索引。然而,這種簡單的表示對於非常大的稀疏/帶空隙的陣列而言是相當浪費的,其中只有很少的條目被佔用。在這種情況下,我們使用基於字典的表示來節省記憶體,但代價是訪問速度稍慢:
const sparseArray = []; sparseArray[9999] = 'foo'; // 建立了一個基於字典元素的陣列
在這個例子中,開闢一個包含一萬條目的完整陣列會相當浪費。相反,V8 會建立一個字典來儲存鍵-值-描述符三元組。在這個例子中,鍵是 '9999'
,值是 'foo'
,預設描述符被使用。由於我們沒有辦法在隱藏類上儲存描述符的詳細資訊,故只要你使用了自定義描述符去定義索引屬性,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'。
在這個例子中,我們在陣列上添加了一個不可配置的屬性。該資訊儲存在慢元素字典三元組的描述符部分中。需要注意的是,在帶有慢元素的物件上陣列函式的執行速度會慢很多。
小整數和雙浮點元素:對於快元素,V8 中還有另一個重要的區分。比如一個常見的例子,只在一個數組中放整數,那麼垃圾回收器就不必檢視陣列,因為整數被直接編碼為所謂的小整數(Smis)。另一個特例是隻包含雙浮點數(double)的陣列。與小整數不同,浮點數通常表示為佔據多個字(word)的完整物件。然而,在 V8 中,純雙浮點數的陣列會使用原生雙精度浮點數來儲存,以減少記憶體和效能開銷。以下例子列出了小整數和雙浮點元素的四個示例:
const a1 = [1,2, 3];// 擠滿小整數 const a2 = [1,, 3];// 帶空隙小整數,a2[1] 從原型中讀取 const b1 = [1.1, 2, 3];// 擠滿雙浮點 const b2 = [1.1,, 3];// 帶空隙雙浮點,b2[1] 從原型中讀取
特殊元素:到目前為止,我們已經涵蓋了 20 種不同元素中的 7 種。為簡單起見,我們排除了 9 種 TypedArrays 相關的元素型別,2 種 String 包裝器相關的,還有 2 種引數物件相關的特殊型別。
元素訪問器:正如你所想的,我們並不太熱衷於在 C++ 中給 元素種類 一個個對應地寫 20 多遍陣列函式。這裡就需要一些 C++ 魔術。我們不是一遍又一遍地實現陣列函式,而是構建了元素訪問器 ElementsAccessor
,這樣我們大多情況下只需要實現簡單的函式來從後備儲存中訪問元素。 ElementsAccessor
依賴於 CRTP 來建立每個陣列函式的專用版本。因此,如果在陣列上呼叫類似 slice
的方法,V8 內部會呼叫 C++ 編寫的內建函式,並通過 ElementsAccessor
排程到函式的專用版本:

本節要點:
- 索引屬性和元素有快速和字典模式。
- 快屬性可以是擠滿的(packed),也可以包含空隙(holes)表示索引屬性被刪除。
- 不同型別的元素根據其內容專門優化,以加速陣列函式並減少垃圾回收器開銷。
瞭解屬性如何工作是 V8 中許多優化的關鍵。對於 JavaScript 開發者來說,許多這樣的內部決策都不是直接可見的,但它們解釋了為什麼某些程式碼模式比其它的更快。更改屬性或元素型別通常會導致 V8 建立一個不同的隱藏類,這可能導致型別汙染 阻止 V8 生成最優程式碼 。請繼續關注有關 V8 虛擬機器內部工作原理的更多文章。