瞭解V8(一) V8採用了哪些策略提高了對象屬性的訪問速度

JavaScript 語言的角度來看,JavaScript 對象像一個字典,字符串做爲鍵名,任意對象能夠做爲鍵值,能夠經過鍵名讀寫鍵值。chrome

然而在 V8 實現對象存儲時,並無徹底採用字典的存儲方式,這主要是出於性能的考量。由於字典是非線性的數據結構,查詢效率會低於線性的數據結構,V8 爲了提高存儲和查找效率,採用了一套複雜的存儲策略瀏覽器

今天咱們瞭解一下v8爲了提高對象的訪問性能都採用了那些策略緩存

首先咱們來分析一下下邊的這段代碼數據結構

function Foo() {
    this[100] = 'test-100'
    this[1] = 'test-1'
    this["B"] = 'bar-B'
    this[50] = 'test-50'
    this[9] =  'test-9'
    this[8] = 'test-8'
    this[3] = 'test-3'
    this[5] = 'test-5'
    this["A"] = 'bar-A'
    this["C"] = 'bar-C'
}
var bar = new Foo()

for(key in bar){
    console.log(`index:${key}  value:${bar[key]}`)
}

在上面這段代碼中,咱們利用構造函數 Foo 建立了一個 bar 對象,在構造函數中,咱們給 bar 對象設置了不少屬性,包括了數字屬性和字符串屬性,而後咱們枚舉出來了 bar 對象中全部的屬性,並將其一一打印出來,下面就是執行這段代碼所打印出來的結果:函數

index:1  value:test-1
index:3  value:test-3
index:5  value:test-5
index:8  value:test-8
index:9  value:test-9
index:50  value:test-50
index:100  value:test-100
index:B  value:bar-B
index:A  value:bar-A
index:C  value:bar-C

觀察這段打印出來的數據,咱們發現打印出來的屬性順序並非咱們設置的順序,咱們設置屬性的時候是亂序設置的,好比開始先設置 100,而後又設置了 1,可是輸出的內容卻很是規律,總的來講體如今如下兩點:工具

設置的數字屬性被最早打印出來了,而且是按照數字大小的順序打印的;佈局

設置的字符串屬性依然是按照以前的設置順序打印的,好比咱們是按照 B、A、C的順序設置的,打印出來依然是這個順序性能

之因此出現這樣的結果,是由於在 ECMAScript 規範中定義了優化

數字屬性應該按照索引值大小升序排列this

字符串屬性根據建立時的順序升序排列

排序屬性&常規屬性&內屬性

在這裏咱們把對象中的數字屬性稱爲排序屬性,在 V8 中被稱爲 elements

段落引用字符串屬性就被稱爲常規屬性,在 V8 中被稱爲 properties。

image.png

在 V8 內部,爲了有效地提高存儲和訪問這兩種屬性的性能,分別使用了兩個線性數據結構來分別保存

數字屬性存儲在排序屬性( elements)中

字符串屬性存放在常規屬性(properties)中

咱們能夠經過chrome瀏覽器的Memory來看一下以前的案例的存儲狀態

image.png

咱們能夠看到在內存快照中咱們只看到了 排序屬性(elements)
卻沒有 常規屬性(properties)

這是由於將不一樣的屬性分別保存到 elements和 properties 中,無疑簡化了程序的複雜度。

可是在查找元素時,卻多了一步操做

好比執行 bar.B這個語句來查找 B 的屬性值,須要先查找出 properties 屬性所指向的對象 properties,而後再在 properties 對象中查找 B 屬性,這種方式在查找過程當中增長了一步操做,所以會影響到元素的查找效率。

因此V8 採起了一個權衡的策略以加快查找屬性的效率,

將部分常規屬性直接存儲到對象自己,咱們把這稱爲對象內屬性 (in-object properties)

image.png

接下來咱們在經過chrome的內存快照來進一步瞭解一下,對象的在內存中的分佈

咱們在控制檯輸入下邊的代碼

function Foo(property_num,element_num) {
    
    //添加可索引屬性
    for (let i = 0; i < element_num; i++) {
        this[i] = `element${i}`
    }

    //添加常規屬性
    for (let i = 0; i < property_num; i++) {
        let ppt = `property${i}`
        this[ppt] = ppt
    }
}

var bar = new Foo(10,10)

將 Chrome 開發者工具切換到 Memory 標籤,而後點擊左側的小圓圈捕獲當前的內存快照

在搜索框裏面輸入構造函數 Foo,Chrome 會列出全部通過構造函數 Foo 建立的對象
image.png
咱們在內存快照中觀察一下此時的佈局

