【JSConf EU 2018】JavaScript引擎: 精粹部分

想成為工匠的碼農發表於2018-06-19

JSConf EU 2018圓滿結束, 谷歌V8的開發者Mathias Bynens以及Benedikt Meurer一起發表了《JavaScript Engines: The Good Parts™》演講,本文將帶領大家回顧一下演講上所提到的重點。

演講第一部分: JavaScript引擎

JavaScript引擎

JavaScript引擎解析原始碼並將其轉換成抽象語法樹(AST)。基於AST,直譯器產生位元組碼。此時,引擎正在執行JavaScript程式碼。為了加快執行速度,位元組碼連同分析資料一起傳送到編譯器。編譯器根據已有的分析資料做出某些假設,然後生成優化後機器程式碼。

1

JavaScript引擎中的直譯器/編譯器

通過對比主流JavaScript引擎之間的一些實現差異來說明JavaScript引擎是如何執行你的程式碼。

直譯器快速生成未優化的位元組碼,編譯器會花費更長的時間,但最終產生高度優化的機器程式碼。

2
以上基本就是V8在Chrome和Node.js中的工作流程

3
V8的直譯器負責生成和執行位元組碼。當它執行位元組碼時,它收集分析資料,這些資料是優化的依據。當函式執行時,生成的位元組碼和分析資料被傳遞給TurboFan編譯器,基於分析資料生成高度優化的機器程式碼。

4
SpiderMonkey是Mozilla的JavaScript引擎,在Firefox和SpiderNode中使用,它和我們上面所講的流程有點不同。它有兩個編譯器。Baseline編譯器生成一些優化的程式碼。結合在執行程式碼時收集的分析資料,IonMonkey編譯器可以產生重度優化的程式碼。如果優化失敗,IonMonkey 回退到Baseline的優化程式碼。

5
Chakra,微軟的JavaScript引擎,用於Edge和Node-ChakraCore,有非常類似的兩個優化編譯器。直譯器生成的位元組碼先通過SimuleJIT生成優化程式碼,這裡的JIT代表即時編譯器。結合分析資料,FuljJIT可以產生更加的優化程式碼。

6
JavaScriptCore(簡稱 JSC),蘋果的JavaScript引擎,用於Safari和React Native,它包含三種不同的編譯器。LLInt直譯器生成位元組碼,可以經過Baseline編譯器生成優化的程式碼。還可以通過DFG編譯器進行進一步優化,最後還可以交給FTL編譯器進行優化。

直譯器可以快速生成位元組碼,但位元組碼通常執行效率不高。另一方面,編譯器需要更長的時間,但最終會產生更高效的機器程式碼。快速獲取程式碼以執行(直譯器)或佔用更多時間,但最終以最佳效能執行程式碼(編譯器)之間存在權衡。

演講第二部分:JavaScript的物件模型

ECMAScript規範基本上將所有物件定義為字典,並將字串鍵對映到描述物件。

7

JavaScript對於陣列的定義類似於物件。例如,包括陣列索引在內的所有鍵都顯式表示為字串。陣列中的第一個元素儲存在鍵“0”。

