透過V8深入理解JavaScript的對象

我們知道,V8是Chrome用來編譯執行JavaScript的JavaScript引擎,所以如果我們要了解JavaScript,那麼結合V8能讓我們更好地理解,這篇博客將結合V8,來談談JavaScript種的對象

對象

什麼是JavaScript的對象

什麼是JavaScript的對象, JavaScript中的對象,並不是單單指Object,JavaScript這門語言本身,是基於對象的,可以說,JavaScript裏面的大部分內容,都是由對象構成的。比如函數,數組,這些都是由對象構成的,甚至一些基本類型還有相關的內置對象諸如Number,String,Boolean

對象在運行時可以被動態修改屬性的特點,讓JavaScript變得十分靈活,但同時,也帶來了一些難以理解的問題。

JavaScript對象可以說簡單,也可以說複雜。簡單來收,它不過是存儲了一些屬性,和這些屬性對應的值而已,是一種key-value的結構。

而說它複雜,確實因爲它的屬性的值可以爲任意類型。這也是它做爲弱類型語言的特性,像Java,對象的每個屬性對應的類型是確定的,而JavaScript並不。由於可以爲任意類型,所以我們可以爲一個屬性,賦值爲基本類型如Number、String、Boolean、null、undefiend等,也可以給它一個數組,一個普通對象,或者一個函數,使這個屬性變成一個方法。這也可以看成,對象的屬性的值可以有三種類型

  • 基本類型
  • 普通對象
  • 函數

此外,我們要明白的是,JavaScript雖然是基於對象的,但卻不是面向對象的,學過Java的同學應該知道,面向對象三大特點,封裝、繼承、多態,而JavaScript裏面並沒有多態的實現。

除了多態沒有實現外,繼承上也與其他語言存在區別,JavaScript採用原型來實現繼承,而原型在JavaScript中只是添加了一個屬性,所以我們先來聊聊V8的對象屬性存儲策略。

V8的對象屬性存儲策略

我們所看到的對象屬性存儲,看起來像是字典的存儲,哪個先存進去,遍歷的時候就先存儲哪個,但實際上,JavaScript的對象的屬性存儲並非完全如此,

一般情況下,屬性確實是按存儲進去的順序,但是如果屬性的名字是數值的話,那會按數值的形式存儲

function Obj(){
    this[100] = 't100'
    this[50] = 't50'
    this.a = 'ta'
    this[20] = 't20'
    this.c = 'tc'
    this[25] = 't25'
    this[1] = 't1'
    this.b = 'tb'
}
let obj = new Obj()
for(let k in obj){
    console.log(k)
}

在這裏插入圖片描述
可以看到,打印結果裏面,數字是按數字的順序打印出來,而a、b、c是按我們寫入的順序打印出來的

直接在控制檯查看obj對象也能看出順序
在這裏插入圖片描述

之所以出現這樣的結果,是因爲在ECMAScript規範中定義了數字屬性應該按照索引值大小升序排列,字符串屬性根據創建時的順序升序排列。

我們把按索引值大小排列的屬性稱爲elements屬性,而根據創建時的順序排列的屬性稱爲property屬性。(這是按照Chrome瀏覽器控制檯裏Memory快照的屬性名稱取的)

因爲elements屬性是按索引順序排序,所以刪除和添加一個elements屬性,都會引起一次屬性排序,而property屬性並不會。在V8中,爲了能提升存儲和訪問這些屬性的性能,對這兩種屬性採用了兩種不同的線性結構來存儲。

在遍歷一個對象屬性時,如上面我們打印的一樣,V8會先去到elements屬性中,根據索引遍歷所有的elements屬性,然後再去遍歷property屬性

使用兩種線性結構來存儲,解決了兩種屬性不同處理的問題,但在我們訪問對象屬性的時候,需要先判斷這個屬性的鍵是什麼類型的,然後再根據類型,去到不同的結構中查找,增加了我們訪問屬性的時間

我們可以來看看Memory中的快照
在這裏插入圖片描述

我們可以看到,這裏有elements屬性,裏面就是我們用索引排序的屬性,但是,沒有看到property屬性,這和V8的權衡機制有關。

我們上面也提到了,使用兩種線性結構,然後根據屬性鍵值來遍歷不同的屬性,會降低訪問效率,V8對此做了一定的權衡。當property屬性不超過10個的時候,就直接將屬性放在對象上,而不放到property屬性上。

我們試試超過十個的property屬性

function Obj(pNum,eNum) {
    for (let i = 0; i < pNum; i++) {
        let pKey = `property${i}`
        this[pKey] = `property${i}`
    }
    for (let i = 0; i < eNum; i++) {
        this[i] = `element${i}`
    }
}

obj = new Obj(11,10)

在這裏插入圖片描述
可以看到,多了個properties屬性,裏面有個property10

我們將直接存儲在對象上的property屬性稱爲對象內屬性,而在properties屬性上的property屬性有快屬性和慢屬性兩種。

對象內屬性的訪問是最快的,因爲直接在對象上,而在properties屬性上的屬性,因爲多了一次尋址,所以會比對象內屬性慢,但是在properties屬性上,其實還有細分。