10 個常規屬性做爲對象內屬性,存放在 bar 函數內部;

10 個排序屬性存放在 elements 中。

接下來咱們能夠將建立的對象屬性的個數調整到 20 個

var bar2 = new Foo(20,10)

這時候屬性的內存佈局是這樣的:

10 屬性直接存放在 bar2 的對象內 ;

10 個常規屬性以線性數據結構的方式存放在 properties 屬性裏面 ;

10 個數字屬性存放在 elements 屬性裏面。

因爲建立的經常使用屬性超過了 10 個,因此另外 10 個經常使用屬性就被保存到 properties 中了

注意由於 properties 中只有 10 個屬性,因此依然是線性的數據結構

那麼若是經常使用屬性太多了,好比建立了 100 個,咱們再來看看其內存分佈

var bar3 = new Foo(100,10)

image.png這時候屬性的內存佈局是這樣的:

10 屬性直接存放在 bar3 的對象內 ;
90 個常規屬性以非線性字典的這種數據結構方式存放在 properties 屬性裏面 ;
10 個數字屬性存放在 elements 屬性裏面。

這時候的 properties 屬性裏面的數據並非線性存儲的,而是以非線性的字典形式存儲的

接下來再看一下刪除一個屬性後的佈局

var bar4 = new Foo(5,5);

delete bar4.property0

image.png
咱們會發現這時候雖然只設置了5個個常規屬性,可是由於咱們執行了delete操做,properties屬性中的存儲結構也會變成非線性的結構

所以咱們能夠總結若是對象中的屬性過多時
或者存在反覆添加或者刪除屬性的操做,V8 就會將線性的存儲模式降級爲非線性的字典存儲模式,這樣雖然下降了查找速度,可是卻提高了修改對象的屬性的速度

隱藏類

剛纔咱們講了是V8對於對象的存儲方式上作了那些提高
接下來咱們再來講一下查找對象屬性的時候,v8又採起了什麼策略來提高查詢效率呢

咱們知道 JavaScript 是一門動態語言,其執行效率要低於靜態語言,

V8 爲了提高 JavaScript 的執行速度,借鑑了不少靜態語言的特性,好比實現了 JIT 機制,爲了提高對象的屬性訪問速度而引入了隱藏類,爲了加速運算而引入了內聯緩存。

咱們來重點分析下 V8 中的隱藏類,看看它是怎麼提高訪問對象屬性值速度的。

隱藏類-靜態語言特徵

在開始研究隱藏類以前咱們就先來分析下爲何靜態語言比動態語言的執行效率更高
image.png
靜態語言在聲明一個對象以前須要定義該對象的結構,也稱爲形狀,編譯時每一個對象的形狀都是固定的,沒法被改變的。那麼訪問一個對象的屬性時,天然就知道該屬性相對於該對象地址的偏移值了,好比在使用 start.x 的時候,編譯器會直接將 x 相對於 start 的地址寫進彙編指令中,那麼當使用了對象 start 中的 x 屬性時,CPU 就能夠直接去內存地址中取出該內容便可,沒有任何中間的查找環節。

JavaScript 在運行時,對象的屬性是能夠被修改的,因此當 V8 使用了一個對象時,好比使用了 start.x 的時候,它並不知道該對象中是否有 x,也不知道 x 相對於對象的偏移量是多少,也能夠說 V8 並不知道該對象的具體的形狀。

那麼,當在 JavaScript 中要查詢對象 start 中的 x 屬性時,V8 會先查找properties,再在properties中中查找x屬性,這個過程很是的慢且耗時

什麼是隱藏類 (Hidden Class)?

根據靜態語言的特徵,v8採用的一個思路就是將 JavaScript 中的對象靜態化,也就是 V8 在運行 JavaScript 的過程當中,會假設 JavaScript 中的對象是靜態的,具體地講,V8 對每一個對象作以下兩點假設

對象建立好了以後就不會添加新的屬性;

對象建立好了以後也不會刪除屬性。

V8 會爲每一個對象建立一個隱藏類,對象的隱藏類中記錄了該對象一些基礎的佈局信息,包括如下兩點

對象中所包含的全部的屬性;

每一個屬性相對於對象的偏移量。

這樣V8 訪問某個對象中的某個屬性時,就會先去隱藏類中查找該屬性相對於它的對象的偏移量,而後直接去內存中取出對於的屬性值,而不須要經歷一系列的查找過程,那麼這就大大提高了 V8 查找對象的效率。

結合一段代碼來分析下隱藏類是怎麼工做的:

let point = {x:100,y:200}

image.png