8
“長度”屬性只是另一個不可列舉和不可配置的屬性。一旦元素新增到陣列中,JavaScript會自動更新“length”屬性的[[Value]描述物件。
9

演講第三部分:屬性的訪問優化

屬性訪問是JavaScript程式中最常見的操作。對JavaScript引擎來說,快速訪問屬性是至關重要的。

const object = {
	foo: 'bar',
	baz: 'qux',
};

// Here, we’re accessing the property `foo` on `object`:
doSomething(object.foo);
//          ^^^^^^^^^^
複製程式碼

Shape

在JavaScript程式中,具有相同屬性鍵的物件是常見的。這樣的物件具有相同的Shape。

const object1 = { x: 1, y: 2 };
const object2 = { x: 3, y: 4 };
// `object1` and `object2` have the same shape.`
在相同Shape的物件上訪問相同的屬性也是非常常見的:
`function logX(object) {
	console.log(object.x);
	//          ^^^^^^^^
}

const object1 = { x: 1, y: 2 };
const object2 = { x: 3, y: 4 };

logX(object1);
logX(object2);
複製程式碼

所以,JavaScript引擎可以基於物件的Shape優化屬性的訪問。

假設我們有一個屬性為x和y的物件,它使用我們前面討論過的字典資料結構:它包含作為字串的鍵,並且他們指向各自屬性的描述物件。

10
如果你訪問了一個屬性,例如object.y,JavaScript引擎將在js物件中查詢關鍵字“y”,然後載入相應的描述物件,最後返回[[Value]]屬性的值。

如果每個JS物件都儲存描述物件,會造成大量的重複和不必要的記憶體開銷。JavaScript引擎會將這些物件的Shape分開儲存。

11
這個Shape使用offset代替了[[Value]],每一個具有相同Shape的JS物件都指向這個Shape例項。

12
當有多個物件時,只要它們有相同的Shape,只需要儲存一個就可以!

所有JavaScript引擎都使用Shape作為優化,但它們並不都稱之為Shape:

  • 學術論文稱之為Hidden Classes
  • V8稱之為Maps
  • Chakra稱之為Types
  • JavaScriptCore稱之為Structures
  • SpiderMonkey稱之為Shapes 演講中統一使用了Shape。

過渡鏈與過渡樹

如果一個物件指向某個Shape,你給它新增一個新的屬性,JavaScript引擎如何找到新的Shape。這類Shape在JavaScript引擎中形成所謂的“過渡鏈”。下面是一個例子:

13
物件開始時沒有任何屬性,因此指向空Shape。下一個語句將一個值為5鍵為“x”的屬性賦值給這個物件,因此JavaScript引擎將JS物件指向一個包含屬性“x”的Shape,並且將5新增到JS物件的第0位。下一行程式碼新增了一個屬性“y”,因此引擎將JS物件指向另一個包含屬性“x”和屬性“y”的Shape,並且將6追加到JS物件的第1位。

我們甚至不需要為每個Shape儲存完整的屬性表。相反,每一個Shape僅需要知道它所引入的新屬性。例如,在這種情況下,我們不必在最後一個Shape中儲存關於“x”的資訊,因為它可以在鏈中更早地找到。為了做到這一點,每一個Shape都和上一個Shape產生連結:

14
如果你在JavaScript程式碼中編寫了o.x,JavaScript引擎通過過渡鏈找到引入屬性“y”的Shape,從而找到找到屬性“x”。

但是如果沒有辦法建立一個過渡鏈怎麼辦?例如,如果有兩個空物件,並且向每個物件新增不同的屬性呢?

const object1 = {};
object1.x = 5;
const object2 = {};
object2.y = 6;
複製程式碼

在這種情況下,我們必須使用分支取代鏈,我們最終得到一個過渡樹:

15

引擎對已經包含屬性的物件應用了一些優化。要麼從空物件開始新增“x”,要麼有一個已經包含“x”的物件:

const object1 = {};
object1.x = 5;
const object2 = { x: 6 };
複製程式碼

16
物件在一開始就指向包含屬性“x”的Shape,有效地跳過空Shape。V8和SpiderMonkey就是這樣做的。這種優化縮短了過渡鏈,並使其更高效地從文字構造物件。

內聯快取(ICs)

ICs是使JavaScript快速執行的關鍵因素!JavaScript引擎使用ICs來記住在何處查詢物件屬性的資訊,以減少查詢次數。 這裡有一個函式getX,它獲取一個物件並從中載入屬性“x”:

function getX(o) {
	return o.x;
}
複製程式碼

如果我們在JSC中執行這個函式,它會生成下面的位元組碼:

17
第一個get_by_id指令從第一個引數(arg1)載入屬性“x”,並將結果儲存到loc0中。第二個指令返回我們儲存到的LoC0。

JSC還將內聯快取嵌入到get_by_id指令中,該指令由兩個未初始化的槽組成。

18
現在假設我們使用{x:“a”}引數來呼叫getX。如我們所知,這個物件指向有屬性“x”的Shape,並且該Shape儲存了屬性“x”的偏移量和描述物件。當第一次執行該函式時,get_by_id指令查詢屬性“x”,並發現該值被儲存在偏移量0。
19
嵌入到get_by_id指令中的IC記住了這個屬性是從哪個Shape以及偏移量中找到的:
20
對於後續的執行,IC只需要比較Shape,如果它與以前相同,只需從儲存的偏移量中載入值即可。具體地說,如果JavaScript引擎看到物件指向了IC之前記錄的Shape,那麼就不需要重新去查詢,可以完全跳過昂貴的屬性查詢。這比每次查詢屬性要快得多。

演講第四部分:有效的儲存陣列

陣列使用陣列索引來儲存屬性。這些屬性的值稱為陣列元素。為每個陣列元素儲存描述物件是不明智的。陣列索引屬性預設為可寫、可列舉和可配置,JavaScript引擎將陣列元素與其他屬性分開儲存。

看一下這個陣列:

const array = [
	'#jsconfeu',
];
複製程式碼

引擎儲存的陣列長度為1,並指向包含length的Shape,偏移值為0。

21

22
每個陣列都有一個單獨的元素後備儲存區,它包含所有陣列索引的屬性值。JavaScript引擎不必為每個陣列元素儲存任何描述物件,因為它們通常都是可寫的、可列舉的和可配置的。

如果更改陣列元素的描述物件,會怎麼樣?

// Please don’t ever do this!
const array = Object.defineProperty(
	[],
	'0',
	{
		value: 'Oh noes!!1',
		writable: false,
		enumerable: false,
		configurable: false,
	}
);
複製程式碼

上面的程式碼段定義了一個名為“0”的屬性(恰好是一個陣列索引),但它將屬性設定為非預設值。

在這樣的極端情況下,JavaScript引擎將整個元素後備儲存區作為字典,對映描述物件到每個陣列索引。

23
即使只有一個陣列元素有非預設描述物件,整個陣列的元素後備儲存區也會進入這個緩慢而低效的模式。避免在元素索引上使用Object.defineProperty!

結語

本次演講讓我們明白JavaScript引擎是如何工作的,如何儲存物件和陣列,以及如何通過Shape和ICs優化了屬性的訪問,如何優化了陣列的儲存。基於這些知識,為我們確定了一些實用的可以幫助提高效能的編碼技巧:

  • 總是以同樣的方式初始化物件,它們最終會有相同的Shape。
  • 不要修改陣列元素的描述物件,它們可以有效地儲存。

註記

  • 本文結構及程式碼來自 Mathias Bynens以及Benedikt Meurer 在 JSConf EU 2018 上所作的演講 JavaScript Engines: The Good Parts™。錄影地址:https://www.youtube.com/watch?v=5nmpokoRaZI&index=11&list=PL37ZVnwpeshG2YXJkun_lyNTtM-Qb3MKa
  • 同時也可以閱讀本次演講的Blog:https://mathiasbynens.be/notes/shapes-ics

相關文章