快屬性採用線性結構來存儲,我們只需要通過索引就能訪問到屬性的值,但同時,帶來的問題是如果我們插入和刪除大量屬性時,執行效率會變低。

所以當我們一個對象的屬性多了的話,就會變成慢屬性了。慢屬性也是在properties上,但是它採用非線性的結構來存儲屬性值的。所以在插入刪除時執行效率不會和線性結構一樣,需要做大量的移動。

obj = new Obj(100,10)

看看創建了100個property屬性的對象後內存快照
在這裏插入圖片描述
可以看到,內存中properties屬性裏面的屬性已經是完全無序的,這表明這些屬性採用了慢屬性的存儲方式

隱藏類

在上面的內存快照中,我們可以看到一個map屬性,這個屬性存放了描述命名屬性,也就是隱藏類。隱藏類可以看成是一個描述對象結構的對象。

爲什麼要引入隱藏類,這是爲了讓JavaScript更快。我們知道,JavaScript是一門動態語言,其運行開發者隨時修改對象的屬性和值,這種方式帶來了靈活的同時,卻讓JavaScript對屬性的訪問變慢了。像Java這樣的靜態語言,因爲類型一旦創建不可變,所以可以通過固定的偏移值對對象的屬性進行訪問,因此比動態語言快,而隱藏類借鑑了部分靜態語言的特性。

V8採用的思路是,將JavaScript的對象靜態化,即假設JavaScript的對象在創建之後就不會添加新的屬性,也不會刪除現有的屬性。(實際上是會的)

V8爲每一個JavaScript對象創建它自己的隱藏類,存放在map中,然後當我們去尋找一個對象的屬性時,如果這個屬性在map中有記錄,那麼我們去到map中,找到這個屬性相對於對象的偏移值,在對象的地址上加上偏移值就可以找到這個屬性了,這樣比起上面的查找會更快。

而當我們創建了兩個形狀一樣的對象時,即對象的屬性個數一樣且名稱一樣,那麼這兩個對象會共用一個隱藏類

function Person(name,age){
    this.name = name
    this.age = age
}
var Bob = new Person('Bob',18)
var Mike = new Person('Mike',20)

在這裏插入圖片描述
可以看到上圖中,兩個對象的map隱藏類是一樣的,即使我們不採用構造函數來創建對象,也是一樣的

這麼做,給我們訪問對象又帶來了一次提速,但是要記住,我們是有假設的,這樣做的前提是對象不會增加屬性,也不會刪除屬性,但實際上,這個假設是不成立的,那麼也就是說,對於JavaScript來說,對象結構是會變的,那麼隱藏類也就會發生改變。

我們可以修改上面的Mike對象,爲其添加一個屬性

Mike.work = 'coding'

在這裏插入圖片描述
可以從內存快照中看到,Mike的map隱藏類地址發生了改變,也就是說隱藏類發生了改變,符合了我們剛纔說的結構改變隱藏類隨之改變,這對於V8的執行效率來說,是一筆大的開銷。

爲了避免這種問題,我們可以在編寫代碼時注意一些優化細節

  1. 字面量聲明對象時順序一致
    兩個對象的屬性順序不同也會創建不同的隱藏類,所以自己在使用字面量創建對象時,儘量順序一致,減少創建隱藏類時間和隱藏類個數,像下面這兩個對象
var p1 = { x:10,y:20 }
var p2 = { y:20,x:10 }

在這裏插入圖片描述
可以看到屬性一樣,卻因爲順序不一樣導致了隱藏類不同。

  1. 儘量使用使用字面量一次性寫入所有的屬性,一個個添加屬性會導致多次創建隱藏類

  2. 減少使用delete,通過delete來刪除對象屬性,也會造成隱藏類的重新創建

雖然經過隱藏類的引入,我們訪問對象屬性的速度加快了,但還是有一些問題需要考慮,看看下面的代碼

var Mike = {
    name:'Mike',
    age:18
}
function getAge(p){
    return p.age
}
for(let i = 0 ; i < 100 ; i++){
    getAge(Mike)
}

在上面的代碼中,我們getAge去獲取Mike的age屬性的時候,通過map隱藏類,使用偏移值得到了對象的age屬性值,但是這實際上,我們還是需要三步,去到對象的map屬性,然後再找到偏移值,計算對象的地址和偏移值相加的值,才能找到age屬性值,但實際上,我們一直在找一個相同的值,有什麼方法可以優化這種尋找嗎

內聯緩存

上面的問題,V8採用了內聯緩存(IC:Inline Cache)來處理。

知道怎麼處理之前,我們先要知道IC的原理。簡單來說,V8通過在JavaScript執行過程中觀察一些調用點的關鍵數據,然後將這些關鍵數據存儲起來,當再次調用這個函數時,就會去直接使用這些關鍵數據,節省了再次獲取的時間。那麼,V8是如何觀察調用點的,又是如何存儲關鍵數據,存儲在哪的

