V8 是怎麼跑起來的 —— V8 中的對象表示

本文創做於 2019-04-30,2019-12-20 遷移至此
本文基於 Chrome 73 進行測試。

20190430123059

前言

V8,多是前端開發人員熟悉而又陌生的領域。html

當你看到這篇文章時,它已經迭代了三版了。目的只有一個,在保證儘量準確的前提下,用更直觀的方式呈現出來,讓你們更加容易接受。本文不須要太多的預備知識,只須要你對 JavaScript 對象有基本的瞭解。前端

爲了讓文章不那麼枯燥,也爲了證明觀點的準確性,文章中包含了不少的小實驗,你們能夠在控制檯中盡情把玩。git

預備知識 —— 在 Chrome 中查看內存快照

首先咱們在控制檯運行這樣一段程序。程序員

function Food(name, type) {
  this.name = name;
  this.type = type;
}
var beef = new Food('beef', 'meat');

切換到 Memory 中,點擊左側的小圈圈就能夠捕獲當前的內存快照。github

20190428093741

經過構造函數建立對象,主要是爲了更方便地在快照中找到它。點開快照後,在過濾器中輸入 Food 就能夠找到由 Food 構造的全部對象了,神奇吧。算法

20190428094209

V8 中對象的結構

在 V8 中,對象主要由三個指針構成,分別是隱藏類(Hidden Class),Property 還有 Element編程

20190424091206

其中,隱藏類用於描述對象的結構。PropertyElement 用於存放對象的屬性,它們的區別主要體如今鍵名可否被索引。數組

Property 與 Element

// 可索引屬性會被存儲到 Elements 指針指向的區域
{ 1: "a", 2: "b" }

// 命名屬性會被存儲到 Properties 指針指向的區域
{ "first": 1, "second": 2 }

事實上,這是爲了知足 ECMA 規範 要求所進行的設計。按照規範中的描述,可索引的屬性應該按照索引值大小升序排列,而命名屬性根據建立的順序升序排列。瀏覽器

咱們來作個簡單的小實驗。網絡

var a = { 1: "a", 2: "b", "first": 1, 3: "c", "second": 2 }

var b = { "second": 2, 1: "a", 3: "c", 2: "b", "first": 1 }

console.log(a) 
// { 1: "a", 2: "b", 3: "c", first: 1, second: 2 }

console.log(b)
// { 1: "a", 2: "b", 3: "c", second: 2, first: 1 }

a 和 b 的區別在於 a 以一個可索引屬性開頭,b 以一個命名屬性開頭。在 a 中,可索引屬性升序排列,命名屬性先有 first 後有 second。在 b 中,可索引屬性亂序排列,命名屬性先有 second 後有 first

能夠看到

  • 索引的屬性按照索引值大小升序排列,而命名屬性根據建立的順序升序排列。
  • 在同時使用可索引屬性和命名屬性的狀況下,控制檯打印的結果中,兩種不一樣屬性之間存在的明顯分隔。
  • 不管是可索引屬性仍是命名屬性先聲明,在控制檯中老是以相同的順序出現(在個人瀏覽器中,可索引屬性老是先出現)。

這兩點均可以從側面印證這兩種屬性是分開存儲的。

側面印證完了,咱們來看看正面。咱們用預備知識中的方法,查看這兩種屬性的快照。

// 實驗1 可索引屬性和命名屬性的存放
function Foo1 () {}
var a = new Foo1()
var b = new Foo1()

a.name = 'aaa'
a.text = 'aaa'
b.name = 'bbb'
b.text = 'bbb'

a[1] = 'aaa'
a[2] = 'aaa'

20190428102103

a、b 都有命名屬性 nametext,此外 a 還額外多了兩個可索引屬性。從快照中能夠明顯的看到,可索引屬性是存放在 Elements 中的,此外,a 和 b 具備相同的結構(這個結構會在下文中介紹)。

你可能會有點好奇,這兩個對象的屬性不同,怎麼會有相同的結構呢?要理解這個問題,首先能夠問本身三個問題。

  • 爲何要把對象存起來?固然是爲了以後要用呀。
  • 要用的時候須要作什麼?找到這個屬性咯。
  • 描述結構是爲了作什麼呢?按圖索驥,方便查找呀。

