【譯】JavaScript 運行原理(二): 瞭解V8引擎 & 如何優化代碼的5個技巧

原文鏈接 How JavaScript works: an overview of the engine, the runtime, and the call stack by Alexander Zlatkovjavascript

幾周以前咱們開始了一系列的文件旨在深刻了解JavaScript和它是如何運行的:咱們認爲,經過了解JavaScript的組成部分以及它們如何一塊兒發揮做用,你可以編寫出更好的代碼和應用。html

這一系列的第一部提供了引擎,運行時和調用棧的概覽。第二篇將深刻了解V8引擎。咱們還將提供一些有關如何編寫更好的JavaScript代碼的快速提示,這也是咱們SessionStack(做者所在公司)開發團隊在構建產品時遵循的最佳作法。java

概述

JavaScript引擎是執行JavaScript代碼的程序或解釋器。 JavaScript引擎能夠實現爲標準解釋器,也能夠做爲即時編譯器將JavaScript編譯爲某種形式的字節碼。git

如下列表是實現了JavaScript引擎的流行項目github

  • V8 — 開源,由Chrome 開發,使用C++編寫
  • Rhino — 由Mozilla 基金會管理,開源,使用java編寫
  • SpiderMonkey — 第一個JavaScript 引擎,過去支持Netscape Navigator,如今支持FireFox
  • JavaScriptCore — 開源, 由Nitro銷售,由Apple爲Safari開發
  • KSJ — Harde Porten最初爲KDE項目的Konqueror網絡瀏覽器開發的KDE引擎
  • CHakra(JScript9) — Internet Explore
  • CHakra(JavaScript) — Microsoft Edge (新版的基於Chromium, 也就是JavaScript 引擎使用的是V8)
  • Nashorn — 由OpenJDK部分開源,由Oracle Java and Tool 組編寫
  • JerryScript — 物聯網輕量級引擎

一. 爲何要開發V8引擎?

V8引擎是由Google創建的開源工程,使用C++編寫。該引擎在Chrome內部使用。和其餘引擎不同,V8也被用於流行的Node.js中。編程

V8最初旨在提升Web瀏覽器中JavaScript執行的性能。爲了提高速度,V8將JavaScript代碼轉換爲更有效的機器代碼,而不是使用解釋器。它經過像許多現代JavaScript引擎(例如SpiderMonkey或Rhino(Mozilla))同樣實現JIT(即時)編譯器,在執行時將JavaScript代碼編譯爲機器代碼。這裏的主要區別是V8不會產生字節碼或任何中間碼。

二. V8 使用的兩種編譯器

在V8的5.9版本出來以前,引擎使用兩種編譯器(後續版本中已經使用TurboFan 替代了前二者, 這裏終於原文進行翻譯,最新的編譯器你們請點前面的鏈接來學習):數組

  • full-codegen — 一個簡單可是快速的編譯器,產生了簡單的相對慢的機器碼
  • GrankShaft — 一個複雜的(Just-In-time)優化的編譯器,產生了高度優化的代碼

V8引擎在內部使用了多線程:瀏覽器

  • 主線程作那些你指望的任務:拉去你的代碼,編譯和執行
  • 另外有一個獨立的線程用於編譯,所以線程正在優化代碼的時候主線程任然能夠繼續執行
  • 探查器線程(Profiler threads)能夠告訴運行時(runtime)哪些方法會花大量的時間以便於Grankshaft優化它們
  • 一些線程來處理垃圾回收器(GarBage Collector)的清掃工做線程正在優化代碼

當首次執行JavaScript代碼的時候,V8的full-codegen直接原模原樣的轉義解析了的JavaScript代碼爲機器碼。這使得它能快速的執行機器碼。請注意,V8不會以這種方式使用中間字節碼錶示,從而無需解釋器。緩存

當你的代碼執行了一段時間,探查器線程聚集了足夠的數據肯定那個方法須要被優化安全

