原文 How JavaScript works: inside the V8 engine + 5 tips on how to write optimized code
幾周前我們開始了一個系列博文旨在深入挖掘 JavaScript
并弄清楚它的工作原理:我們認(rèn)為通過了解 JavaScript
的構(gòu)建單元并熟悉它們是怎樣結(jié)合起來的,有助于寫出更好的代碼和應(yīng)用盏浙。
這個系列的第一篇文章聚焦于提供一個關(guān)于引擎政供、運(yùn)行時和調(diào)用棧的概述椒振。本文將會深入分析 Google
的 V8
引擎的內(nèi)部實現(xiàn)。我們也會提供一些編寫更優(yōu)質(zhì) JavaScript
代碼的小技巧——我們的團(tuán)隊在構(gòu)建 SessionStack
應(yīng)用時遵循的最佳實踐澎迎。
概述
JavaScript
引擎是執(zhí)行 JavaScript
代碼的程序或解釋器。 JavaScript
引擎可以實現(xiàn)為標(biāo)準(zhǔn)的解釋器夹供,或即時編譯器仁堪,以某種形式將 JavaScript
編譯成字節(jié)碼。
以下是一些流行的 JavaScript
引擎項目:
-
V8 —— 開源弦聂,
Google
開發(fā),C++
編寫 -
Rhino ?——
Mozilla
基金會管理莺葫,開源匪凉,完全使用Java
開發(fā) -
SpiderMonkey —— 第一個
JavaScript
引擎,以前由Netscape Navigator
維護(hù)徙融,現(xiàn)在由Firefox
維護(hù) -
JavaScriptCore —— 開源洒缀,以
Nitro
的名義銷售瑰谜,由Apple
公司為Safari
瀏覽器開發(fā) -
KJS? ——
KDE
的引擎欺冀,最初由Harri Porten
為KDE
項目的Konqueror
瀏覽器開發(fā) -
Chakra (JScript9)? ——
IE
瀏覽器 -
Chakra (JavaScript)? ——
Edge
瀏覽器 -
Nashorn ——
OpenJDK
開源項目的一部分,由Oracle Java
和其工具集開發(fā) - JerryScript? —— 一個輕量級的物聯(lián)網(wǎng)引擎
為什么要創(chuàng)建V8引擎萨脑?
谷歌公司研發(fā)的 V8
引擎是由 C++
編寫的開源引擎隐轩。該引擎使用在谷歌瀏覽器內(nèi)部。但與其他引擎不同的是渤早,V8
也應(yīng)用于 Node.js
這一流行的運(yùn)行時當(dāng)中职车。
V8
最初是為了提高瀏覽器中 JavaScript
執(zhí)行的性能而設(shè)計的。為了獲得速度鹊杖,V8
將 JavaScript
代碼轉(zhuǎn)換成更高效的機(jī)器編碼而不是使用解釋器悴灵。同其他現(xiàn)代 JavaScript
引擎如 SpiderMonkey
或 Rhino
(Mozilla
)所做的一樣,V8
通過實現(xiàn)即時編譯器在執(zhí)行時將 JavaScript
代碼編譯成機(jī)器代碼骂蓖。其中最主要的區(qū)別是 V8
不生成字節(jié)碼或任何中間代碼积瞒。
V8曾有兩個編譯器
在 V8
5.9版本發(fā)布之前(2017年初發(fā)布),該引擎使用兩個編譯器:
- full-codegen —— 簡單登下、非趁?祝快的編譯器叮喳,生成簡單和相對較慢的機(jī)器代碼
- Crankshaft ?—— 更加復(fù)雜的(即時)優(yōu)化編譯器,生成高度優(yōu)化的代碼
同時 V8
內(nèi)部使用了多條線程:
- 主線程的工作正如你所預(yù)期:獲取代碼缰贝、編譯然后執(zhí)行代碼
- 另有一條獨(dú)立線程負(fù)責(zé)編譯馍悟,這樣主線程可以在前者優(yōu)化代碼時繼續(xù)執(zhí)行
- 一條分析器線程會告訴運(yùn)行時,哪些方法會耗費(fèi)大量時間以便
Crankshaft
編譯器優(yōu)化代碼 - 還有幾條線程處理垃圾回收清理
首次執(zhí)行 JavaScript
代碼時剩晴,V8
利用 full-codegen
無過渡地直接將解析后的 JavaScript
轉(zhuǎn)換成機(jī)器代碼锣咒。這使得它可以非常快速地開始執(zhí)行機(jī)器代碼赞弥。注意 V8
不使用中間代碼表示宠哄,因此擺脫了對解釋器的需要。
在你的代碼運(yùn)行了一定時間后毛嫉,分析線程就能收集到足夠的數(shù)據(jù)判斷哪些方法需要優(yōu)化承粤。
接著闯团,Crankshaft
優(yōu)化在另一線程開始房交。它將 JavaScript
抽象語法樹轉(zhuǎn)換成高級靜態(tài)單賦值(SSA
)表示,稱為 Hydrogen
(注:氮)刃唤,并嘗試優(yōu)化氮圖尚胞。大多數(shù)優(yōu)化都在這個級別完成帜慢。
內(nèi)聯(lián)
優(yōu)化的第一步是先內(nèi)聯(lián)盡可能多的代碼粱玲。內(nèi)聯(lián)是一個將調(diào)用引用(函數(shù)調(diào)用的那行代碼)替換成所調(diào)用的函數(shù)體的過程。這個簡單的步驟使接下來的優(yōu)化過程更有意義:
隱藏類
JavaScript
是基于原型的語言:沒有類允青,使用克隆的方式創(chuàng)建對象昧廷。JavaScript
還是一個動態(tài)編程語言,這意味著當(dāng)對象被初始化之后還可以輕易地增刪其屬性皆串。
大多數(shù) JavaScript
解釋器采用類字典數(shù)據(jù)結(jié)構(gòu)(基于哈希函數(shù))來存儲對象屬性值在內(nèi)存中的位置眉枕。這種結(jié)構(gòu)使得在 JavaScript
中取回屬性值的計算開銷比非動態(tài)語言如 Java
或 C#
更昂貴速挑。在 Java
中,所有的對象屬性在編譯前就由固定對象布局決定了翅萤,不允許在運(yùn)行時動態(tài)增加或刪除(C#
有動態(tài)類型套么,但那是另一個話題)胚泌。因此肃弟,屬性值(或指向?qū)傩缘闹羔槪┚涂梢砸赃B續(xù)緩沖區(qū)存儲在內(nèi)存中笤受,之間用固定的偏移量隔開。偏移量的長度簡單地根據(jù)屬性的類型確定绅项,然而這在 JavaScript
中是不可能的,因為屬性類型可以在運(yùn)行時更改囊陡。
由于通過字典查找對象屬性在內(nèi)存中的位置非常低效撞反,V8
采用了另一方法作為替代:隱藏類。隱藏類的原理類似于 Java
等語言中使用的固定對象布局(類)嘹害,除了是在運(yùn)行時創(chuàng)建。現(xiàn)在幢踏,讓我們來看看它們實際是什么樣的:
function Point(x, y) {
this.x = x;
this.y = y;
}
var p1 = new Point(1, 2);
當(dāng) new Point(1, 2)
調(diào)用發(fā)生房蝉,V8
將創(chuàng)建了一個名為 C0
的隱藏類。
現(xiàn)在 Point
還沒有定義任何屬性微渠,所以 C0
是空的。
一旦第一條聲明 this.x = x
開始執(zhí)行(在 Point
函數(shù)內(nèi))檀蹋,V8
將創(chuàng)建第二個基于 C0
的隱藏類 C1
续扔。C1
描述了在內(nèi)存中(相對于 point
對象)能找到屬性 x
的位置焕数。在這個例子中堡赔,x
保存在偏移量為 0
的位置,這意味著在將內(nèi)存中的對象視作一個連續(xù)緩沖區(qū)時灼捂,第一個偏移量對應(yīng)著 x
悉稠。V8
還會通過一個“類轉(zhuǎn)換”更新 C0
,以表明如果一個屬性 x
被添加到 point
對象中的猛,隱藏類 C0
就會轉(zhuǎn)換成 C1
想虎。下面 point
對象的隱藏類現(xiàn)在變成了 C1
舌厨。
當(dāng) this.y = y
語句執(zhí)行時將會重復(fù)同樣的過程(同樣在 Point
函數(shù)內(nèi)肴颊,this.x = x
之后)渣磷。
新的隱藏類 C2
將被創(chuàng)建醋界,C1
發(fā)生類轉(zhuǎn)換表示如果向一個 Point
對象添加屬性 y
(已經(jīng)包含一個屬性 x
),隱藏類應(yīng)該更新為 C2
丘侠,并且 point
對象的隱藏類更新為 C2
蜗字。
隱藏類轉(zhuǎn)換依賴向?qū)ο笏砑訉傩缘捻樞蚺膊丁U埧聪旅娴拇a片段:
function Point(x, y) {
this.x = x;
this.y = y;
}
var p1 = new Point(1, 2);
p1.a = 5;
p1.b = 6;
var p2 = new Point(3, 4);
p2.b = 7;
p2.a = 8;
現(xiàn)在你可能會假設(shè) p1
和 p2
使用相同的隱藏類和轉(zhuǎn)換级零。實際則并非如此奏纪。對于 p1
,先添加屬性 a
然后添加屬性 b
亥贸。而對于 p2
,先添加的屬性是 b
然后才是 a
男韧。因此,由于轉(zhuǎn)換路徑不同此虑, p1
和 p2
最終將會產(chǎn)生不同的隱藏類朦前。在這種情況下韭寸,最好在初始化動態(tài)屬性時保持順序一致以便復(fù)用相同的隱藏類恩伺。
內(nèi)聯(lián)緩存
V8
利用了另一項叫做內(nèi)聯(lián)緩存的技術(shù)來優(yōu)化動態(tài)類型語言。內(nèi)聯(lián)緩存依賴于這樣一種觀察:同一方法的重復(fù)調(diào)用通常發(fā)生在同一類型的對象上凰荚。關(guān)于內(nèi)聯(lián)緩存的深入闡述在這里便瑟。
我們準(zhǔn)備介紹內(nèi)聯(lián)緩存的一般概念(以免你沒有時間查看上述的深入闡述)番川。
那么它的原理是什么爽彤?V8
維護(hù)著在最近的方法調(diào)用中作為參數(shù)傳入的對象類型的緩存适篙,并利用這個信息假設(shè)未來會被當(dāng)做參數(shù)的對象的類型嚷节。如果 V8
能很好地假設(shè)出將要傳入方法的對象的類型,就能直接越過如何獲取對象屬性的計算過程衩婚,取而代之的是使用之前查找對象的隱藏類時存儲的信息非春。
那么隱藏類是如何與內(nèi)聯(lián)緩存關(guān)聯(lián)起來的?每當(dāng)某一對象調(diào)用方法時护侮,V8
必須執(zhí)行對此對象的隱藏類的查詢來確定訪問某個屬性的偏移量羊初。當(dāng)對同一隱藏類成功調(diào)用過兩次同樣的方法后什湘,V8
將省略對隱藏類的查詢而只將屬性偏移量添加到對象指針本身长赞。對于那個方法未來所有的調(diào)用得哆,V8
都假定隱藏類不改變柳恐,并利用之前查詢存儲的偏移量直接跳到某一屬性的內(nèi)存地址乐设。這極大地提高了執(zhí)行速度近尚。
內(nèi)聯(lián)緩存也是同類對象共享同一隱藏類如此重要的原因。如果你創(chuàng)建了擁有不同隱藏類的兩個同類對象(正如前面的例子)也搓,V8
就無法使用內(nèi)聯(lián)緩存,因為即便這兩個對象是相同的類型拒迅,但他們對應(yīng)的隱藏類為屬性指定了不同的偏移量她倘。
編譯到機(jī)器代碼
一旦氮圖優(yōu)化好后个扰,Crankshaft
會將它降為更低水平的表示葱色,稱為 Lithium
(注:鋰)苍狰。大多數(shù) Lithium
的實現(xiàn)依賴于特定架構(gòu)淋昭。寄存器分配發(fā)生在這個級別翔忽。
最終歇式,Lithium
被編譯成機(jī)器代碼材失。隨后發(fā)生 OSR
:堆棧上替換。在開始編譯和優(yōu)化明顯長時間運(yùn)行的方法前硫豆,我們可能會運(yùn)行它熊响。V8
不會在再次開始執(zhí)行優(yōu)化版本時忘記那些緩慢的執(zhí)行。而是轉(zhuǎn)換我們所有的上下文(棧秸弛,寄存器)以便能在執(zhí)行中切換到優(yōu)化版本剔难。這是個非常復(fù)雜的任務(wù)胆屿,記住在其他的優(yōu)化中,V8
最先做了代碼內(nèi)聯(lián)偶宫。V8
不是唯一有這種能力的引擎非迹。
還有種被稱為反優(yōu)化的安全措施能做反向轉(zhuǎn)換,回退到未優(yōu)化代碼纯趋,以防引擎做出的假設(shè)不再成立憎兽。
垃圾回收
在垃圾回收方面冷离,V8
采用傳統(tǒng)分代方法標(biāo)記和清掃來清理老的代。標(biāo)記階段會暫停 JavaScript
的執(zhí)行纯命。為了控制垃圾回收的開銷并使執(zhí)行更加穩(wěn)定西剥,V8
采用增量標(biāo)記:它不遍歷全部棧堆,而是嘗試標(biāo)記每一個可能的對象咆畏,它只遍歷棧堆的一部分麦牺,然后恢復(fù)正常執(zhí)行魏颓。下一次垃圾回收暫停會在之前棧堆的停止位置繼續(xù)枕荞。這可使正常執(zhí)行期間只發(fā)生相當(dāng)短的暫停渣刷。正如之前提到的瞭吃,清理階段由單獨(dú)的線程處理。
Ignition 和 TurboFan
隨著2017年初 V8
5.9版本的發(fā)布止状,一個新的執(zhí)行管道被引入。新的管道在實際的JavaScript
應(yīng)用中實現(xiàn)了更大的性能提升和的顯著的內(nèi)存節(jié)省伏社。
新的執(zhí)行管道構(gòu)建在 V8
的解釋器 Ignition
和 V8
最新的優(yōu)化編譯器 TurboFan
之上。
你可以在這里查閱 V8
團(tuán)隊關(guān)于這個主題的博文妨马。
自從 V8
5.9版本發(fā)布以來脂崔, V8
就不再在 JavaScript
執(zhí)行里使用 full-codegen
和 Crankshaft
(自2010年來一直支撐著 V8
的技術(shù)),這是由于 V8
團(tuán)隊也在努力地跟上新的 JavaScript
語言特性的腳步和這些特性所需的優(yōu)化屁擅。
這意味著將來在整體上 V8
將擁有更加簡單和更易于維護(hù)的架構(gòu)。
這些提升僅僅是個開始早抠。新的 Ignition
和 TurboFan
管道鋪墊了更遠(yuǎn)的優(yōu)化之路吼过,將會推進(jìn) JavaScript
的性能并在接下來的幾年里縮小 V8
在 Chrome
和 Node.js
中的足跡羊赵。
最后,這里有幾條關(guān)于如何編寫更優(yōu)化的、更好的 JavaScript
代碼的建議和技巧。雖然你可以很容易地從上述的內(nèi)容中得到這些,為了方便還是把它們做了以下的總結(jié):
怎么編寫優(yōu)化的JavaScript
- 對象屬性的順序:始終使用相同的順序初始化對象屬性劲绪,以便共享隱藏類和隨后的優(yōu)化代碼祷安。
- 動態(tài)屬性:在初始化完成之后添加對象動態(tài)屬性會強(qiáng)制改變隱藏類并使之前的隱藏類已優(yōu)化的方法變慢汇鞭。相反淡溯,在對象的構(gòu)造器里指定所有的屬性强品。
- 方法:重復(fù)執(zhí)行相同方法的代碼會比僅執(zhí)行一次許多不同的方法運(yùn)行的更快(由于內(nèi)聯(lián)緩存)夫晌。
- 數(shù)組:避免使用鍵值不遞增的稀疏數(shù)組盏档。并非每個元素都存在的稀疏數(shù)組是一個哈希表。訪問稀疏數(shù)組的元素將會花費(fèi)更昂貴的開銷鱼填。此外宦言,避免預(yù)先分配大數(shù)組响疚。最好是按需要增加長度鸦采。最后咱旱,不要刪除數(shù)組中的元素吐限。這會使數(shù)組變得稀疏。
-
帶標(biāo)記的值:
V8
用32位字節(jié)表示對象和數(shù)字诸典。其中使用了一個位來標(biāo)識是對象(標(biāo)識為1)或是整數(shù)(標(biāo)識為0)蒋搜,由于它們是31位的而被稱為SMI
(SMall Integer
)豆挽。如果一個數(shù)值大小超過了31位可以表示的數(shù)字,V8
將會包裝它私蕾,將其轉(zhuǎn)換為一個雙字節(jié)類型值并創(chuàng)建一個新的對象存入其中。盡量使用31帶符號的數(shù)值避免JS
對象的昂貴包裝操作。