那麼,對於可索引屬性來講,它自己已是有序地進行排列了,咱們爲何還要屢次一舉經過它的結構去查找呢。既然不用經過它的結構查找,那麼咱們也不須要再去描述它的結構了是吧。這樣,應該就不難理解爲何 ab 具備相同的結構了,由於它們的結構中只描述了它們都具備 nametext 這樣的狀況。

固然,這也是有例外的。咱們在上面的代碼中再加入一行。

a[1111] = 'aaa'

20190428102317

能夠看到,此時隱藏類發生了變化,Element 中的數據存放也變得沒有規律了。這是由於,當咱們添加了 a[1111] 以後,數組會變成稀疏數組。爲了節省空間,稀疏數組會轉換爲哈希存儲的方式,而再也不是用一個完整的數組描述這塊空間的存儲。因此,這幾個可索引屬性也不能再直接經過它的索引值計算得出內存的偏移量。至於隱藏類發生變化,多是爲了描述 Element 的結構發生改變(這個圖片能夠與下文中慢屬性的配圖進行比較,能夠看到 Foo1 的 Property 並無退化爲哈希存儲,只是 Element 退化爲哈希存儲致使隱藏類發生改變)。

命名屬性的不一樣存儲方式

V8 中命名屬性有三種的不一樣存儲方式:對象內屬性(in-object)、快屬性(fast)和慢屬性(slow)。

20190429164509

  • 對象內屬性保存在對象自己,提供最快的訪問速度。
  • 快屬性比對象內屬性多了一次尋址時間。
  • 慢屬性與前面的兩種屬性相比,會將屬性的完整結構存儲(另外兩種屬性的結構會在隱藏類中描述,隱藏類將在下文說明),速度最慢(在下文或其它相關文章中,慢屬性、屬性字典、哈希存儲說的都是一回事)。

這樣是否是有點抽象。別急,咱們經過一個例子來講明。

// 實驗2 三種不一樣類型的 Property 存儲模式
function Foo2() {}

var a = new Foo2()
var b = new Foo2()
var c = new Foo2()

for (var i = 0; i < 10; i ++) {
  a[new Array(i+2).join('a')] = 'aaa'
}

for (var i = 0; i < 12; i ++) {
  b[new Array(i+2).join('b')] = 'bbb'
}

for (var i = 0; i < 30; i ++) {
  c[new Array(i+2).join('c')] = 'ccc'
}

a、b 和 c 分別擁有 10 個,12 個和 30 個屬性,在目前的 Chrome 73 版本中,分別會以對象內屬性、對象內屬性 + 快屬性、慢屬性三種方式存儲。這塊的運行快照有點長,咱們分別看一看。

對象內屬性和快屬性

首先咱們看一下 a 和 b。從某種程度上講,對象內屬性和快屬性其實是一致的。只不過,對象內屬性是在對象建立時就固定分配的,空間有限。在個人實驗條件下,對象內屬性的數量固定爲十個,且這十個空間大小相同(能夠理解爲十個指針)。當對象內屬性放滿以後,會以快屬性的方式,在 properties 下按建立順序存放。相較於對象內屬性,快屬性須要額外多一次 properties 的尋址時間,以後即是與對象內屬性一致的線性查找。

慢屬性

<img width="616" alt="20190430082222" src="https://user-images.githubusercontent.com/22996287/57186244-38855c00-6f0e-11e9-9497-0afef050623b.png">

接着咱們來看看 c。這個實在是太長了,只截取了一部分。能夠看到,和 b (快屬性)相比,properties 中的索引變成了毫無規律的數,意味着這個對象已經變成了哈希存取結構了。

因此,問題來了,爲何要分這麼幾種存儲方式呢?我來講說個人理解。

爲何要分三種存儲方式?(我的理解)

這實際上是在公司內部分享的時候,有同窗提出的問題。我相信你們讀到這裏的時候也會有相似的疑惑。當時的我也並不能很好的解釋爲何,直到我看到一張哈希存儲的圖(圖片來自於網絡)。

20190430083653

在 V8 裏,一切看似匪夷所思的優化,最根本的緣由就是爲了更快。—— 本人

能夠這麼看,早期的 JS 引擎都是用慢屬性存儲,前二者都是出於優化這個存儲方式而出現的。