下一步,Grankshaft 開始在另一個線程進行優化。它將JavaScript抽象語法樹轉換爲稱爲Hydrogen的高級靜態單分配(SSA)表示形式,並嘗試優化該Hydrogen圖。大多數優化都在此級別上完成。

圖片地址: v8.dev/blog/igniti…

三. 內聯

第一個優化是儘量的提早內聯儘量多的代碼。內聯是將調用部分(調用函數的代碼行)替換爲被調用函數的主體的過程。這個簡單的步驟可以使後續優化變得更有意義。

四. 隱藏類

JavaScript是基於原型的語言:沒有使用克隆建立的類和對象。 JavaScript仍是一種動態編程語言,這意味着能夠在實例化對象後輕鬆地添加或刪除屬性。

大多數JavaScript解釋器使用類字典的結構(基於哈希函數)將對象屬性值的位置存儲在內存中。與非動態編程語言(例如Java或C#)相比,在JavaScript使用這種結構檢索屬性的值在計算上更加昂貴。在Java中,全部對象屬性都是在編譯以前由固定的對象結構肯定的,而且沒法在運行時動態添加或刪除(C#具備動態類型,這是另外一個主題)。結果就是,屬性的值(或指向這些屬性的指針)能夠做爲連續緩衝區存儲在內存中,而且在每一個緩衝區之間具備固定偏移量。能夠根據屬性類型輕鬆肯定偏移量的長度,可是在JavaScript中這是不可能的,由於JavaScript在運行時能夠更改屬性類型。

因爲使用字典查找對象屬性在內存中的位置效率很低,所以V8改用了另外一種方法:隱藏類。隱藏類的工做方式相似於Java之類的語言中使用的固定的對象結構(類),但它們是在運行時建立的。如今,讓咱們看看它們的實際外觀:

function Point(x, y) {
    this.x = x;
    this.y = y;
}
var p1 = new Point(1, 2);
複製代碼

一旦"new Point(1,2)" 執行了,V8將會建立一個隱藏的類稱之爲「C0」

C0類上尚未任何屬性,因此"C0"是空的。

第一條語句「this.x = x」執行(在Point 函數內部),V8將會基於「 C0」建立第二個隱藏類「C1」,「C1」描述了能夠找到X屬性的內存位置(至關於對象指針)。在這個例子中,「x」能夠存儲的偏移量是0,也就是說能夠把在內存中的Point對象看爲一個連續的緩衝區,第一個偏移值就與「x」相對應。若是屬性"x"添加到對象"point"中,V8也會經過「類轉換(class transition)」更新"C0",將隱藏類從「C0」轉換到「C1」。如今隱藏類變成了「C1」。

每次新屬性添加到對象中,舊的隱藏類將會轉換爲新的隱藏類。隱藏類轉換很是重要,由於它們容許隱藏類在以相同方式建立的對象之間進行共享。若是兩個對象共享一個隱藏類,而且向它們兩個都添加了相同的屬性,則類轉換將確保兩個對象都接收到相同的新隱藏類以及後續的優化代碼。

當語句「this.y = y」 (在Point 函數內部,this.x=x後面)被執行的時候,上訴步驟會重複執行。

新的隱藏類「C2」被建立,「類轉換」將被應用於「C1」,此時屬性「y」被加入Point對象中(該對象已經包含了屬性x)。隱藏類從「C1「轉換到「C2」。

隱藏的類轉換取決於將屬性添加到對象的順序。看下面的代碼片斷

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;
複製代碼

如今,你假設p1和p2會應用相同的隱藏類和類轉換。可是,這是不正確的,對於p1,首先添加的是a屬性,而後添加的b屬性,可是對於p2首先添加的是b屬性而後添加的是a屬性。所以,p1和p2是不一樣的隱藏類和不一樣的類轉換。在這種狀況下,最好以相同的順序初始化動態屬性,以便複用隱藏類。

五. 內聯緩存

V8充分利用另外一種稱爲「內聯緩存」的優化動態語言的技術。內聯緩存依賴於這種情形:對相同方法的調用每每發生在相同類型的對象上。

內聯緩存的深刻解釋請參考這裏

咱們將介紹內聯緩存的通常概念(以防你沒有時間瞭解上述的深刻說明)

它是如何工做的?V8維護了在最近的方法調用中做爲參數傳遞的對象類型的緩存。使用此信息能夠對未來做爲參數傳遞的對象類型作出假設。若是V8可以很好地假設將來將傳遞給方法的對象的類型,則它能夠繞開找出如何訪問對象屬性的過程,而可使用先前查找到的對象的隱藏類的存儲信息(該信息能夠經過偏移量訪問屬性)。

隱藏類和內聯緩存是如何相關聯的?當在某個對象上調用方法的時候,V8引擎會查詢對象的隱藏類去決定是否使用偏移量訪問對象的屬性。若是兩次調用了一樣的方法到一樣的隱藏類。在對相同的隱藏類成功調用同一方法兩次以後,V8會省略了隱藏類查找,只是將屬性的偏移量添加到對象指針自己。對於之後使用該方法的全部調用,V8引擎都假定隱藏類未更改,並使用之前查找中存儲的偏移量直接跳轉到特定屬性的內存地址。這大大提升了執行速度。

舉例(注:由於做者這一段說的比較模糊,因此參考了其餘文章添加了一個例子,原文中並無該示例):

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

若是是JSC(JavaScriptCore和V8同樣是JS引擎,見上文),會生成一下字節碼。

其中函數getX的第一個指令 get_by_id從第一個參數arg1加載屬性"x",而且把它存儲到結果「loc0」中,第二個指令返回存儲的「loc0」。

JSC還將內聯緩存嵌入到get_by_id指令中,該指令由兩個未初始化的插槽組成。其中Shape和上文提到的內聯類同樣,只是表達方式不同,它屬於SpiderMonkey。

如今假設咱們使用對象{x:'a'}調用getX。該對象的Shape具備屬性「x」,而且Shape存儲該屬性x的偏移量和屬性。首次執行該函數時,get_by_id指令查找屬性「x」並發現該值存儲在偏移量0處。

對於後續運行,內斂緩存僅須要比較Shape,若是與之前相同,則只需從存儲的偏移量加載值便可。

內聯緩存也是爲何同類型的對象共享隱藏類如此重要的緣由。若是你建立了兩個相同類型的對象可是卻有不一樣的隱藏類(咱們以前提到過的)。即便兩個類具備相同的類型,V8將不會使用內聯緩存,由於他們的隱藏類爲同一個屬性分配了不一樣的偏移量。

六. 編譯爲機器碼

一旦Hydrogen圖被優化,Crankshaft將其下降爲較低級別的表示形式稱爲Lithium。大多數的Lithium的實現是特定結構。寄存器分配也發生在此級別。

最終,Lithium編譯爲機器碼。 而後稱之爲OSR的開始執行堆疊替換(on-stack replacement)。在咱們編譯和優化明顯長的方法的時候,會先運行它。V8不會忘記重啓優化後的代碼的時候執行會變慢。因此,它會轉換咱們全部的上下文(堆,寄存器),以便咱們能夠在執行過程當中切換到優化版本。這是一個很是複雜的任務,請記住,在全部的優化中,V8已經在最開始內聯了代碼。V8不是惟一可以作到這一點的引擎。

有一種稱爲反優化的保護措施,能夠進行相反的轉換,並在假設引擎再也不適用的狀況下還原爲未優化的代碼。

七. 垃圾回收

對於垃圾收集,V8使用標記清除的傳統世代方法來清理舊一代的對象。標記階段應該中止JavaScript執行。爲了控制GC成本並使執行更加穩定,V8使用了增量標記:不是遍歷整個堆而是嘗試標記每一個可能的對象,而是遍歷堆的一部分,而後恢復正常執行。下一個GC將從上一個堆遍歷中止的位置繼續。這容許在正常執行期間很是短的暫停。如前所述,清除階段由單獨的線程處理。

八. Ignition and TurboFan

隨着2017年初V8 5.9的發佈,引入了新的執行管道。這個新的管道在實際的JavaScript應用程序中實現了更大的性能改進並顯著節省了內存。

新的執行管道基於Ignition,V8解釋器,TurboFan,V8最新優化的編譯器。

你能夠從V8團隊的文章中查看。

自從V8 5.9發佈以來full-codegen和Crankshaft (從2010年開發服務v8的技術)不在被v8用於Javascript的執行。 v8團隊一直在努力跟上新的JavaScript語言功能以及這些功能所需的優化。

這意味着整個V8未來將具備更加簡單和可維護的體系結構。

這些提升僅僅是開始, 新的Ignition和TurboFan管道爲進一步優化鋪平了道路,這些優化將在將來幾年內提升JavaScript性能並縮小V8在Chrome和Node.js中的佔用空間。

最後,這是有關如何編寫通過優化的JavaScript的一些技巧。你能夠從上文中輕鬆獲得這些內容,可是,爲方便起見,如下是摘要:

  1. 給對象屬性定義一個順序: 始終以相同的順序實例化對象屬性,以便於能夠共享隱藏類和後續優化的代碼
  2. 動態屬性(Dynamic properties): 在實例化對象以後給對象添加屬性將會強制更改隱藏類和並減慢爲先前的隱藏類優化的全部方法的執行速度。最好,在對象的構造函數中分配其全部屬性。
  3. Methods: 重複執行相同的代碼比執行不一樣的代碼各一次要快的多(因爲內聯緩存)。
  4. 數組: 避免那些鍵不是增量數字的稀疏數組。沒有填滿元素的稀疏數組是一個哈希表。這種數組中的元素訪問起來代價更昂貴。另外,請嘗試避免預先分配大數組。隨你的需求增加最好。最後,不要刪除數組中的元素。它使鍵稀疏。
  5. 標籤值(Tagged values): V8用32位表示對象和數字。它使用一個位來知道它是一個對象(flag= 1)仍是一個稱爲SMI(小整數)的整數(flag= 0),由於它有31位。而後,若是數值大於31位,則V8會將封裝該數字,將其變成雙精度並建立一個新對象以將數字放入其中。儘量使用31位帶符號的數字,以免對JS對象進行昂貴的封裝操做。

九. 本系列其餘文章

  1. 關於引擎,運行時,調用棧的概述
  2. 深刻了解V8引擎 & 如何寫出最優代碼的5個提示
  3. 內存管理 & 如何處理4種常見的內存泄露
  4. 事件循環機制和異步編程的興起 & 經過async/await更好的編碼的5種方法
  5. 經過SSE深刻了解WebSockets和HTTP2 & 如何選擇正確的路徑
  6. 對比WebAssembly & 爲何某種狀況下它要優於JavaScript
  7. Web Workers的構 & 你須要用都它的5種狀況
  8. Service Workers,它的生命週期和使用案例
  9. Web Push Notifications的機制
  10. 經過MutatioinObserver跟蹤DOM的變化
  11. 渲染引擎和優化技巧
  12. 深刻了解網絡層 & 性能優化和安全性
  13. 理解CSS和JS動畫的內部原理 & 性能優化
  14. 解析,抽象語法樹(ASTs) & 如何優化解析時間
  15. 類和繼承的內部原理 & Babel和TypeScript轉義(transpiling)
  16. Storage 引擎 & 如何選擇合適的存儲API
  17. Shadow Dom 的內部原理 & 如何構建獨立的組件
  18. WebRTC和對等網絡機制
相關文章
相關標籤/搜索