【譯】V8 引擎怎樣對屬性進行快速訪問

sunshine小小倩發表於2017-09-20

V8 引擎怎樣對屬性進行快速訪問

在這篇文章中我將要解釋 V8 引擎內部是如何處理 JavaScript 屬性的。從 JavaScript 的角度來看,屬性們區別並不大,JavaScript 物件表現形式更像是字典,字串作為鍵,任意物件作為值。ECMAScript 語言規範 中,物件的數字索引和其他型別索引在規範中沒有明確區分,但是在 V8 引擎內部卻不是這樣的。除此之外,不同屬性的行為基本相同,和他們可不可以進行整數索引沒有關係。

然而在 V8 引擎中屬性的不同表現形式確實會對效能和記憶體有影響,在這篇文章中我們來解析 V8 引擎是如何能夠在動態新增屬性時進行快速的屬性訪問的,理解屬性是如何工作的,以解釋 V8 引擎是如何的優化,(例如 內聯快取 )。

這篇文章解釋了處理整數索引屬性和命名屬性的不同之處,之後我們展示了 V8 中是如何為了提供一個快速的方式定義一個物件的模型在新增一個命名屬性時使用 HiddenClasses。然後,我們將繼續深入瞭解如何根據使用情況進行屬性名的命名優化,以便能夠快速訪問或者快速修改。在最後一節中,我們介紹 V8 如何處理整數索引屬性或陣列索引的詳細資訊。

命名屬性和元素

讓我們從分析一個非常簡單的物件開始,比如:{a: "foo", b: "bar"}。這個物件有兩個命名屬性,"a" 和 "b"。它沒有使用任何的整數索引作為屬性名。我們也可以使用索引訪問屬性,特別是物件為陣列的情況。例如,陣列 ["foo", "bar"] 有兩個可以使用陣列索引的屬性:索引為 0 的值是 "foo",索引為 1 的值是 "bar"

這是 V8 一般處理屬性的第一個主要區別。

下圖顯示了一個 JavaScript 的基本物件在記憶體中的樣子。

元素和屬性儲存在兩個獨立的資料結構中,這使得使用不同的模式新增和訪問屬性和元素將會更加高效。

元素主要用於各種 Array.prototype methods 例如 popslice。考慮到這些函式是在連續範圍儲存區域內訪問屬性的,V8 引擎內部大部分情況下也將他們表示為簡單的陣列。稍後我們將解釋如何使用一個稀疏的基於字典的表示來節省記憶體。

命名屬性的儲存類似於稀疏陣列的儲存。然而,與元素不同,我們不能簡單的使用鍵推斷其在屬性陣列中的位置,我們需要一些額外的後設資料。在 V8 中,每一個 JavaScript 物件都有一個相關聯的 HiddenClass。這個 HiddenClass 儲存了一個物件的模型資訊,在其他方面,有一個從屬性名到屬性索引對映。我們有時使用一個字典來代替簡單的陣列。我們專門會在一個章節中更詳細地解釋這一點。

本節重點:

  • 陣列索引屬性儲存在單獨的元素儲存區中。
  • 命名屬性儲存在屬性儲存區中。
  • 元素和屬性可以是陣列或字典。
  • 每個 JavaScript 物件有一個和物件的模型相關聯的 HiddenClass

HiddenClasses 和描述符陣列

在介紹了元素和命名屬性的大致區別之後,我們需要來看一下 HiddenClasses 在 V8 中是怎麼工作的。HiddenClass 儲存了一個物件的後設資料,包括物件和物件引用原型的數量。HiddenClasses 在典型的物件導向的程式語言的概念中和“類”類似。然而,在像 JavaScript 這樣的基於原型的程式語言中,一般不可能預先知道類。因此,在這種情況下,在 V8 引擎中,HiddenClasses 建立和更新屬性的動態變化。HiddenClasses 作為一個物件模型的標識,並且是 V8 引擎優化編譯器和內聯快取的一個非常重要的因素。通過 HiddenClass 可以保持一個相容的物件結構,這樣的話例項可以直接使用內聯的屬性。

讓我們來看一下 HiddenClass 的重點

在 V8 中,JavaScript 物件的第一部分就是指向 HiddenClass。(實際上,V8 中的任何物件都在堆中並且受垃圾回收器管理。)在屬性方面,最重要的資訊是第三段區域,它儲存屬性的數量,以及一個指向描述符陣列的指標。描述符陣列包含有關命名屬性的資訊,如名稱本身和儲存值的位置。注意,我們不在這裡跟蹤整數索引屬性,因此描述符陣列中沒有整數索引的條目。

