JavaScript V8 Object 記憶體結構與屬性訪問詳解

王下邀月熊發表於2017-01-22

上世紀九十年代,隨著網景瀏覽器的發行,JavaScript 首次進入人們的視線。之後隨著 AJAX 的大規模應用與富客戶端、單頁應用時代的到來,JavaScript 在 Web 開發中佔據了越來越重要的地位。在早期的 JavaScript 引擎中,效能越發成為了開發網頁應用的瓶頸。而 V8 引擎設計的目標就是為了保證大型 JavaScript 應用的執行效率,在很多測試中可以明顯發現 V8 的效能優於 JScript (Internet Explorer), SpiderMonkey (Firefox), 以及 JavaScriptCore(Safari). 根據 V8 的官方文件介紹,其主要是從屬性訪問、動態機器碼生成以及高效的垃圾回收這三個方面著手效能優化。Obejct 當屬 JavaScript 最重要的資料型別之一,本文我們對其內部結構進行詳細闡述。其繼承關係圖如下所示:

3349374777-5883605834196_articlex

在 V8 中新分配的 JavaScript 物件結構如下所示:

在建立新的物件時,V8 會建立某個預分配的記憶體區域來存放所謂的 in-object 屬性,預分配區域的大小由建構函式中的引數數目決定(this.field = expr)。當你打算向物件中新增某個新屬性時,V8 首先會嘗試放入所謂的 in-order 槽位中,當 in-object 槽位過載之後,V8 會嘗試將新的屬性新增到 out-of-object 屬性列表。而屬性名與屬性下標的對映關係即存放在所謂隱藏類中,譬如{ a: 1, b: 2, c: 3, d: 4}物件的儲存方式可能如下:

隨著屬性數目的增加,V8 會轉回到傳統的字典模式/雜湊表模式:

Reference

Property Name:屬性名

作為動態語言,JavaScript 允許我們以非常靈活的方式來定義物件,譬如:

參照 JavaScript 定義規範中的描述,屬性名恆為字串,即使你使用了某個非字串的名字,也會隱式地轉化為字串型別。譬如你建立的是個陣列,以數值下標進行訪問,然而 V8 還是將其轉化為了字串再進行索引,因此以下的方式就會獲得相同的效果:

而 JavaScript 中的 Array 只是包含了額外的length屬性的物件而已,length會返回當前最大下標加一的結果(此時字串下標會被轉化為數值型別計算):

Function本質上也是物件,只不過length屬性會返回引數的長度而已:

In-Object Properties & Fast Property Access:物件內屬性與訪問優化

作為動態型別語言,JavaScript 中的物件屬性可以在執行時動態地增刪,意味著整個物件的結構會頻繁地改變。大部分 JavaScript 引擎傾向於使用字典型別的資料結構來存放物件屬性( Object Properties),每次進行屬性訪問的時候引擎都需要在內層中先動態定位屬性對應的下標地址然後讀取值。這種方式實現上比較容易,但是會導致較差的效能表現。其他的類似於 Java 與 Smalltalk 這樣的靜態語言中,成員變數在編譯階段即確定了其在記憶體中的固定偏移地址,進行屬性訪問的時候只需要單指令從記憶體中載入即可。而 V8 則利用動態建立隱藏內部類的方式動態地將屬性的記憶體地址記錄在物件內,從而提升整體的屬性訪問速度。總結而言,每當為某個物件新增新的屬性時,V8 會自動修正其隱藏內部類。我們先通過某個實驗來感受下隱藏類的存在:

該程式的執行結果如下:

第一種實現中,每次為物件o設定新的屬性時,V8 都會建立新的隱藏內部類(內部稱為 Map)來儲存新的記憶體地址以優化屬性查詢速度。而第二種實現時,我們在建立新的物件時即初始化了內部類,這樣在賦值屬性時 V8 以及能夠高效能地定位這些屬性。第三種實現則是用的 ES6 Class,在純正的 V8 下效能最好。接下來我們具體闡述下隱藏類的工作原理,假設我們定義了描述點的函式:

當我們執行new Point(x,y)語句時,V8 會建立某個新的Point物件。建立的過程中,V8 首先會建立某個所謂C0的隱藏內部類,因為尚未為物件新增任何屬性,此時隱藏類還是空的:
1092511452-588360576ad90_articlex接下來呼叫首個賦值語句this.x = x;為當前Point物件建立了新的屬性x,此時 V8 會基於C0建立另一個隱藏類C1來替換C0,然後在C1中存放物件屬性x的記憶體位置資訊:
2296983179-5883605954772_articlex

這裡從C0C1的變化稱為轉換(Transitions),當我們為同一個型別的物件新增新的屬性時,並不是每次都會建立新的隱藏類,而是多個物件會共用某個符合轉換條件的隱藏類。接下來繼續執行this.y = y 這一條語句,會為Point物件建立新的屬性。此時 V8 會進行以下步驟:

  • 基於C1建立另一個隱藏類C1,並且將關於屬性y的位置資訊寫入到C2中。
  • 更新C1為其新增轉換資訊,即當為Point物件新增屬性 y 時,應該轉換到隱藏類 C2

2753479324-588360592e7ca_articlex整個過程的虛擬碼描述如下:

Reused Hidden Class:重複使用的隱藏類

我們在上文中提及,如果每次新增新的屬性時都建立新的隱藏類無疑是極大的效能浪費,實際上當我們再次建立新的Point物件時,V8 並不會建立新的隱藏類而是使用已有的,過程描述如下:

  • 初始化新的Point物件,並將隱藏類指向C0
  • 新增x屬性時,遵循隱藏類的轉換原則指向到C1 , 並且根據C1指定的偏移地址寫入x
  • 新增y屬性時,遵循隱藏類的轉換原則指向到C2,並且根據C2指定的偏移地址寫入y

