V8 是怎么跑起來的 —— V8 中的對象表示
本文創作於 2019-04-30,2019-12-20 遷移至此
本文基於 Chrome 73 進行測試。
前言
V8,可能是前端開發人員熟悉而又陌生的領域。
當你看到這篇文章時,它已經迭代了三版了。目的只有一個,在保證盡可能准確的前提下,用更直觀的方式呈現出來,讓大家更加容易接受。本文不需要太多的預備知識,只需要你對 JavaScript 對象有基本的了解。
為了讓文章不那么枯燥,也為了證實觀點的准確性,文章中包含了很多的小實驗,大家可以在控制台中盡情把玩。
預備知識 —— 在 Chrome 中查看內存快照
首先我們在控制台運行這樣一段程序。
function Food(name, type) {
this.name = name;
this.type = type;
}
var beef = new Food('beef', 'meat');
切換到 Memory 中,點擊左側的小圈圈就可以捕獲當前的內存快照。

通過構造函數創建對象,主要是為了更方便地在快照中找到它。點開快照后,在過濾器中輸入 Food
就可以找到由 Food
構造的所有對象了,神奇吧。

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

其中,隱藏類用於描述對象的結構。Property
和 Element
用於存放對象的屬性,它們的區別主要體現在鍵名能否被索引。
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'

a、b 都有命名屬性 name
和 text
,此外 a 還額外多了兩個可索引屬性。從快照中可以明顯的看到,可索引屬性是存放在 Elements
中的,此外,a 和 b 具有相同的結構(這個結構會在下文中介紹)。
你可能會有點好奇,這兩個對象的屬性不一樣,怎么會有相同的結構呢?要理解這個問題,首先可以問自己三個問題。
- 為什么要把對象存起來?當然是為了之后要用呀。
- 要用的時候需要做什么?找到這個屬性咯。
- 描述結構是為了做什么呢?按圖索驥,方便查找呀。
那么,對於可索引屬性來說,它本身已經是有序地進行排列了,我們為什么還要多次一舉通過它的結構去查找呢。既然不用通過它的結構查找,那么我們也不需要再去描述它的結構了是吧。這樣,應該就不難理解為什么 a
和 b
具有相同的結構了,因為它們的結構中只描述了它們都具有 name
和 text
這樣的情況。
當然,這也是有例外的。我們在上面的代碼中再加入一行。
a[1111] = 'aaa'

可以看到,此時隱藏類發生了變化,Element
中的數據存放也變得沒有規律了。這是因為,當我們添加了 a[1111]
之后,數組會變成稀疏數組。為了節省空間,稀疏數組會轉換為哈希存儲的方式,而不再是用一個完整的數組描述這塊空間的存儲。所以,這幾個可索引屬性也不能再直接通過它的索引值計算得出內存的偏移量。至於隱藏類發生變化,可能是為了描述 Element
的結構發生改變(這個圖片可以與下文中慢屬性的配圖進行比較,可以看到 Foo1 的 Property
並沒有退化為哈希存儲,只是 Element
退化為哈希存儲導致隱藏類發生改變)。
命名屬性的不同存儲方式
V8 中命名屬性有三種的不同存儲方式:對象內屬性(in-object)、快屬性(fast)和慢屬性(slow)。

- 對象內屬性保存在對象本身,提供最快的訪問速度。
- 快屬性比對象內屬性多了一次尋址時間。
- 慢屬性與前面的兩種屬性相比,會將屬性的完整結構存儲(另外兩種屬性的結構會在隱藏類中描述,隱藏類將在下文說明),速度最慢(在下文或其它相關文章中,慢屬性、屬性字典、哈希存儲說的都是一回事)。
這樣是不是有點抽象。別急,我們通過一個例子來說明。
// 實驗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
的尋址時間,之后便是與對象內屬性一致的線性查找。
慢屬性

接着我們來看看 c。這個實在是太長了,只截取了一部分。可以看到,和 b (快屬性)相比,properties
中的索引變成了毫無規律的數,意味着這個對象已經變成了哈希存取結構了。
所以,問題來了,為什么要分這么幾種存儲方式呢?我來說說我的理解。
為什么要分三種存儲方式?(個人理解)
這其實是在公司內部分享的時候,有同學提出的問題。我相信大家讀到這里的時候也會有類似的疑惑。當時的我也並不能很好的解釋為什么,直到我看到一張哈希存儲的圖(圖片來自於網絡)。

在 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]]
:定義屬性是否可配置(刪除)

隱藏類的引入,將屬性的 Value
與其它 Attribute
分開。一般情況下,對象的 Value 是經常會發生變動的,而 Attribute
是幾乎不怎么會變的。那么,我們為什么要重復描述幾乎不會改變的 Attribute
呢?顯然這是一種內存浪費。
隱藏類的創建
對象創建過程中,每添加一個命名屬性,都會對應一個生成一個新的隱藏類。在 V8 的底層實現了一個將隱藏類連接起來的轉換樹,如果以相同的順序添加相同的屬性,轉換樹會保證最后得到相同的隱藏類。
下面的例子中,a 在空對象時、添加 name
屬性后、添加 text
屬性后會分別對應不同的隱藏類。
// 實驗3 隱藏類的創建
let a = {}
a.name = 'thorn1'
a.text = 'thorn2'
下面是創建過程的示意圖(僅描述過程,具體細節可能與實際實現有略微差異)。