關於 HiddenClasses 的基本假設是物件具有相同的結構,例如,相同的順序對應相同的屬性,共用相同的 HiddenClass。當我們給一個物件新增一個屬性的時候我們使用不同的 HiddenClass 實現。在下面的例子中,我們從一個空物件開始並且新增三個命名屬性。

每次加入一個新屬性時,物件的 HiddenClass 就會改變,在 V8 引擎的後臺會建立一個將 HiddenClass 連線在一起的轉移樹。V8 引擎就知道你新增的 HiddenClass 是哪一個了,例如,屬性 “a” 新增到一個空物件中,如果你以相同的順序新增相同的屬性,這個轉化樹會使用相同的 HiddenClass。下面的示例表明,即使在兩者之間新增簡單的索引屬性,我們也將遵循相同的轉換樹。

本節重點:

  • 結構相同的物件(相同的順序對於相同的屬性)有相同的 HiddenClasses。
  • 預設情況下,每新增一個新的命名屬性將產生了一個新的 HiddenClasses。
  • 增加陣列索引屬性並不創造新 HiddenClasses。

三種不同的命名屬性

在概述了 V8 引擎是如何使用 HiddenClasses 來追蹤物件的模型之後,我們來看一下這些屬性實際上是如何儲存的。正如上面介紹所介紹的,有兩種基本屬性:命名屬性和索引屬性。以下部分是命名屬性:

一個簡單的物件,例如 {a: 1, b: 2} 在 V8 引擎的內部有多種表現形式,雖然 JavaScript 物件或多或少的和外部的字典相似,V8 引擎仍然試圖避免和字典類似因為他們妨礙某些優化,例如 內聯快取,我們將在一篇單獨的文章中解釋。

In-object 屬性和一般屬性: V8 引擎支援直接儲存在所謂的 In-object 的屬性。這些是 V8 引擎中可用的最快速的屬性,因為他們可以直接訪問。In-object 屬性的數量是由物件的初始大小決定的。如果在物件中新增超出儲存空間的屬性,那麼他們會儲存在屬性儲存區中。屬性儲存多了一層間接定址但這是獨立的區域。

快屬性 VS 慢屬性: 下一個重要的區別來自於快屬性和慢屬性。通常,我們將儲存線上性屬性儲存區域的屬性稱為快屬性。快屬性僅通過屬性儲存區的索引訪問,為了在屬性儲存區的實際位置得到屬性的名字,我們必須通過在 HiddenClass 中的描述符陣列。

然而,從一個物件中新增或刪除多個屬性,會為了保持描述符陣列和 HiddenClasses 而產生大量的時間和記憶體的開銷。因此,V8 引擎也支援所謂的慢屬性,一個有慢屬性的物件有一個自包含的字典作為屬性儲存區。所有的屬性後設資料都不再儲存在 HiddenClass 的描述符陣列而是直接在屬性字典。因此,屬性可以新增和刪除不更新的 HiddenClass。由於內聯快取不使用字典屬性,後者通常比快速屬性慢。

本節重點:

  1. 有三種不同的命名屬性型別:物件、快字典和慢字典。
  • 在物件屬性中直接儲存在物件本身上,並提供最快的訪問速度。
  • 快屬性儲存在屬性儲存區,所有的後設資料儲存在 HiddenClass 的描述符陣列中。
  • 慢屬性儲存在自身的屬性字典中,後設資料不再儲存於 HiddenClass。
  1. 慢屬性允許高效的屬性刪除和新增,但訪問速度比其他兩種型別慢。

元素或陣列索引屬性

到目前為止,我們已經瞭解了命名屬性,在研究的過程中忽略陣列中常用的整數索引屬性。處理整數索引屬性並不比命名屬性簡單。雖然所有的索引屬性總是單獨存放在元素儲存中,但是有 20 種不同型別的元素!

元素是連續的的還是有預設的: V8 引擎的第一個主要區別是元素在儲存區是連續的還是有預設的。如果刪除索引元素,或者在不定義索引元素的情況下,就會在儲存區中有一個預設。一個簡單的例子是 [1,,3],第二個位置預設。下面的例子說明了這個問題:

const o = ["a", "b", "c"];
console.log(o[1]);          // 列印 "b".

