V8 之旅:物件表示

發表於2015-07-22

前一篇文章中,我們觀察了V8的簡單編譯器——Full Compiler。在我們繼續觀察Crankshaft之前,為更好地理解它,我們首先來看看V8在記憶體中如何表達物件。

本文來自Jay Conrod的A tour of V8: object representation,其中的術語、程式碼請以原文為準。

概覽

簡易的圖表或許是瞭解物件表示的最為快速直觀的方法。

object-representation

所有的物件記憶體區都會有一個Map指標,用以描述該物件的結構。絕大多數物件將其自身的屬性存放在一塊記憶體中(“a”和“b”);附加的命名屬性通常會存放在一個單獨的陣列中(“c”和“d”);而數字式的屬性則單獨存放在另一個地方,通常是一個連續的陣列。

這張圖僅僅表示已被優化的JS物件的通常狀態,另有一些狀態來處理其他情況。如果你對此抱有興趣,繼續讀下文吧。

屬性的怪異屬性

V8有它的難處:JavaScript標準中允許開發者以非常靈活的方式定義物件,因此很難用一種形式來高效地表示物件。一個物件基本上就是一堆屬性的集合:也就是一群鍵值對。你可以以兩種方式來訪問物件的屬性:

根據標準,屬性的名稱永遠是字串。如果你用不是字串的東西來作為屬性的名稱,那它將會被隱式轉換為字串。所以一個怪異的情況就是,如果用數字作為屬性名,則數字也會被轉換為字串(至少根據標準就是這樣)。因此,你可以以小數或者負數來作為下標。

陣列在JS中也只是帶有神奇length屬性的物件。大多數陣列的屬性名都是非負整數,而length的值則來計算於這些屬性名中最大的那個加一,比如:

除此之外陣列和普通的物件沒什麼區別。函式也是物件,只不過它們的length屬性返回的是其定義的引數個數。

字典模式

譯註,也即雜湊表模式

既然JavaScript中的物件就是鍵值對對映,為何不直接以雜湊表來表示物件呢?這種方式沒什麼問題,V8內部實際上也用了這樣的方式來表達一些難以用優化形式表達的物件(後文詳述)。但訪問雜湊表中的值要比訪問指定偏移的值慢多了。

我們來看看字串和雜湊表在V8中如何工作。字串有多種表達方式,用來表示屬性名的是最常見的ASCII碼序列——所有字元挨個排列,每個字元1位元組。

譯註:左邊是偏移量,右邊是該偏移量起始記憶體存放的值含義;從0開始,除最後一處外每個要素佔用4位元組,最後一處則是長度為length的字元

字串通常不可變,唯一可能變的是惰性計算而來的雜湊值。用做屬性名的字串被稱為符號,這意味著它必須獨有(譯註:原文uniquified,意思是這個字串物件不會因為在其他地方也引用了,導致其它地方可以對這個物件的內部進行修改),非獨有的字串如果被用作屬性名,都會被單獨複製一份出來,以便不受其它修改的影響。

V8中的雜湊表由一個包含鍵和值的大陣列組成。初始時,所有的鍵和值都被初始化為undefined(一個特殊值),當有鍵值對插入到雜湊表中時,鍵的雜湊值被計算出來,其低位被用作陣列的下標。如果陣列的該位置已經被佔用,則雜湊表嘗試(取模過後的)下一個位置,以此類推。以下是這一過程的虛擬碼:

由於符號字串是獨有的,這裡的hash code至多計算一次,計算該值和對比鍵值通常都很快。然而這一演算法仍然不夠簡單,導致於每次訪問物件的屬性都會慢下來。V8會盡可能地避免這種表達方式。

快速的物件內屬性

在Lars Bak(V8的締造團隊領導者)2008年的這段視訊當中,他講述了一種可以在通常情況下更快速訪問屬性的物件表達方式。考慮如下的建構函式:

像這樣的建構函式是最為多見的。絕大多數時間裡,同一建構函式所產生的物件會擁有以相同順序賦值的相同屬性。既然這些物件有著如此類似的結構,我們在記憶體中就可以以這樣相同的結構來佈局這些物件。