另外我們在上文以連結串列的方式描述轉換,實際上真實場景中 V8 會以樹的結構來描述轉換及其之間的關係,這樣就能夠用於類似於下面的屬性一致而賦值順序顛倒的場景:

Methods & Prototypes:方法與原型

JavaScript 中並沒有類的概念(語法糖除外),因此對於方法的呼叫處理會難於 C++ 或者 Java。下面這個例子中,distance方法可以被看做Point的普通屬性之一,不過其並非原始型別的資料,而是指向了另一個函式:

如果我們像上文介紹的普通的 in-object 域一樣來處理distance屬性,那麼無疑會帶來較大的記憶體浪費,畢竟每個物件都要存放一段外部函式引用(Reference 的記憶體佔用往往大於原始型別)。C++ 中則是以指向多個虛擬函式的虛擬函式表(V-Tables)解決這個問題。每個包含虛擬函式的類的例項都會指向這個虛擬函式表,當呼叫某個虛擬函式時,程式會自動從虛擬函式表中載入該函式的地址資訊然後轉向到該地址呼叫。V8 中我們已經使用了隱藏類這一共享資料結構,因此可以很方便地改造下就可以。我們引入了所謂 Constant Functions 的概念,某個 Constant Function 即代表了物件中僅包含某個名字,而具體的屬性值存放在描述符本身的概念:

注意,在這裡如果我們將PointDistance 重定義指向了其他函式,那麼這個轉換也會自動失效,V8 會建立新的隱藏類。另一種解決這個問題的方法就是使用原型,每個建構函式都會有所謂的Prototype屬性,該屬性會自動成為物件的原型鏈上的一環,上面的例子可以改寫為以下方式:

V8 同樣會把原型鏈上的方法在隱藏類中對映為 Constant Function 描述符,而呼叫原型方法往往會比呼叫自身方法慢一點,畢竟引擎不僅要去掃描自身的隱藏類,還要去掃描原型鏈上物件的隱藏類才能得知真正的函式呼叫地址。不過這個不會對於程式碼的效能造成明顯的影響,因此寫程式碼的時候也不必小心翼翼的避免這個。

Dictionary Mode

對於複雜屬性的物件,V8 會使用所謂的字典模式(Dictionary Mode)來儲存物件,也就是使用雜湊表來存放鍵值資訊,這種方式儲存開銷會小於上文提到的包含了隱藏類的方式,不過查詢速度會遠小於前者。初始狀態下,雜湊表中的所有的鍵與值都被設定為了undefined,當插入新的資料時,計算得出的鍵名的雜湊值的低位會被當做初始的儲存索引地址。如果此地址已經被佔用了,V8 會嘗試向下一個地址進行插入,直到插入成功,虛擬碼表述如下:

儘管計算鍵名雜湊值與比較的速度會比較快,但是每次讀寫屬性的時候都進行這麼多步驟無疑會大大拉低速度,因此 V8 儘可能地會避免使用這種儲存方式。

Fast Elements:數值下標的屬性

V8 中將屬性名為非負整數(0、1、2……)的屬性稱為Element,每個物件都有一個指向Element陣列的指標,其存放和其他屬性是分開的。注意,隱藏類中並不包含 Element 的描述符,但可能包含其它有著不同 Element 型別的同一種隱藏類的轉換描述符。大多數情況下,物件都會有 Fast Element,也就是說這些 Element 以連續陣列的形式存放。有三種不同的 Fast Element:

  • Fast small integers
  • Fast doubles
  • Fast values

根據標準,JavaScript 中的所有數字都理應以64位浮點數形式出現。因此 V8 儘可能以31位帶符號整數來表達數字(最低位總是0,這有助於垃圾回收器區分數字和指標)。因此含有Fast small integers型別的物件,其 Element 型別只會包含這樣的數字。如果需要儲存小數、大整數或其他特殊值,如-0,則需要將陣列提升為 Fast doubles。於是這引入了潛在的昂貴的複製-轉換操作,但通常不會頻繁發生。Fast doubles 仍然是很快的,因為所有的數字都是無封箱儲存的。但如果我們要儲存的是其他型別,比如字串或者物件,則必須將其提升為普通的 Fast Element 陣列。

JavaScript 不提供任何確定儲存元素多少的辦法。你可能會說像這樣的辦法,new Array(100),但實際上這僅僅針對Array建構函式有用。如果你將值存在一個不存在的下標上,V8會重新開闢更大的記憶體,將原有元素複製到新記憶體。V8 可以處理帶空洞的陣列,也就是隻有某些下標是存有元素,而期間的下標都是空的。其內部會安插特殊的哨兵值,因此試圖訪問未賦值的下標,會得到undefined。當然,Fast Element 也有其限制。如果你在遠遠超過當前陣列大小的下標賦值,V8 會將陣列轉換為字典模式,將值以雜湊表的形式儲存。這對於稀疏陣列來說很有用,但效能上肯定打了折扣,無論是從轉換這一過程來說,還是從之後的訪問來說。如果你需要複製整個陣列,不要逆向複製(索引從高到低),因為這幾乎必然觸發字典模式。

由於普通的屬性和數字式屬性分開存放,即使陣列退化為字典模式,也不會影響到其他屬性的訪問速度(反之亦然)。

Object 程式碼宣告

 

相關文章