V8 執行到這段代碼時,會先爲 point 對象建立一個隱藏類(又稱爲 map),每一個對象都有一個 map 屬性,其值指向內存中的隱藏類。

隱藏類描述了對象的屬性佈局,它主要包括了屬性名稱和每一個屬性所對應的偏移量;
好比 point 對象的隱藏類就包括了 x 和 y 屬性,x 的偏移量是 4,y 的偏移量是 8
image.png

上圖左邊的是 point 對象在內存中的佈局,point 對象的第一個屬性就指向了它的 map;

有了 map 以後,當你再次使用 point.x 訪問 x 屬性時,
V8 會查詢 point 的 map 中 x 屬性相對 point 對象的偏移量

而後將 point 對象的起始位置加上偏移量,就獲得了 x 屬性的值在內存中的位置,有了這個位置也就拿到了 x 的值,這樣咱們就省去了一個比較複雜的查找過程。

多個對象共用一個隱藏類

咱們在控制檯輸入下面的代碼,而後查看內存快照

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'

image.png

a、b 都有命名屬性 name 和 text,此外 a 還額外多了兩個可索引屬性。從快照中能夠明顯的看到,可索引屬性是存放在 elements 中的,此外,a 和 b 具備相同的結構(map後邊我標紅的位置)

每一個對象都有一個 map 屬性,該屬性值指向該對象的隱藏類。不過若是兩個對象的形狀是相同的,V8 就會爲其複用同一個隱藏類,這樣有兩個好處:

減小隱藏類的建立次數,也間接加速了代碼的執行速度;
減小了隱藏類的存儲空間。

什麼狀況下兩個對象的形狀是相同的,要知足如下兩點:

相同的屬性名稱;

相等的屬性個數。

那麼對於前邊的案例你可能會有點好奇,前邊的兩個對象的屬性不同(b比a多了兩個數字屬性),怎麼會有相同的結構呢?要理解這個問題,首先能夠思考下邊三個問題。

爲何要把對象存起來?固然是爲了以後要用。

要用的時候須要作什麼?找到這個屬性。

描述結構是爲了作什麼呢?按圖索驥,方便查找

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

從新構建隱藏類

在開頭咱們提到了,V8 爲了實現隱藏類,須要兩個假設條件:

對象建立好了以後就不會添加新的屬性;

對象建立好了以後也不會刪除屬性。

可是,JavaScript 依然是動態語言,在執行過程當中,對象的形狀是能夠被改變的,若是某個對象的形狀改變了,隱藏類也會隨着改變,這意味着 V8 要爲新改變的對象從新構建新的隱藏類,這對於 V8 的執行效率來講,是一筆大的開銷。

通俗地理解,給一個對象添加新屬性,刪除屬性,或者改變性的類型都會改變這個對象的形狀,那麼勢必也就會觸發 V8 爲改變形狀後的對象重建新的隱藏類。

好比以前的案例,咱們能夠試一下執行(delete a.name)
image.png

這樣咱們會發現a和b的map就不相同了,而且會將字符串屬性以非線性的字典的結構存儲在properties中,也就是由內屬性變爲了慢屬性

最佳實踐

結合上邊說若是但願查找效率更高,咱們但願對象中的隱藏類不要隨便被改變,由於這樣會觸發 V8 重構該對象的隱藏類,直接影響到了程序的執行性能。

那麼在實際工做中,咱們應該儘可能注意如下幾點:

一,初始化對象時,要保證屬性的順序是一致的。
好比不要先經過字面量 x、y 的順序建立了一個 point 對象,而後經過字面量 y、x 的順序建立一個對象 point2

二,儘可能一次性初始化完整對象屬性。
由於每次爲對象添加一個屬性時,V8 都會爲該對象從新設置隱藏類。

三,儘可能避免使用 delete 方法。
delete 方法會破壞對象的形狀,一樣會致使 V8 爲該對象從新生成新的隱藏類。

內聯緩存

咱們來分析一下下邊的代碼

function loadX(o) { 
    return o.x
}
var o = { x: 1,y:3}
var o1 = { x: 3 ,y:6}
for (var i = 0; i < 90000; i++) {
    loadX(o)
    loadX(o1)
}

咱們定義了一個 loadX 函數,它有一個參數 o,該函數只是返回了 o.x。

一般 V8 獲取 o.x 的流程是這樣的:查找對象 o 的隱藏類,再經過隱藏類查找 x 屬性偏移量,而後根據偏移量獲取屬性值。

在這段代碼中 loadX 函數會被經過for循環反覆執行,那麼獲取 o.x 流程也須要反覆被執行。

有沒有辦法再度簡化這個查找過程,最好能一步到位查找到 x 的屬性值呢?