V8將這種描述物件的方式稱為Map。你可以假想Map為一張填滿描述符的表,每一項都表示一個屬性。Map也包含其他資訊,比如物件的大小以及指向建構函式和原型的指標等,但這裡我們主要關注這些描述符。同樣結構的物件,通常會共享同一個Map。一個完成初始化的Point例項可能就像這樣:

現在你可能會想到,不是所有的Point例項都有相同的屬性。當Point的例項剛剛在記憶體中開闢空間時(在建構函式中的程式碼真正執行前),它是沒有任何屬性的,Map M2並不符合它的結構。另外,我們也可以在建構函式完成後隨時為它增加新的其他屬性。

V8處理通過一種特殊的描述符來處理這種情形:Transition。當增加一個新的屬性時,除非迫不得已,我們不會建立新的Map,而是儘可能使用一個現存符合結構的Map。Transition描述符就是用來指向這些Map的。

在上面的例子中新的Point例項從沒有任何Field的M0開始;在第一次賦值時,物件的Map指標指向了M1,屬性x的值存放在了偏移量12的位置;在第二次賦值時,Map指標指向了M2,屬性y的值放在了偏移量16的位置。

如果在M2的基礎上再新增屬性呢?

如果新增的屬性之前沒有,則我們會通過複製M2建立一個新的Map,M3,然後將一個新的FIELD描述符增加在M3上。同時我們要在M2上增加一個TRANSITION描述符。注意,新增TRANSITION是修改Map為數不多的情況之一,通常Map是不可變的。

如果物件的屬性並不是以相同的順序出現呢?比如:

在這種情況下,我們最終會得到一個Transition樹,而不是鏈。初始的Map(上面的M0)將會有兩個Transition,具體程式碼中轉向哪個,會根據xy的賦值順序來定。正因為這樣,不是所有的Point都會有相同的Map了。

這正是事情變糟的地方。V8對於這樣的小規模分支情形可以容忍,但如果你的程式碼中充斥著以同一個建構函式得出的隨機賦值物件,V8就會將其退化到字典模式,將屬性存放在雜湊表中。否則就會有大量的Map湧現。

物件內的稀疏追蹤

你可能好奇V8如何確定為一個物件保留多少記憶體。很明顯,我們不希望每次增加屬性都重新開闢記憶體,同時也不想為一個小物件預留大片的記憶體。V8使用一個叫做物件內稀疏追蹤(譯註,原文:In-object slack tracking)的辦法來確定為建構函式的新例項分配多少記憶體。

一開始,建構函式所產生的物件會被分配較多的記憶體:足夠存放32個快速屬性的記憶體。一旦該建構函式例項化了足夠多次(最後一次看的時候是8次),V8就會選取其中最大的例項,通過Transition指標遍歷該建構函式對應的Map。新例項分配的記憶體,將直接使用遍歷得來的最大記憶體值。而最開始例項化出來的物件,也採用了非常精明的方式來縮減記憶體佔用。當物件初始化時,物件所得的記憶體將以接近垃圾回收器可回收記憶體的形式出現。由於物件的Map標明瞭它的記憶體佔用大小,垃圾回收器不會直接回收這片記憶體。直到稀疏追蹤的過程完成之後,Map中的記憶體大小被重新修正,相應物件的記憶體佔用也就小了。此時垃圾回收器會回收掉這些已經是可回收的記憶體,而原先的物件也無需重新挪動。

現在我估計你的下一個問題是,“如果一個物件在稀疏追蹤結束之後又增加了新的屬性呢?”。這就要依靠一個單獨的陣列來存放這些附加的屬性。只要有屬性加入,這個陣列隨時可以重新分配為更大的陣列。

譯註:回憶一下文章開始的那張圖吧

成員函式與原型

JavaScript沒有類,因此它的成員函式呼叫與C++及Java不同。JavaScript中的成員函式只是普通的屬性。在下面的例子中,distance只是Point物件的一個屬性,它指向PointDistance函式。JavaScript中的任何函式都可以作為成員函式,並且通過this來訪問其目標物件。

