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

簡評:這篇文章解釋 V8 引擎內部是如何處理 JavaScript 屬性的,解析 V8 引擎是如何能夠在動態添加屬性時進行快速的屬性訪問的,理解屬性是如何工作的,以解釋 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 例如 pop 或 slice。考慮到這些函數是在連續範圍存儲區域內訪問屬性的,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"];nconsole.log(o[1]); // 列印 "b".nndelete o[1]; // 刪除一個屬性.nconsole.log(o[1]); // 列印 "undefined"; 第二個屬性不存在no.__proto__ = {1: "B"}; // 在原型上定義第二個屬性nnconsole.log(o[0]); // 列印 "a".nconsole.log(o[1]); // 列印 "B".nconsole.log(o[2]); // 列印nconsole.log(o[3]); // 列印 undefinedn

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

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

const sparseArray = [];nsparseArray[1 << 20] = "foo"; // 使用字典元素創建一個數組。n

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

const array = [];nObject.defineProperty(array, 0, {value: "fixed", configurable});nconsole.log(array[0]); // 列印 "fixed".narray[0] = "other value"; // 不能重新第 1 個索引.nconsole.log(array[0]); // 仍然列印 "fixed".n

在這個例子中,我們在數組上添加了一個 configurable 為 false 的屬性。此信息存儲在慢元素字典三元組的描述符部分中。需要注意的是,在慢元素對象上,數組函數的執行速度要慢得多。

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

const a1 = [1, 2, 3]; // Smi Packednconst a2 = [1, , 3]; // Smi Holey, a2[1] reads from the prototypenconst b1 = [1.1, 2, 3]; // Double Packednconst b2 = [1.1, , 3]; // Double Holey, b2[1] reads from the prototypen

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

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

本節重點:

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

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


推薦閱讀:

產品經理技術報2:PM能學一些簡單的編程語言么?
我準備取關前端開發這個話題了
【 js 基礎 】【 源碼學習 】源碼設計 (更新了backbone分析)
sublime text3如何在瀏覽器預覽?

TAG:稀土掘金 | 前端开发 |