首先,V8會爲每個函數維護一個反饋向量(FeedBack Vector),記錄函數在執行過程中的一些關鍵的中間數據,反饋向量實際上就是一個標,表中的每一項都是一個slot插槽,每個關鍵數據對應一個slot。每個插槽中包括了插槽的索引(slot index)、插槽的類型(type)、插槽的狀態(state)、隱藏類(map)的地址、還有屬性的偏移量

IC會存儲三種插槽類型:存儲類型、調用類型、加載類型,對應着數據的存儲,函數的調用和對象屬性的加載

看下面這段代碼

function foo(){}
function loadX(o) { 
    o.y = 4
    foo()
    return o.x
}
loadX({x:1,y:4})
  • 當我們執行loadX的時候,就會爲其生成一個反饋向量
  • 執行到o.y時,就會創建一個存儲類型的插槽,將操作結果放入插槽中,然後插入到反饋向量
  • 執行foo()時,首先要找到foo的地址,所以要創建一個加載類型的插槽,存儲foo的地址,插入到反饋向量中,然後執行方法,創建一個調用類型的插槽,將調用結果放入插槽中,最後將這個插槽插入到反饋向量中
  • return o.x,獲取到o.x的值,創建一個加載類型的插槽,存儲o.x的值,插入到反饋向量中

之後我們再次調用這個函數時,只需要從反饋向量裏面找到對應的插槽,就可以完成操作了

然而,這種性能提升的方式,侷限於函數內部執行的內容是相同的,如果不相同,又會產生新的問題,看看下面這段代碼

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爲它們創建的隱藏類也是不同的。

而在我們第一次調用loadX的時候,V8會將對象o的隱藏類和屬性偏移值記錄到反饋向量裏面,但是當我們再次調用loadX的時候,V8會發現o1的結構和反饋向量裏記錄的隱藏類是不同的,所以這次無法使用反饋向量裏面的緩存。

遇到這種情況時,V8會選擇將同一個調用點的數據,存儲在反饋向量裏的同一個插槽裏面,這樣子,當這個函數再次被調用的時候,V8需要先去比對,插槽裏記錄的隱藏類,哪個和我當前傳入的對象隱藏類相同,發現哪一個相同,就去使用哪一個記錄,如果沒有相同,則爲這個插槽加入新的值。

現在我們知道了,一個反饋向量的一個插槽中可以包含多個隱藏類的信息,那麼:

  • 如果一個插槽中只包含1個隱藏類,那麼我們稱這種狀態爲單態(monomorphic);
  • 如果一個插槽中包含了2~4個隱藏類,那我們稱這種狀態爲多態(polymorphic);
  • 如果一個插槽中超過4個隱藏類,那我們稱這種狀態爲超態(magamorphic)。

因此,我們需要儘可能地保持單態,避免多態和超態

原型鏈

聊完對象是怎麼存儲屬性的,接下來我們可以聊聊JavaScript裏面重要的繼承機制,基於原型鏈的繼承。

原型鏈是JavaScript重要的基礎,也是面試裏經常談及的問題。這裏我們談談V8裏面是怎麼去通過原型鏈實現繼承的。

繼承

首先,什麼是繼承,簡單地說,我們通過讓一個對象A繼承另一個對象B,那麼A可以直接調用B的方法,這就是繼承。

繼承的實現一般有兩種,一種是基於類的繼承,一種是基於原型的繼承。

我們常看到的Java,C都是基於類的繼承,這種繼承方式的特點就是,我們能看到一些和繼承相關的關鍵字,諸如public、private、protected、interface、class等,我們通過這些關鍵字,來實現類的聲明和類的繼承,同時限定類的訪問。

而JavaScript使用的,是基於原型的繼承,雖然JavaScript在ES6中出現了class,但它實際上只是一個語法糖而已,它的本質仍是基於原型的繼承。JavaScript的原型繼承,也只是通過引入了一個指向原型的屬性而已。

如何實現原型繼承

JavaScript的原型繼承,通過引入了一個指向原型對象的隱藏屬性__proto__,我們可以直接在console看到,也可以通過Memory快照看到對象的這個隱藏屬性。

而JavaScript的原型繼承,就是通過這個原型指向去尋找屬性和方法,如果現在繼承是C->B->A(C繼承B,B繼承A)。那麼當我們去調用C的方法或者去訪問C的屬性,V8首先會在C這個對象上,尋找對應的屬性方法,找不到的話,就沿着原型鏈的指向,去到對象B上找,如果還找不到,就繼續沿着原型鏈,去A對象上找,如果還找不到,就看A是否原型鏈還有指向的對象,如果沒有,返回undefined,如果是方法,此時執行就會報錯。

綜上,原型繼承其實挺簡單的,就是沿着原型鏈尋找而已,但是JavaScript實現繼承的方式還有挺多的,在紅寶書(《JavaScript高級程序設計》)裏面,就說到了6種繼承的方式,如果你看過紅寶書,應該有印象,如果你沒看過,建議你去看看,雖然書名高級,但在我看來只是本比較廣泛的基礎書籍而已,總的來說還是很有用的。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章