V8 中的地圖(隱藏類別)
讓我們展示 V8 如何建立其隱藏類別。主要資料結構為
Map
:隱藏類別本身。它是物件中的第一個指標值,因此可以輕鬆比較以查看兩個物件是否具有相同的類別。DescriptorArray
:此類別所具有的所有屬性的完整清單以及有關其的資訊。在某些情況下,屬性值甚至會出現在此陣列中。TransitionArray
:從此Map
到同層Map
的「邊緣」陣列。每個邊緣都是一個屬性名稱,應將其視為「如果我要將具有此名稱的屬性新增到目前的類別,我會轉換到哪個類別?」
由於許多 Map
物件只有一個轉換到另一個物件(即它們是「過渡性」地圖,只用於轉換到其他物件),因此 V8 並不總是為其建立完整的 TransitionArray
。相反,它只會直接連結到這個「下一個」Map
。系統必須在所指向的 Map
的 DescriptorArray
中進行一些探勘,才能找出附加到轉換的名稱。
這是一個非常豐富的主題。它也可能會變更,不過,如果您了解本文中的概念,未來的變更應可以逐步理解。
為什麼要有隱藏類別? #
當然,V8 可以不用隱藏類別。它會將每個物件視為一個屬性袋。然而,一個非常有用的原則會被擱置:智慧設計的原則。V8 推測您只會建立這麼多不同的種類的物件。而且每種類型的物件將以最終可以視為刻板的方式使用。我說「最終可以視為」是因為 JavaScript 語言是一種腳本語言,而不是預先編譯的語言。因此,V8 永遠不知道接下來會發生什麼事。為了利用智慧設計(也就是假設程式碼背後有一個心智),V8 必須觀察和等待,讓結構感滲入。隱藏類別機制是執行此操作的主要方法。當然,它預設了一個精密的監聽機制,而這些就是已經寫了很多的內聯快取 (IC)。
因此,如果您確信這是一項良好且必要的任務,請追隨我!
一個範例 #
function Peak(name, height, extra) {
this.name = name;
this.height = height;
if (isNaN(extra)) {
this.experience = extra;
} else {
this.prominence = extra;
}
}
m1 = new Peak("Matterhorn", 4478, 1040);
m2 = new Peak("Wendelstein", 1838, "good");
有了這段程式碼,我們已經從根地圖(也稱為初始地圖)取得了一個有趣的映射樹,該地圖附加到函式 Peak
每個藍色方塊都是一個地圖,從初始地圖開始。這是物件的地圖,如果我們設法執行函式 Peak
而沒有新增任何屬性,就會傳回該物件。後續地圖是透過新增地圖之間邊緣上的名稱所給予的屬性而產生的。每個地圖都有與該地圖物件相關聯的屬性清單。此外,它描述每個屬性的確切位置。最後,從其中一個地圖(例如 Map3
,它是物件的隱藏類別,如果您在 Peak()
中傳遞一個數字作為 extra
參數,您將會取得該物件),您可以追蹤一個反向連結,一直到初始地圖。
讓我們再次繪製它,並加上這些額外資訊。註解 (i0)、(i1) 表示物件內部欄位位置 0、1 等
現在,如果您在建立至少 7 個 Peak
物件前花時間檢查這些地圖,您會遇到可能會造成混淆的鬆散追蹤。我寫了另一篇文章探討這個主題。只要建立多 7 個物件,它就會完成。此時,您的 Peak 物件將有正好 3 個物件內部屬性,而且無法直接在物件中新增更多屬性。任何額外的屬性都將卸載到物件的屬性後援儲存區。它只是一個屬性值陣列,其索引來自地圖(好吧,技術上來說,來自附加到地圖的 DescriptorArray
)。讓我們在新的行中新增一個屬性到 m2
,然後再看一次地圖樹
m2.cost = "one arm, one leg";
我在這裡偷偷加了一些東西。請注意,所有屬性都註解為「const」,這表示從 V8 的觀點來看,自建構函式以來,從來沒有人變更過它們,因此它們在初始化後可以視為常數。TurboFan(最佳化編譯器)很喜歡這個。假設 m2
被一個函式參照為常數全域變數。那麼 m2.cost
的查詢可以在編譯時完成,因為該欄位標記為常數。我稍後會在本文中回顧這一點。
請注意,屬性「cost」標記為 const p0
,這表示它是一個常數屬性,儲存在屬性後援儲存區的索引零處,而不是直接儲存在物件中。這是因為我們在物件中沒有更多空間了。這個資訊可以在 %DebugPrint(m2)
中看到
d8> %DebugPrint(m2);
DebugPrint: 0x2f9488e9: [JS_OBJECT_TYPE]
- map: 0x219473fd <Map(HOLEY_ELEMENTS)> [FastProperties]
- prototype: 0x2f94876d <Object map = 0x21947335>
- elements: 0x419421a1 <FixedArray[0]> [HOLEY_ELEMENTS]
- properties: 0x2f94aecd <PropertyArray[3]> {
0x419446f9: [String] in ReadOnlySpace: #name: 0x237125e1
<String[11]: #Wendelstein> (const data field 0)
0x23712581: [String] in OldSpace: #height:
1838 (const data field 1)
0x23712865: [String] in OldSpace: #experience: 0x237125f9
<String[4]: #good> (const data field 2)
0x23714515: [String] in OldSpace: #cost: 0x23714525
<String[16]: #one arm, one leg>
(const data field 3) properties[0]
}
...
{name: "Wendelstein", height: 1, experience: "good", cost: "one arm, one leg"}
d8>
您可以看到我們有 4 個屬性,全部標記為 const。前 3 個在物件中,最後一個在 properties[0]
中,這表示屬性後援儲存區的第一個槽。我們可以看看那個
d8> %DebugPrintPtr(0x2f94aecd)
DebugPrint: 0x2f94aecd: [PropertyArray]
- map: 0x41942be9 <Map>
- length: 3
- hash: 0
0: 0x23714525 <String[16]: #one arm, one leg>
1-2: 0x41942329 <undefined>
這些額外屬性只是在您突然決定要新增更多屬性時備而不用。
真正的結構 #
在這個時間點,我們可以做不同的事情,但由於你一定非常喜歡 V8,已經讀到這裡,我想嘗試繪製我們使用的真實資料結構,也就是在 Map
、DescriptorArray
和 TransitionArray
開頭提到的那些。現在你對在幕後建構的隱藏類別概念有一些概念,你也可以藉由正確的名稱和結構,將你的思考更緊密地結合到程式碼中。讓我嘗試用 V8 的表示法重現最後一個圖形。首先,我要繪製 DescriptorArrays,它包含給定 Map 的屬性清單。這些陣列可以共用,其關鍵在於 Map 本身知道它可以在 DescriptorArray 中查看多少屬性。由於屬性是按照時間順序新增的,因此這些陣列可以由多個 Map 共用。請參閱
請注意,Map1、Map2 和 Map3 全都指向 DescriptorArray1。每個 Map 中「描述符」欄位旁邊的數字表示 DescriptorArray 中有多少欄位屬於 Map。因此,只知道「name」屬性的 Map1,只會查看 DescriptorArray1 中列出的第一個屬性。而 Map2 有兩個屬性,「name」和「height」。因此,它會查看 DescriptorArray1 中的第一個和第二個項目(name 和 height)。這種共用方式可以節省大量空間。
自然而然地,我們無法在有分歧的地方共用。如果新增「experience」屬性,則會從 Map2 轉換到 Map4,如果新增「prominence」屬性,則會轉換到 Map3。你可以看到 Map4 和 Map4 共用 DescriptorArray2,就像 DescriptorArray1 在三個 Map 之間共用一樣。
我們的「真實」圖表中唯一缺少的是 TransitionArray
,它在這個時間點仍然是比喻性的。我們來改變它。我冒昧地移除了 返回指標線,讓事情變得更清晰。請記住,從樹狀結構中的任何 Map,你也可以往上走。
這張圖表值得研究。問題:如果在「名稱」之後新增一個「評分」屬性,而不是繼續新增「高度」和其他屬性,會發生什麼事?
解答:Map1 會取得一個真實的 TransitionArray,以便追蹤分岔。如果新增屬性 高度,我們應該轉換到 Map2。但是,如果新增屬性 評分,我們應該轉到一個新的地圖 Map6。這個地圖需要一個新的 DescriptorArray,其中提到 名稱 和 評分。物件在這個時候有額外的空閒插槽(只有三個中的一個被使用),所以屬性 評分 將會取得其中一個插槽。
我利用 %DebugPrintPtr()
檢查我的解答,並畫出以下內容
不用請我停止,我知道這類圖表的極限在哪裡!但我認為你可以了解這些部分如何移動。想像一下,在新增這個替代屬性 評分 之後,我們繼續新增 高度、經驗 和 成本。嗯,我們必須建立地圖 Map7、Map8 和 Map9。因為我們堅持在已建立的地圖鏈中間新增這個屬性,所以我們會複製許多結構。我沒有心情畫出那個圖形,不過如果你把它寄給我,我會把它新增到這份文件中 :)。
我使用方便的 DreamPuf 專案輕鬆製作這些圖表。以下是 TurboFan 和 const 屬性 #
到目前為止,所有這些欄位在 DescriptorArray
中都標記為 const
。讓我們來玩玩看。在偵錯版本中執行以下程式碼
// run as:
// d8 --allow-natives-syntax --no-lazy-feedback-allocation --code-comments --print-opt-code
function Peak(name, height) {
this.name = name;
this.height = height;
}
let m1 = new Peak("Matterhorn", 4478);
m2 = new Peak("Wendelstein", 1838);
// Make sure slack tracking finishes.
for (let i = 0; i < 7; i++) new Peak("blah", i);
m2.cost = "one arm, one leg";
function foo(a) {
return m2.cost;
}
foo(3);
foo(3);
%OptimizeFunctionOnNextCall(foo);
foo(3);
你會得到最佳化函式 foo()
的列印輸出。程式碼很短。你會在函式的結尾看到
...
40 mov eax,0x2a812499 ;; object: 0x2a812499 <String[16]: #one arm, one leg>
45 mov esp,ebp
47 pop ebp
48 ret 0x8 ;; return "one arm, one leg"!
TurboFan 這個淘氣的傢伙,直接插入了 m2.cost
的值。這真是太棒了!
當然,在最後一次呼叫 foo()
之後,你可以插入這行
m2.cost = "priceless";
你認為會發生什麼事?有一件事可以確定,我們不能讓 foo()
保持原樣。它會傳回錯誤的答案。重新執行程式,但加入旗標 --trace-deopt
,這樣當最佳化程式碼從系統中移除時,你會收到通知。在最佳化 foo()
的列印輸出後,你會看到這些行
[marking dependent code 0x5c684901 0x21e525b9 <SharedFunctionInfo foo> (opt #0) for deoptimization,
reason: field-const]
[deoptimize marked code in all contexts]
哇。

如果你強制重新最佳化,你會得到不太好的程式碼,但仍然可以從我們一直在描述的 Map 結構中獲益良多。從我們的圖表中,可以記住屬性 cost 是
物件屬性備份儲存中的第一個屬性。嗯,它可能失去了 const 指定,但我們仍然有它的位址。基本上,在具有 map Map5 的物件中,我們肯定會驗證全域變數 m2
仍然存在,我們只需要——
- 載入屬性備份儲存,以及
- 讀取第一個陣列元素。
讓我們看看。在最後一行下方加入這段程式碼
// Force reoptimization of foo().
foo(3);
%OptimizeFunctionOnNextCall(foo);
foo(3);
現在來看看產生的程式碼
...
40 mov ecx,0x42cc8901 ;; object: 0x42cc8901 <Peak map = 0x3d5873ad>
45 mov ecx,[ecx+0x3] ;; Load the properties backing store
48 mov eax,[ecx+0x7] ;; Get the first element.
4b mov esp,ebp
4d pop ebp
4e ret 0x8 ;; return it in register eax!
為什麼?這正是我們所說的應該發生的事情。也許我們開始知道了。
如果變數 m2
曾經變更為不同的類別,TurboFan 也夠聰明可以解除最佳化。你可以使用像這樣有趣的東西再次觀看最新的最佳化程式碼解除最佳化
m2 = 42; // heh.
從這裡開始 #
有很多選項。Map 遷移。字典模式(又稱「慢速模式」)。這個領域有許多值得探索的地方,我希望你和我一樣享受其中——感謝你的閱讀!