咱們知道,全部的數據在底層都會表示爲二進制。咱們又知道,若是程序邏輯只涉及二進制的位運算(包含與、或、非),速度是最快的。下面咱們忽略尋址的等方面的耗時,單純從計算的次數來比較這三種(兩類)方式。

對象內屬性和快屬性作的事情很簡單,線性查找每個位置是否是指定的位置,這部分的耗時能夠理解爲至多 N 次簡單位運算(N 爲屬性的總數)的耗時。而慢屬性須要先通過哈希算法計算。這是一個複雜運算,時間上若干倍於簡單位運算。另外,哈希表是個二維空間,因此經過哈希算法計算出其中一維的座標後,在另外一維上仍須要線性查找。因此,當屬性很是少的時候爲何不用慢屬性應該就不難理解了吧。

附上一段 V8 中字符串的哈希算法,其中光是左移和右移就有 60 次(60 次簡單位運算)。

// V8 中字符串的哈希值生成器
uint32_t StringHasher::GetHashCore(uint32_t running_hash) {
  running_hash += (running_hash << 3);
  running_hash ^= (running_hash >> 11);
  running_hash += (running_hash << 15);
  int32_t hash = static_cast<int32_t>(running_hash & String::kHashBitMask);
  int32_t mask = (hash - 1) >> 31;
  return running_hash | (kZeroHash & mask);
}

那爲何不一直用對象內屬性或快屬性呢?

這是由於屬性太多的時候,這兩種方式可能就沒有慢屬性快了。假設哈希運算的代價爲 60 次簡單位運算,哈希算法的表現良好。若是隻用對象內屬性或快屬性的方式存,當我須要訪問第 120 個屬性,就須要 120 次簡單位運算。而使用慢屬性,咱們須要一次哈希計算(60 次簡單位運算)+ 第二維的線性比較(遠小於 60 次,已假設哈希算法表現良好,那屬性在哈希表中是均勻分佈的)。

單方面友情推薦程序員小灰的《漫畫:什麼是HashMap?》

隱藏類

上面提到的描述命名屬性是怎麼存放的,也就是 「按圖索驥」 中的 「圖」,在 V8 中被稱爲 Map,更出名的稱呼是隱藏類(Hidden Class)。

在 SpiderMonkey (火狐引擎)中,相似的設計被稱爲 Shape。

爲何要引入隱藏類?

首先固然是更快。

JavaScript 是一門動態編程語言,它容許開發者使用很是靈活的方式定義對象。對象能夠在運行時改變類型,添加或刪除屬性。相比之下,像 Java 這樣的靜態語言,類型一旦建立變不可更改,屬性能夠經過固定的偏移量進行訪問。

前面也提到,經過哈希表的方式存取屬性,須要額外的哈希計算。爲了提升對象屬性的訪問速度,實現對象屬性的快速存取,V8 中引入了隱藏類。

隱藏類引入的另一個意義,在於大大節省了內存空間。

在 ECMAScript 中,對象屬性的 Attribute 被描述爲如下結構。

  • [[Value]]:屬性的值
  • [[Writable]]:定義屬性是否可寫(便是否能被從新分配)
  • [[Enumerable]]:定義屬性是否可枚舉
  • [[Configurable]]:定義屬性是否可配置(刪除)

20190422092423

隱藏類的引入,將屬性的 Value 與其它 Attribute 分開。通常狀況下,對象的 Value 是常常會發生變更的,而 Attribute 是幾乎不怎麼會變的。那麼,咱們爲何要重複描述幾乎不會改變的 Attribute 呢?顯然這是一種內存浪費。

隱藏類的建立

對象建立過程當中,每添加一個命名屬性,都會對應一個生成一個新的隱藏類。在 V8 的底層實現了一個將隱藏類鏈接起來的轉換樹,若是以相同的順序添加相同的屬性,轉換樹會保證最後獲得相同的隱藏類。

下面的例子中,a 在空對象時、添加 name 屬性後、添加 text 屬性後會分別對應不一樣的隱藏類。

// 實驗3 隱藏類的建立
let a = {}
a.name = 'thorn1'
a.text = 'thorn2'

下面是建立過程的示意圖(僅描述過程,具體細節可能與實際實現有略微差別)。

<img width="676" alt="20190430101709" src="https://user-images.githubusercontent.com/22996287/57186252-50f57680-6f0e-11e9-9e6b-de6a3387e2da.png">