delete o[1];                // 刪除一個屬性.
console.log(o[1]);          // 列印 "undefined"; 第二個屬性不存在
o.__proto__ = {1: "B"};     // 在原型上定義第二個屬性

console.log(o[0]);          // 列印 "a".
console.log(o[1]);          // 列印 "B".
console.log(o[2]);          // 列印
console.log(o[3]);          // 列印 undefined複製程式碼

簡言之,如果接收器上不存在屬性,我們必須繼續在原型鏈上查詢。如果元素是自包含的,我們不在 HiddenClass 中儲存有關當前索引的屬性,我們需要一個特殊的值,稱為 the_hole,來標記該位置的屬性是不存在的。這個陣列函式的效能是至關重要的。如果我們知道有沒有預設,即元素是連續的,我們可以不用昂貴代價來查詢原型鏈來進行本地操作。

快速元素和字典元素: 元素的第二個主要區別是它們是快速的還是字典模式的。快速元素是簡單的 VM 內部陣列,其中屬性索引對映到元素儲存區中的索引。然而,這種簡單的表示在稀疏陣列中是相當浪費的。在這種情況下,我們使用基於字典的表示來節省記憶體,以訪問速度稍微慢一些為代價:

const sparseArray = [];
sparseArray[1 << 20] = "foo"; // 使用字典元素建立一個陣列。複製程式碼

在這個例子中,如果分配一個 10K 的全排列會更浪費。所以取而代之的是 V8 建立的一個字典,我們在其中儲存三個一模一樣的鍵值描述符。本例中的鍵為 10000,值為“字串”還有一個預設描述符。因為我們沒有辦法在 HiddenClass 儲存區描述細節,在 V8 中 當你定義一個索引屬性與自定義描述符儲存在慢元素中:

const array = [];
Object.defineProperty(array, 0, {value: "fixed", configurable});
console.log(array[0]);      // 列印 "fixed".
array[0] = "other value";   // 不能重新第 1 個索引.
console.log(array[0]);      // 仍然列印 "fixed".複製程式碼

在這個例子中,我們在陣列上新增了一個 configurablefalse 的屬性。此資訊儲存在慢元素字典三元組的描述符部分中。需要注意的是,在慢元素物件上,陣列函式的執行速度要慢得多。

小整數和雙精度元素: 對於快速元素,V8中還有另一個重要的區別。例如,如果你只儲存整數陣列,一個常見的例子:GC 沒有接受陣列,因為整數直接編碼為所謂的小整數(SMIS)。另一個特例是陣列,它們只包含雙精度數。不像SMIS,浮點數通常表示為物件佔用的幾個字元。然而,V8 使用兩行來儲存純雙精度組,以避免記憶體和效能開銷。下面的示例列出了 SMI 和雙精度元素的 4 個示例:

const a1 = [1,   2, 3];  // Smi Packed
const a2 = [1,    , 3];  // Smi Holey, a2[1] reads from the prototype
const b1 = [1.1, 2, 3];  // Double Packed
const b2 = [1.1,  , 3];  // Double Holey, b2[1] reads from the prototype複製程式碼

特別的元素: 到目前為止,我們涵蓋了 20 種不同元素中的 7 種。為簡單起見,我們排除了 9 元種 陣列型別,兩個字串包裝等等,兩個引數物件。

ElementsAccessor: 你可以想象我們並不想為了每一種元素在 C++ 中寫 20 次陣列函式。這就是 C++ 的奇妙之處。為了代替一次又一次陣列函式的實現,我們在從後備儲存訪問元素建立了 ElementsAccessor 。ElementsAccessor 依賴 CRTP 建立每一個陣列函式的專業版。所以,如果你呼叫陣列中的一些方法例如 slice,將通過呼叫 V8 引擎的內部呼叫內建 C++ 編寫的,ElementsAccessor 的專業版:

本節重點:

  • 有快速模式和字典模式索引屬性和元素。
  • 快速屬性可以被打包並且他們可以包含被刪除索引屬性預設的標誌。
  • 陣列元素型別固定,以加速陣列函式並減少 GC 開銷,方便引擎優化。

瞭解屬性如何工作是在 V8 中許多優化的關鍵。對於 JavaScript 開發人員來說,這些內部決策中有很多是不可見的,但它們解釋了為什麼某些程式碼模式比其他程式碼模式更快。更改屬性或元素型別通常讓 V8 創造不同的 HiddenClass,阻礙 V8 優化的原因。敬請期待我以後的文章:V8 引擎 VM 內部是如何工作的。


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章