Google V8 引擎淺析 - 面向對象
繼續上次分享的主旨,再來談一談 V8 引擎的一些細節優化的處理,對第一期感興趣的同學可以看下:Google V8 引擎淺析 [1]
寫在前面
JavaScript 中的對象扮演着舉足輕重的角色,本次分享希望能從底層機制的角度,來探究下 V8 引擎在對象處理上,又做了哪些性能方面的優化,又能給我們日常工作上帶來哪些比較有趣啓迪或者值得遵守的建議呢?
對象屬性優化
Fast properties in V8 · V8[2]
從語言的角度來看,JavaScript 中的對象像一個字典,字符串作爲鍵名,任意對象可以作爲鍵值,可以通過鍵名讀寫鍵值。本次快慢屬性的討論範圍,只限於單一對象的內部屬性查找,不涉及到從原型鏈向上查找這一場景。
V8 並沒有完全採用字典的結構來存儲數據,因爲我們知道,非線性結構查找數據的效率是低於線性結構的(連續內存空間分配後的查找明顯是優於非連續空間的),而是爲了提升存儲和查找的性能,採用了一套較爲複雜的策略。
我們先以代碼示例來看下:
function testV8() { // 隨意定義一些key和value的組合,有整數的、小數、字符串等key
this[1] = 'test-1'
this["B"] = 'foo-B'
this[50] = 'test-50'
this[8] = 'test-8'
this[3] = 'test-3'
this[5] = 'test-5'
this["4"] = 'test-4'
this["A"] = 'foo-A'
this["C"] = 'foo-C'
this[4.5] = "foo-4.5"
}
const testObj = new testV8();
for (let key in testObj) {
console.log(`${key}:${testObj[key]}`);
}
控制檯輸出的 key-value 的結果:
多次打印後,發現輸出的順序是一致的,並沒有隨機性,但同樣沒有按照我們定義的順序來輸出,根據這一個現象,我們就要探究下,在 V8 引擎中,對象屬性內部的設計思想。
在 V8 內部,爲了有效地提升存儲和訪問這兩種屬性的性能,分別使用了兩個線性數據結構來分別保存兩種屬性。
origin_img_v2_b7194174-62a0-4909-8bbc-a22f89d350bg.png
如果在數組屬性(排序屬性)和命名屬性(常規屬性)同時存在的情況下,優先數組屬性排序,上面的例子中將 "4" 轉換成了數字整型,而浮點數 "4.5" 轉換成了字符串,V8 會先從 elements 屬性中按照順序讀取所有的元素,然後再在 properties 屬性中讀取所有的元素,這樣就完成一次索引操作。我們通過 chrome 調試工具 snapshot 來佐證下:
但爲什麼上面的快照中的對象卻沒有顯示 properties 的屬性呢?這就和 V8 的另外一個優化策略有關了:對象內屬性(In-object Properties)。
對象內屬性(In-object Properties)
當採用兩種線性結構存儲後,在查詢屬性的時候,就會明顯多出了一個步驟,要先去查詢到 Properties 對應的對象(多了一次尋址的過程),再從 Properties 對象中查到對應的某個 key 的值,V8 爲了提升這個過程的效率,提出來了對象內屬性的思路:當對象的屬性數量小於 10 個的情況,直接將屬性 key 存在對象內的屬性上,如果需要查詢某個 key 的值時,直接中對象內中獲取對應 key 的值就可以了。
我們再來驗證下:
function testV8(properties, elements) {
//添加可索引屬性
for (let i = 0; i < elements; i++) {
this[i] = `element${i}`;
}
//添加常規屬性
for (let i = 0; i < properties; i++) {
const prop = `property${i}`;
this[prop] = prop;
}
}
const testobj = new testV8(15, 10);
for (let key in testobj) {
console.log(`${key}:${testobj[key]}`);
}
快屬性
保存在線性數據結構中的屬性,通過索引就可以訪問到對應的屬性值,但也存在一個缺點,就是在刪除的時候效率不高。
慢屬性
屬性過多的時候,V8 會採用 "慢屬性" 的處理,屬性的對象內部會有獨立的非線性數據結構(字典)
當屬性數量不是特別多的情況下,Properties 的索引是有序的(快屬性),但當屬性數量特別多的時候,就會變成無序的字典類型的存儲(慢屬性)。
const testobj = new testV8(10, 10);
%DebugPrint(testobj);
const testobj1 = new testV8(150, 100);
%DebugPrint(testobj1);
爲什麼還要用慢屬性,而不用全部線性結構的快屬性呢?
簡單來講:在屬性的數量比較大的時候,快屬性的訪問的速度就不一定比慢屬性的速度快了。
我們可以粗略的計算一次:假定一次哈希運算的成本,等於 n 次簡單位運算的時間,一個對象有 M 個屬性值,如果全部使用快屬性,查詢速度等於 M 次的簡單位運算之和;如果使用慢屬性,只需要一次哈希運算(n 次位運算的成本) + 二維的線性比較(對比 key 值後取到對應 key 的 value 值),線性比較的成本遠低於一次哈希運算的成本,這樣估算的情況下,M > 2n 的情況下,慢屬性的查詢速度就會快於快屬性的查詢速度。
還有一種場景,當對一個對象頻繁的插入屬性或者刪除屬性的時候,如果一直用快屬性,線性結構的查詢效率是 O(1),但插入或者刪除時的效率就變成了 O(n),這個時候 V8 就會降級處理成慢屬性。
隱藏類 (Hidden Class)
靜態語言中,當創建類型後,就不能再次改變了,屬性可以通過固定的偏移量來訪問,但在 js 中卻不是,對象的屬性的類型、值等信息是可以隨時改變的,也就是說運行的時候才能拿到最後的屬性內存偏移量,V8 爲了提升對象的屬性獲取性能,設計了 Hidden Class 隱藏類的概念,每一個對象都有對應的隱藏類,當每次對象的屬性發生改變時,V8 會動態更新對應的內存偏移量更新到隱藏類中。
-
每個對象都擁有自己的隱藏類:上面例子中對應的 map 屬性就是隱藏類對象。
-
隱藏類中記錄了對象中每個屬性的標識信息 (descriptors),它保存了屬性 key 以及描述符數組的指針。描述符數組包含了有關命名屬性的信息,例如名稱本身以及值保存的位置,但只會存命名屬性相關的,不會保存整數類的屬性
- 當對象創建一個新屬性,或者一個老屬性被刪除時,V8 會創建一個新的隱藏類並通過 back_pointer 指針指向老的隱藏類,新的隱藏類中只記錄進行了變更的屬性信息,隨後對象指向隱藏類的指針會指向新的隱藏類。
const testobj1 = new testV8(2, 3);
const testobj2 = new testV8(2, 3);
testobj2.new = "new";
- 對象創建一個新屬性時,會檢查該對象隱藏類的轉換信息(transition information) 。如果轉換信息包含了與當前屬性更改相同的條件,則對象會將其隱藏類變更爲轉換信息中記錄的類,而不會再創建一個新的隱藏類。
const testobj1 = new testV8(2, 3);
const testobj2 = new testV8(2, 3);
const testobj3 = new testV8(2, 3);
testobj2.new = "new"; //
testobj3.new = "new"; // testobj2 3的隱藏類使用的是一個,不是新創建一個
在引擎的底層,V8 創建了一個將隱藏類連接在一起的轉換樹(transiton tree),相同順序增加屬性,會保證隱藏類的引用是同一個。
帶孔(hole)的數組
當我們去查詢一個數組裏面的元素時,如果數組本身並不存在,按照原型鏈的原理,會逐一向上查詢,這樣就增加了 “多餘” 的開銷,在 V8 中增加了對數組是否全部充滿(packed)的判斷,如果數組是 packed 的情況下,再查不到對應的值,就不會再沿着數組的原型鏈查詢了,而是在當前作用域中直接查詢。
const a = [1, 2, 3];
delete a[1];
a.__proto__ = {1:2};
console.log(a[1]); //2
a 最開始是完全填滿的數組(Paked-Array), 但行 2 把第二位的元素刪除掉了,V8 同時增加了一個_hole 來標記缺失的元素,表明 a 已經不再是充滿的數組了,再當去查詢的時候纔會去按照原型鏈的原理去查詢。這個策略對於數組的查詢至關重要。
const a = new Array(10); // HOLEY_SMI_ELEMENTS
const b = [1,2,3,4] // PACKED_SMI_ELEMENTS
%DebugPrint(a);
%DebugPrint(b);
爲什麼數組也是對象類型的?
const c = [1, "hello", true, function () { // 數組內部也是用key-value的存儲形式
return 1;
}];
%DebugPrint(c);// PACKED_ELEMENTS
快慢數組
const a = [];
a[9999] = "9999";
如果 V8 要按照正常的邏輯處理聲明的話,會新開 10000 個數組長度,這種是對空間相當浪費的,V8 這個時候會把數組降級的慢數組,改用Object.defineProperty(object, key, descriptor)
的 Api 來定義。
那究竟什麼是快數組和慢數組呢?我們看下 V8 底層對於數組的定義:https://source.chromium.org/chromium/chromium/src/+/master:v8/src/objects/js-array.h
-
Fast 模式的存儲結構是 FixedArray [3] 並且長度小於等於 elements.length,可以通過 push 和 pop 擴容和縮容數組(弱語言類型,無法固定空間,只能通過 length 變化實現),實現內存空間的連續
-
slow 模式的存儲結構是一個以數字爲鍵的 HashTable[4](哈希表),不用開闢連續的內存空間,節省內存
數組擴容
空數組預分配的大小爲 4
// v8/src/objects/js-objects.h 551
static const uint32_t kMinAddedElementsCapacity = 16;
// Computes the new capacity when expanding the elements of a JSObject.
static uint32_t NewElementsCapacity(uint32_t old_capacity) {
// (old_capacity + 50%) + kMinAddedElementsCapacity
// 擴容公式:原有內存容量(1.5倍)+ 16
return old_capacity + (old_capacity >> 1) + kMinAddedElementsCapacity;
}
template <typename TIndex>
// 嘗試擴容元素空間
TNode<FixedArrayBase> CodeStubAssembler::TryGrowElementsCapacity(
TNode<HeapObject> object, TNode<FixedArrayBase> elements, ElementsKind kind,
TNode<TIndex> key, TNode<TIndex> capacity, Label* bailout) {
static_assert(
std::is_same<TIndex, Smi>::value || std::is_same<TIndex, IntPtrT>::value,
"Only Smi or IntPtrT key and capacity nodes are allowed");
Comment("TryGrowElementsCapacity");
CSA_SLOW_DCHECK(this, IsFixedArrayWithKindOrEmpty(elements, kind));
// If the gap growth is too big, fall back to the runtime.
TNode<TIndex> max_gap = IntPtrOrSmiConstant<TIndex>(JSObject::kMaxGap);
TNode<TIndex> max_capacity = IntPtrOrSmiAdd(capacity, max_gap);
GotoIf(UintPtrOrSmiGreaterThanOrEqual(key, max_capacity), bailout);
// Calculate the capacity of the new backing store. 計算出新的存儲空間
TNode<TIndex> new_capacity = CalculateNewElementsCapacity(
IntPtrOrSmiAdd(key, IntPtrOrSmiConstant<TIndex>(1)));
// 執行擴容
return GrowElementsCapacity(object, elements, kind, kind, capacity,
new_capacity, bailout);
}
擴容後將數組拷貝到新的內存空間中
數組縮容
https://source.chromium.org/chromium/chromium/src/+/main:v8/src/objects/elements.cc;l=739
數組的 pop 方法會觸發 PopImpl 方法,PopImpl 又會調用 RemoveElement,RemoveElement 最後會調用到 SetLengthImpl 方法,判斷是否要縮容,如果容量大於等於實際內容的 length*2 + 16, 則進行縮容調整,否則就用上面提到的_hole 標記來填充未初始化的位置,elements_to_trim 是來計算要裁剪掉的大小,通過 length + 1 和 old_length 來判斷是將空出來的空間全部裁掉還是留一半。
快慢轉換
- 快 > 慢
static const uint32_t kMaxGap = 1024;
static const uint32_t kPreferFastElementsSizeFactor = 3;
class NumberDictionaryShape : public NumberDictionaryBaseShape {
public:
static const int kPrefixSize = 1;
static const int kEntrySize = 3;
};
-
如果快數組新的容量 >= 3 * 擴容後的容量 * 2,意味着它比 HashTable 形式存儲佔用更大的內存,快數組會轉換爲慢數組;
-
如果快數組新增的索引與原來最大索引的差值大於等於 1024,中間全部是_hole 標記,快數組會被轉換會慢數組。
const a = [1]
a[1025] = 3;
%DebugPrint(a);
- 慢 > 快
元素能存放在快數組中並且長度不在 smi 之間(64 位 - 2^31 到 2^32-1),並且當前慢數組空間相比快數組節省值小於等於 50%,則轉變成爲快數組。
const a = [1]
a[1025] = 3;
for (let i = 300; i < 1025; i++) {
a[i] = i;
}
%DebugPrint(a);
-
快數組就是以空間換時間的方式,申請了大塊連續內存,提高了執行效率。
-
慢數組以時間換空間,不必申請連續的空間,節省了內存,但需要付出效率變差的代價。
總結:
js 的數組看似不同,其實只是 V8 在底層實現上做了一層封裝,使用兩種數據結構實現數組,並且通過時間和空間 2 個緯度的取捨,優化了數組的性能。
內聯緩存策略 (Inline Cache)
雖然隱藏類能夠加速查找對象的速度,但是在 V8 查找對象屬性值的過程中,依然有查找對象的隱藏類和根據隱藏類來查找對象屬性值的過程。
function getXProps(o) {
return o.x;
}
const o1 = {x:1, y:2, z:3};
const o2 = {x:3, y:100, m:10, n: -3};
for(let i = 0; i < 10000; i++) {
getXProps(o1);
getXProps(o2);
}
上段代碼中 getXProps 的方法會被反覆執行,也就是代表着:查找 o 對象的隱藏類,再通過隱藏類查找 x 屬性的偏移量,最後再通過偏移量獲取屬性值的這個過程會被反覆執行,V8 對這種過程是否有優化呢?
答案就是內聯緩存 (Inline Cache) ,簡稱爲 ****IC,這個技術已經很古老了,最初的應用是在 Smalltalk 虛擬機上 [5],原理簡單講就是在代碼運行過程中,收集一些關鍵數據信息,將這部分信息緩存起來,再次執行的時候直接用這些信息,有效的節省了再次獲取這些信息的消耗,從而提高了性能。
V8 中利用 IC 的機制,爲每個函數維護了一個反饋向量(FeedBack Vector),其實就是用了一個表結構來存儲關鍵信息:
-
單態:一個插槽中,只包含 1 個隱藏類
-
多態:一個插槽中,包含了 2-4 個隱藏類
-
超態:一個插槽中,包含了超過 4 個隱藏類
function setProps(o) {
o.y = 4;
return o.x;
}
setProps({x:1,z:5});
setProps({x:100,z:20});
執行過程分析:
-
V8 識別出來有兩個調用點,o.y 和 return o.x
-
在執行的時候,每個調用點會向反饋向量(FeedBack Vector)表中插入一條緩存數據
-
當再次調用 setProps 方法的時候,每次執行到對應點的時候,V8 就直接先去對應的插槽中尋找對應屬性的偏移量(offset),之後就直接可以從內存中獲取對應的屬性值就可以了,大大提升了 V8 的執行效率
-
上面的 {x:1,z:5} 和{x:100,z:20} 屬性名和順序都是一致的,所以隱藏類是同一個,屬於單態,而最開始的例子中,隱藏類是 2 個,所以對應的是多態。多態的情況下,要在 map 中的多個隱藏類進校一一對比
let data = [1, 2, 3, 4];
let data1 = ['1', 2, '3', 4];
data.forEach((item) => console.log(item.toString());
data1.forEach((item) => console.log(item.toString());
// 哪個效率更高,why?
總結
-
儘量保持單態,比如定義了一個公共方法 loadX(obj),參數的形狀儘量的保持相同的一種。
-
儘量不要大量的對對象添加刪除操作,避免命名屬性退化成慢屬性。
-
儘量同類型的對象,對象的屬性、順序、個數要保持一致,避免創建過多的隱藏類。
-
儘量不要一次初始化超長的數組,同時不賦值
-
實際的項目中,不要過度考慮是否破壞了 V8 底層的優化機制,找出那些直接影響性能瓶頸的問題才至關重要
下期預告
V8 垃圾回收器 & 內存管理
消息隊列 & 異步編程
協程與進程
參考
JavaScript 引擎基礎:Shapes 和 Inline Caches[6]
V8 Hidden class - LINE ENGINEERING[7]
Fast properties in V8 · V8[8]
Explaining JavaScript VMs in JavaScript -[9]
參考資料
[1]
Google V8 引擎淺析: https://juejin.cn/post/7018468848886579214
[2]
Fast properties in V8 · V8: https://v8.dev/blog/fast-properties
[3]
FixedArray : https://source.chromium.org/chromium/chromium/src/+/main:v8/src/objects/fixed-array.h;l=101;bpv=0;bpt=1
[4]
HashTable: https://source.chromium.org/chromium/chromium/src/+/main:third_party/swiftshader/third_party/llvm-10.0/llvm/include/llvm/DebugInfo/PDB/Native/HashTable.h;l=103;drc=1b51a630d5f980e6d1b22c90d1891ddc809313e1?q=%20HashTable&ss=chromium%2Fchromium%2Fsrc
[5]
在 Smalltalk 虛擬機上: https://zh.wikipedia.org/zh-hans/%E5%86%85%E8%81%94%E7%BC%93%E5%AD%98
[6]
JavaScript 引擎基礎:Shapes 和 Inline Caches: https://zhuanlan.zhihu.com/p/38202123
[7]
V8 Hidden class - LINE ENGINEERING: https://engineering.linecorp.com/en/blog/v8-hidden-class/
[8]
Fast properties in V8 · V8: https://v8.dev/blog/fast-properties
[9]
Explaining JavaScript VMs in JavaScript -: https://mrale.ph/blog/2012/06/03/explaining-js-vms-in-js-inline-caches.html
我們來自字節跳動,是旗下大力教育前端部門,負責字節跳動教育全線產品前端開發工作。
我們圍繞產品品質提升、開發效率、創意與前沿技術等方向沉澱與傳播專業知識及案例,爲業界貢獻經驗價值。包括但不限於性能監控、組件庫、多端技術、Serverless、可視化搭建、音視頻、人工智能、產品設計與營銷等內容。
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/MRDnh959wDD5LC3k6IMo9w