經過內存快照,咱們也能夠看到,Hidden Class 1 和 Hidden Class2 是不一樣的,而且後者的 back_pointer 指針指向前者,這也證明了上圖中的流程分析。

<img width="655" alt="20190430101520" src="https://user-images.githubusercontent.com/22996287/57186255-65397380-6f0e-11e9-838a-6d46852fc1bc.png">

有的文章中提到,在實際存儲中,每次添加屬性時,新建立隱藏類實際上只會描述這個新添加的屬性,而不會描述全部屬性,也就是 Hidden Class 2 中實際上只會描述 text,沒有 name。這點本人暫時沒有經過內存快照的方式驗證(流下了沒有技術的眼淚),但從邏輯上分析應該是這樣的。

此處還有一個小小的知識點。

// 實驗4 隱藏類建立時的優化
let a = {};
a.name = 'thorn1'
let b = { name: 'thorn2' }

<img width="692" alt="20190430095031" src="https://user-images.githubusercontent.com/22996287/57186256-75e9e980-6f0e-11e9-8c3f-ff6aa5d44e10.png">

a 和 b 的區別是,a 首先建立一個空對象,而後給這個對象新增一個命名屬性 name。而 b 中直接建立了一個含有命名屬性 name 的對象。從內存快照咱們能夠看到,a 和 b 的隱藏類不同,back_pointer 也不同。這主要是由於,在建立 b 的隱藏類時,省略了爲空對象單首創建隱藏類的一步。因此,要生成相同的隱藏類,更爲準確的描述是 —— 從相同的起點,以相同的順序,添加結構相同的屬性(除 Value 外,屬性的 Attribute 一致)。

若是對隱藏類的建立特別特別感興趣,單方面友情推薦知乎 @hijiangtao 的譯做《JavaScript 引擎基礎:Shapes 和 Inline Caches》

神奇的 delete 操做

上面咱們討論了增長屬性對隱藏類的影響,下面咱們來看看一下刪除操做對於隱藏類的影響。

// 實驗5 delete 操做的影響
function Foo5 () {}
var a = new Foo5()
var b = new Foo5()

for (var i = 1; i < 8; i ++) {
  a[new Array(i+1).join('a')] = 'aaa'
  b[new Array(i+1).join('b')] = 'bbb'
}

delete a.a

<img width="540" alt="20190430122021" src="https://user-images.githubusercontent.com/22996287/57186258-87cb8c80-6f0e-11e9-8180-3625aa63df58.png">

按照咱們以前試驗的,a 和 b 自己都是對象內屬性。從快照能夠看到,刪除了 a.a 後,a 變成了慢屬性,退回哈希存儲。

可是,若是咱們按照添加屬性的順序逆向刪除屬性,狀況會有所不一樣。

// 實驗6 按添加順序刪除屬性
function Foo6 () {}
var a = new Foo6()
var b = new Foo6()

a.name = 'aaa'
a.color= 'aaa'
a.text = 'aaa'

b.name = 'bbb'
b.color = 'bbb'

delete a.text

<img width="322" alt="20190430122835" src="https://user-images.githubusercontent.com/22996287/57186260-9619a880-6f0e-11e9-98c6-13ea3c93afe9.png">

咱們給 a 和 b 按相同屬性添加相同的屬性 namecolor,再給 a 額外添加一個屬性 text,而後刪除這個屬性。能夠發現,此時 a 和 b 的隱藏類相同,a 也沒有退回哈希存儲。

結論與啓示

  • 屬性分爲命名屬性和可索引屬性,命名屬性存放在 Properties 中,可索引屬性存放在 Elements 中。
  • 命名屬性有三種不一樣的存儲方式:對象內屬性、快屬性和慢屬性,前二者經過線性查找進行訪問,慢屬性經過哈希存儲的方式進行訪問。
  • 老是以相同的順序初始化對象成員,能充分利用相同的隱藏類,進而提升性能。
  • 增長或刪除可索引屬性,不會引發隱藏類的變化,稀疏的可索引屬性會退化爲哈希存儲。
  • delete 操做可能會改變對象的結構,致使引擎將對象的存儲方式降級爲哈希表存儲的方式,不利於 V8 的優化,應儘量避免使用(當沿着屬性添加的反方向刪除屬性時,對象不會退化爲哈希存儲)。

相關連接

參考資料

相關文章
相關標籤/搜索