通過內存快照,我們也可以看到,Hidden Class 1 和 Hidden Class2 是不同的,並且后者的 back_pointer
指針指向前者,這也證實了上圖中的流程分析。

有的文章中提到,在實際存儲中,每次添加屬性時,新創建隱藏類實際上只會描述這個新添加的屬性,而不會描述所有屬性,也就是 Hidden Class 2 中實際上只會描述text
,沒有name
。這點本人暫時沒有通過內存快照的方式驗證(流下了沒有技術的眼淚),但從邏輯上分析應該是這樣的。
此處還有一個小小的知識點。
// 實驗4 隱藏類創建時的優化
let a = {};
a.name = 'thorn1'
let b = { name: 'thorn2' }

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

按照我們之前試驗的,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

我們給 a 和 b 按相同屬性添加相同的屬性 name
和 color
,再給 a 額外添加一個屬性 text
,然后刪除這個屬性。可以發現,此時 a 和 b 的隱藏類相同,a 也沒有退回哈希存儲。
結論與啟示
- 屬性分為命名屬性和可索引屬性,命名屬性存放在
Properties
中,可索引屬性存放在Elements
中。 - 命名屬性有三種不同的存儲方式:對象內屬性、快屬性和慢屬性,前兩者通過線性查找進行訪問,慢屬性通過哈希存儲的方式進行訪問。
- 總是以相同的順序初始化對象成員,能充分利用相同的隱藏類,進而提高性能。
- 增加或刪除可索引屬性,不會引起隱藏類的變化,稀疏的可索引屬性會退化為哈希存儲。
- delete 操作可能會改變對象的結構,導致引擎將對象的存儲方式降級為哈希表存儲的方式,不利於 V8 的優化,應盡可能避免使用(當沿着屬性添加的反方向刪除屬性時,對象不會退化為哈希存儲)。
相關鏈接
參考資料
V8 是怎么跑起來的 —— V8 中的對象表示V8 是怎么跑起來的 —— V8 中的對象表示ThornWuThornWuThe best is yet to come30 人贊同了該文章本文創作於 2019-04-30,2019-12-20 遷移至此本文基於 Chrome 73 進行測試。前言V8,可能是前端開發人員熟悉而又陌生的領域。
當你看到這篇文章時,它已經迭代了三版了。目的只有一個,在保證盡可能准確的前提下,用更直觀的方式呈現出來,讓大家更加容易接受。本文不需要太多的預備知識,只需要你對 JavaScript 對象有基本的了解。
為了讓文章不那么枯燥,也為了證實觀點的准確性,文章中包含了很多的小實驗,大家可以在控制台中盡情把玩。
預備知識 —— 在 Chrome 中查看內存快照首先我們在控制台運行這樣一段程序。
function Food(name, type) { this.name = name; this.type = type;}var beef = new Food('beef', 'meat');切換到 Memory 中,點擊左側的小圈圈就可以捕獲當前的內存快照。
通過構造函數創建對象,主要是為了更方便地在快照中找到它。點開快照后,在過濾器中輸入 Food 就可以找到由 Food 構造的所有對象了,神奇吧。
V8 中對象的結構在 V8 中,對象主要由三個指針構成,分別是隱藏類(Hidden Class),Property 還有 Element。
其中,隱藏類用於描述對象的結構。Property 和 Element 用於存放對象的屬性,它們的區別主要體現在鍵名能否被索引。
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'
a、b 都有命名屬性 name 和 text,此外 a 還額外多了兩個可索引屬性。從快照中可以明顯的看到,可索引屬性是存放在 Elements 中的,此外,a 和 b 具有相同的結構(這個結構會在下文中介紹)。
你可能會有點好奇,這兩個對象的屬性不一樣,怎么會有相同的結構呢?要理解這個問題,首先可以問自己三個問題。
為什么要把對象存起來?當然是為了之后要用呀。要用的時候需要做什么?找到這個屬性咯。描述結構是為了做什么呢?按圖索驥,方便查找呀。那么,對於可索引屬性來說,它本身已經是有序地進行排列了,我們為什么還要多次一舉通過它的結構去查找呢。既然不用通過它的結構查找,那么我們也不需要再去描述它的結構了是吧。這樣,應該就不難理解為什么 a 和 b 具有相同的結構了,因為它們的結構中只描述了它們都具有 name 和 text 這樣的情況。
當然,這也是有例外的。我們在上面的代碼中再加入一行。
a[1111] = 'aaa'
可以看到,此時隱藏類發生了變化,Element 中的數據存放也變得沒有規律了。這是因為,當我們添加了 a[1111] 之后,數組會變成稀疏數組。為了節省空間,稀疏數組會轉換為哈希存儲的方式,而不再是用一個完整的數組描述這塊空間的存儲。所以,這幾個可索引屬性也不能再直接通過它的索引值計算得出內存的偏移量。至於隱藏類發生變化,可能是為了描述 Element 的結構發生改變(這個圖片可以與下文中慢屬性的配圖進行比較,可以看到 Foo1 的 Property 並沒有退化為哈希存儲,只是 Element 退化為哈希存儲導致隱藏類發生改變)。
命名屬性的不同存儲方式V8 中命名屬性有三種的不同存儲方式:對象內屬性(in-object)、快屬性(fast)和慢屬性(slow)。
對象內屬性保存在對象本身,提供最快的訪問速度。快屬性比對象內屬性多了一次尋址時間。慢屬性與前面的兩種屬性相比,會將屬性的完整結構存儲(另外兩種屬性的結構會在隱藏類中描述,隱藏類將在下文說明),速度最慢(在下文或其它相關文章中,慢屬性、屬性字典、哈希存儲說的都是一回事)。這樣是不是有點抽象。別急,我們通過一個例子來說明。
// 實驗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 的尋址時間,之后便是與對象內屬性一致的線性查找。
慢屬性
接着我們來看看 c。這個實在是太長了,只截取了一部分。可以看到,和 b (快屬性)相比,properties 中的索引變成了毫無規律的數,意味着這個對象已經變成了哈希存取結構了。
所以,問題來了,為什么要分這么幾種存儲方式呢?我來說說我的理解。
為什么要分三種存儲方式?(個人理解)這其實是在公司內部分享的時候,有同學提出的問題。我相信大家讀到這里的時候也會有類似的疑惑。當時的我也並不能很好的解釋為什么,直到我看到一張哈希存儲的圖(圖片來自於網絡)。
在 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]]:定義屬性是否可配置(刪除)
隱藏類的引入,將屬性的 Value 與其它 Attribute 分開。一般情況下,對象的 Value 是經常會發生變動的,而 Attribute 是幾乎不怎么會變的。那么,我們為什么要重復描述幾乎不會改變的 Attribute 呢?顯然這是一種內存浪費。
隱藏類的創建對象創建過程中,每添加一個命名屬性,都會對應一個生成一個新的隱藏類。在 V8 的底層實現了一個將隱藏類連接起來的轉換樹,如果以相同的順序添加相同的屬性,轉換樹會保證最后得到相同的隱藏類。
下面的例子中,a 在空對象時、添加 name 屬性后、添加 text 屬性后會分別對應不同的隱藏類。
// 實驗3 隱藏類的創建let a = {}a.name = 'thorn1'a.text = 'thorn2'下面是創建過程的示意圖(僅描述過程,具體細節可能與實際實現有略微差異)。
通過內存快照,我們也可以看到,Hidden Class 1 和 Hidden Class2 是不同的,並且后者的 back_pointer 指針指向前者,這也證實了上圖中的流程分析。
有的文章中提到,在實際存儲中,每次添加屬性時,新創建隱藏類實際上只會描述這個新添加的屬性,而不會描述所有屬性,也就是 Hidden Class 2 中實際上只會描述 text,沒有 name。這點本人暫時沒有通過內存快照的方式驗證(流下了沒有技術的眼淚),但從邏輯上分析應該是這樣的。此處還有一個小小的知識點。
// 實驗4 隱藏類創建時的優化let a = {};a.name = 'thorn1'let b = { name: 'thorn2' }
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
按照我們之前試驗的,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
我們給 a 和 b 按相同屬性添加相同的屬性 name 和 color,再給 a 額外添加一個屬性 text,然后刪除這個屬性。可以發現,此時 a 和 b 的隱藏類相同,a 也沒有退回哈希存儲。
結論與啟示屬性分為命名屬性和可索引屬性,命名屬性存放在 Properties 中,可索引屬性存放在 Elements 中。命名屬性有三種不同的存儲方式:對象內屬性、快屬性和慢屬性,前兩者通過線性查找進行訪問,慢屬性通過哈希存儲的方式進行訪問。總是以相同的順序初始化對象成員,能充分利用相同的隱藏類,進而提高性能。增加或刪除可索引屬性,不會引起隱藏類的變化,稀疏的可索引屬性會退化為哈希存儲。delete 操作可能會改變對象的結構,導致引擎將對象的存儲方式降級為哈希表存儲的方式,不利於 V8 的優化,應盡可能避免使用(當沿着屬性添加的反方向刪除屬性時,對象不會退化為哈希存儲)。相關鏈接參考資料V8之旅:對象表示JavaScript 引擎基礎:Shapes 和 Inline Caches - 知乎V8 Hidden Class - w3ctechV8 Hidden class - LINE ENGINEERINGHidden classes in JavaScript and Inline CachingFast properties in V8 · V8發布於 2019-12-20