答案是:

V8 會想盡一切辦法來壓縮這個查找過程,以提高對象的查找效率。
這個加速函數執行的策略就是內聯緩存 (Inline Cache),簡稱爲 IC。接下來咱們來看一下,V8 是怎麼經過 IC,來加速函數 loadX 的執行效率的。

什麼是內聯緩存?

V8 執行函數的過程當中,會觀察函數中一些調用點 (CallSite) 上的關鍵的中間數據,而後將這些數據緩存起來,當下次再次執行該函數的時候,V8 就能夠直接利用這些中間數據,節省了再次獲取這些數據的過程,所以 V8 利用 IC,能夠有效提高一些重複代碼的執行效率。

IC 會爲每一個函數維護一個反饋向量 (FeedBack Vector),反饋向量記錄了函數在執行過程當中的一些關鍵的中間數據。

好比下面這段函數:

function loadX(o) { 
    o.y = 4
    return o.x
}

image.png

當 V8 執行這段函數的時候,它會判斷 o.y = 4 和 return o.x 這兩段是調用點 (CallSite),由於它們使用了對象和屬性,那麼 V8 會在 loadX 函數的反饋向量中爲每一個調用點分配一個插槽。每一個插槽中包括了插槽的索引 (slot index)、插槽的類型 (type)、插槽的狀態 (state)、隱藏類 (map) 的地址、還有屬性的偏移量,

好比上面這個函數中的兩個調用點都使用了對象 o,那麼反饋向量兩個插槽中的 map 屬性也都是指向同一個隱藏類的,所以這兩個插槽的 map 地址是同樣的。

當 V8 再次調用 loadX 函數時,好比執行到 loadX 函數中的 return o.x 語句時,它就會在對應的插槽中查找 x 屬性的偏移量,以後 V8 就能直接去內存中獲取 o.x 的屬性值了。這樣就大大提高了 V8 的執行效率。

多態和超態

經過緩存執行過程當中的基礎信息,就可以提高下次執行函數時的效率。
可是這有一個前提,那就是屢次執行時,對象的形狀是固定的,若是對象的形狀不是固定的,那 V8 會怎麼處理呢?

咱們調整一下上面這段 loadX 函數的代碼,調整後的代碼以下所示:

function loadX(o) { 
    return o.x
}
var o = { x: 1,y:3}
var o1 = { x: 3, y:6,z:4}
for (var i = 0; i < 90000; i++) {
    loadX(o)
    loadX(o1)
}

咱們能夠看到,對象 o 和 o1 的形狀是不一樣的,這意味着 V8 爲它們建立的隱藏類也是不一樣的。

面對這種狀況,V8 會選擇將新的隱藏類也記錄在反饋向量中,同時記錄屬性值的偏移量,這時,反饋向量中的第一個槽裏就包含了兩個隱藏類和偏移量
image.png
當 V8 再次執行 loadX 時,一樣會查找反饋向量表,此時插槽中記錄了兩個隱藏類。這時,V8 須要額外作一件事,拿這個新的隱藏類和第一個插槽中的兩個隱藏類來一一比較,若是找到相同的,那麼就使用該隱藏類的偏移量。若是沒有相同的呢?一樣將新的信息添加到反饋向量的第一個插槽中。

因此一個反饋向量的一個插槽中能夠包含多個隱藏類的信息:

插槽中只包含 1 個隱藏類,咱們稱這種狀態爲單態 (monomorphic);

插槽中包含了 2~4 個隱藏類,稱這種狀態爲多態 (polymorphic);

插槽中超過 4 個隱藏類,稱這種狀態爲超態 (magamorphic)。

由於多態存在比較的環節,因此多態或者超態的狀況,其執行效率確定要低於單態的。

單態的性能優於多態和超態,因此咱們須要稍微避免多態和超態的狀況。

最後我還想強調一點,雖然咱們分析的隱藏類和 IC 能提高代碼的執行速度

可是在實際的項目中,影響執行性能的因素很是多,

找出那些影響性能瓶頸纔是相當重要的,

你不須要過分關注微優化,你也不須要過分擔心你的代碼是否破壞了隱藏類或者 IC 的機制,

由於相對於其餘的性能瓶頸,它們對效率的影響多是微不足道的。

思考題### 三級標題
觀察下面兩段代碼:

let data = [1, 2, 3, 4]
data.forEach((item) => console.log(item.toString())
let data = ['1', 2, '3', 4]
data.forEach((item) => console.log(item.toString())

你認爲這兩段代碼,哪段的執行效率高,爲何?歡迎你在留言區與我分享討論。

相關文章
相關標籤/搜索