譯註:在C++中,obj.method(param)實際是C程式碼method(this, param)的語法糖,因此this指標實際是函式的目標物件,而不是函式的發起者。

如果distance像普通的物件內屬性一樣對待,那很顯然會佔用大量的記憶體空間,原因是每一個Point例項都會有一個Field來存放這個共同的屬性。對於有大量成員函式的物件更是如此。我們可以對此改進。

C++解決這個問題的方法是虛表(譯註:原文v-table)。虛表是一個存放各個虛擬函式指標的陣列。帶有虛擬函式的類的每個例項,都會有一個指向該類虛表的指標。當你呼叫虛擬函式時,程式會讀取虛表,並按照虛表中該虛擬函式的地址跳轉執行。在V8中,我們已經有了這麼一個類似的表,它就是Map。

為了讓Map有類似虛表的功能,我們需要為其增加一種新的描述符:Constant Function。CF型別的描述符表示該物件有一個已知名稱的屬性,該屬性不存放在物件中,而是直接尾隨描述符。

注意,轉換到另一個Map只會在描述符的函式與實際函式一致時才會發生。因此如果程式設計師對PointDistance重新賦值為另一個值,則該Transition不再有效,Map也會重新建立。同時注意,我們並不像虛表那樣僅僅是跳轉到虛擬函式,而是會生成一個包含函式地址的優化程式碼,以便在下次執行時,一旦發現物件使用的Map是這個Map並要呼叫該函式,則直接跳轉過去。

JavaScript中還有另一種方法來提供公共屬性,那就是通過建構函式所關聯的原型物件。對於一個建構函式的例項來說,原型物件所擁有的屬性,它也可以直接使用。舉例來說:

這樣的程式碼隨處可見,同時也是實現繼承的一種正規化,因為原型還可以有自己的原型。instanceof操作符所針對的就是原型鏈

和普通物件一樣,V8也會將原型的成員函式以CF描述符來表示。呼叫原型的函式會比直接呼叫物件自己的函式略慢,因為編譯器不僅需要檢查目標物件的Map,同時也要檢查原型鏈上的其他Map。但這不會產生大的效能問題,對於開發者來說也不應影響程式碼書寫。

數字式屬性:Fast Element

至此,我們已經討論了普通屬性和方法,並且假設物件總是以相同順序構造相同的屬性。但這對於數字式的屬性(以下標的形式來訪問的陣列元素)並不成立,同時任何物件都有可能像陣列一樣使用,因此我們需要對陣列一樣的物件區別對待。記住,根據標準,所有的屬性都必須是字串,其他型別會先轉換為字串。

我們將屬性名為非負整數(0、1、2……)的屬性稱為Element。V8中,Element的存放和其他屬性是分開的。每個物件都有一個指向Element陣列的指標,物件Map中的Element Field將反映出Element是如何儲存的。注意,Map中並不包含Element的描述符,但可能包含其它有著不同Element型別的同一種Map的Transition描述符(譯註:換言之,一個Map只對應一種Element陣列,如果Element陣列的型別不同,會形成一個Transition。)。大多數情況下,物件都會有Fast Element,也就是說這些Element以連續陣列的形式存放。有三種不同的Fast Element:

  • Fast small integers
  • Fast doubles
  • Fast values

根據標準,JS中的所有數字都理應以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會將陣列轉換為字典模式,將值以雜湊表的形式儲存。這對於稀疏陣列來說很有用,但效能上肯定打了折扣,無論是從轉換這一過程來說,還是從之後的訪問來說。如果你需要複製整個陣列,不要逆向複製(索引從高到低),因為這幾乎必然觸發字典模式。

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

總結

這篇文章中我們觀察了V8內部是如何表示物件及其屬性的。V8為通用介面提供了針對具體場景可切換的資料儲存模型,這作為VM語言的一項優勢,對於編譯型語言來說是難以企及的:那些語言要麼只能小範圍優化,要麼則依賴於程式設計師對物件結構的控制。

在接下來的文章中,我們要觀察V8的優化編譯器——Crankshaft,以及它是如何利用本文中的這些結構優勢來生成高效程式碼的。

相關文章