[譯文] JavaScript工做原理:V8引擎內部+5條優化代碼的竅門

原文 How JavaScript works: inside the V8 engine + 5 tips on how to write optimized codejavascript

幾周前咱們開始了一個系列博文旨在深刻挖掘 JavaScript 並弄清楚它的工做原理:咱們認爲經過了解 JavaScript 的構建單元並熟悉它們是怎樣結合起來的,有助於寫出更好的代碼和應用。html

這個系列的第一篇文章聚焦於提供一個關於引擎、運行時和調用棧的概述。本文將會深刻分析 GoogleV8 引擎的內部實現。咱們也會提供一些編寫更優質 JavaScript 代碼的小技巧——咱們的團隊在構建 SessionStack 應用時遵循的最佳實踐。java

概述

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

如下是一些流行的 JavaScript 引擎項目:github

  • V8 —— 開源,Google 開發,C++ 編寫
  • Rhino  —— Mozilla 基金會管理,開源,徹底使用 Java 開發
  • SpiderMonkey —— 第一個 JavaScript 引擎,之前由 Netscape Navigator 維護,如今由 Firefox 維護
  • JavaScriptCore —— 開源,以 Nitro 的名義銷售,由 Apple 公司爲 Safari 瀏覽器開發
  • KJS  —— KDE 的引擎,最初由 Harri PortenKDE 項目的 Konqueror 瀏覽器開發
  • Chakra (JScript9)  —— IE 瀏覽器
  • Chakra (JavaScript)  —— Edge 瀏覽器
  • Nashorn —— OpenJDK 開源項目的一部分,由 Oracle Java 和其工具集開發
  • JerryScript  —— 一個輕量級的物聯網引擎

爲何要建立V8引擎?

谷歌公司研發的 V8 引擎是由 C++ 編寫的開源引擎。該引擎使用在谷歌瀏覽器內部。但與其餘引擎不一樣的是,V8 也應用於 Node.js 這一流行的運行時當中。編程

2-1 V8

V8 最初是爲了提升瀏覽器中 JavaScript 執行的性能而設計的。爲了得到速度,V8JavaScript 代碼轉換成更高效的機器編碼而不是使用解釋器。同其餘現代 JavaScript 引擎如 SpiderMonkeyRhinoMozilla)所作的同樣,V8 經過實現即時編譯器在執行時將 JavaScript 代碼編譯成機器代碼。其中最主要的區別是 V8 不生成字節碼或任何中間代碼。數組

V8曾有兩個編譯器

V8 5.9版本發佈以前(2017年初發布),該引擎使用兩個編譯器:瀏覽器

  • full-codegen —— 簡單、很是快的編譯器,生成簡單和相對較慢的機器代碼
  • Crankshaft  —— 更加複雜的(即時)優化編譯器,生成高度優化的代碼

同時 V8 內部使用了多條線程:緩存

  • 主線程的工做正如你所預期:獲取代碼、編譯而後執行代碼
  • 另有一條獨立線程負責編譯,這樣主線程能夠在前者優化代碼時繼續執行
  • 一條分析器線程會告訴運行時,哪些方法會耗費大量時間以便 Crankshaft 編譯器優化代碼
  • 還有幾條線程處理垃圾回收清理

首次執行 JavaScript 代碼時,V8 利用 full-codegen 無過渡地直接將解析後的 JavaScript 轉換成機器代碼。這使得它能夠很是快速地開始執行機器代碼。注意 V8 不使用中間代碼表示,所以擺脫了對解釋器的須要。安全

在你的代碼運行了必定時間後,分析線程就能收集到足夠的數據判斷哪些方法須要優化。

接着,Crankshaft 優化在另外一線程開始。它將 JavaScript 抽象語法樹轉換成高級靜態單賦值(SSA)表示,稱爲 Hydrogen(注:氮),並嘗試優化氮圖。大多數優化都在這個級別完成。

內聯

優化的第一步是先內聯儘量多的代碼。內聯是一個將調用引用(函數調用的那行代碼)替換成所調用的函數體的過程。這個簡單的步驟使接下來的優化過程更有意義:

2-2 Inlining

隱藏類

JavaScript 是基於原型的語言:沒有,使用克隆的方式建立對象。JavaScript 仍是一個動態編程語言,這意味着當對象被初始化以後還能夠輕易地增刪其屬性。

大多數 JavaScript 解釋器採用類字典數據結構(基於哈希函數)來存儲對象屬性值在內存中的位置。這種結構使得在 JavaScript 中取回屬性值的計算開銷比非動態語言如 JavaC#更昂貴。在 Java 中,全部的對象屬性在編譯前就由固定對象佈局決定了,不容許在運行時動態增長或刪除(C#有動態類型,但那是另外一個話題)。所以,屬性值(或指向屬性的指針)就能夠以連續緩衝區存儲在內存中,之間用固定的偏移量隔開。偏移量的長度簡單地根據屬性的類型肯定,然而這在 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 的隱藏類。

2-3 C0

如今 Point 尚未定義任何屬性,因此 C0 是空的。

一旦第一條聲明 this.x = x 開始執行(在 Point 函數內),V8 將建立第二個基於 C0 的隱藏類 C1C1 描述了在內存中(相對於 point 對象)能找到屬性 x 的位置。在這個例子中,x 保存在偏移量爲 0 的位置,這意味着在將內存中的對象視做一個連續緩衝區時,第一個偏移量對應着 xV8 還會經過一個「類轉換」更新 C0,以代表若是一個屬性 x 被添加到 point 對象中,隱藏類 C0 就會轉換成 C1。下面 point 對象的隱藏類如今變成了 C1

2-4 C1

每次添加一個新屬性到對象,舊隱藏類都會經過一個轉換路徑更新成一個新隱藏類。隱藏類轉換之因此如此重要是由於它能使隱藏類在以一樣方式建立的對象間共享。若是兩個對象共享同一個隱藏類並向它們添加相同的屬性,轉換能夠確保它們得到相同的隱藏類和全部與其相關的優化代碼。

this.y = y 語句執行時將會重複一樣的過程(一樣在 Point 函數內,this.x = x 以後)。

新的隱藏類 C2 將被建立,C1 發生類轉換表示若是向一個 Point 對象添加屬性 y (已經包含一個屬性 x),隱藏類應該更新爲 C2,而且 point 對象的隱藏類更新爲 C2

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

如今你可能會假設 p1p2 使用相同的隱藏類和轉換。實際則並不是如此。對於 p1,先添加屬性 a 而後添加屬性 b。而對於 p2,先添加的屬性是 b 而後纔是 a。所以,因爲轉換路徑不一樣, p1p2 最終將會產生不一樣的隱藏類。在這種狀況下,最好在初始化動態屬性時保持順序一致以便複用相同的隱藏類。

內聯緩存

V8 利用了另外一項叫作內聯緩存的技術來優化動態類型語言。內聯緩存依賴於這樣一種觀察:同一方法的重複調用一般發生在同一類型的對象上。關於內聯緩存的深刻闡述在這裏

咱們準備介紹內聯緩存的通常概念(以避免你沒有時間查看上述的深刻闡述)。

那麼它的原理是什麼?V8 維護着在最近的方法調用中做爲參數傳入的對象類型的緩存,並利用這個信息假設將來會被當作參數的對象的類型。若是 V8 能很好地假設出將要傳入方法的對象的類型,就能直接越過如何獲取對象屬性的計算過程,取而代之的是使用以前查找對象的隱藏類時存儲的信息。

那麼隱藏類是如何與內聯緩存關聯起來的?每當某一對象調用方法時,V8 必須執行對此對象的隱藏類的查詢來肯定訪問某個屬性的偏移量。當對同一隱藏類成功調用過兩次一樣的方法後,V8 將省略對隱藏類的查詢而只將屬性偏移量添加到對象指針自己。對於那個方法將來全部的調用,V8 都假定隱藏類不改變,並利用以前查詢存儲的偏移量直接跳到某一屬性的內存地址。這極大地提升了執行速度。

內聯緩存也是同類對象共享同一隱藏類如此重要的緣由。若是你建立了擁有不一樣隱藏類的兩個同類對象(正如前面的例子),V8 就沒法使用內聯緩存,由於即使這兩個對象是相同的類型,但他們對應的隱藏類爲屬性指定了不一樣的偏移量。

2-6 Inline caching

這兩個對象基本相同,但 ab 屬性的建立順序不一樣。

編譯到機器代碼

一旦氮圖優化好後,Crankshaft 會將它降爲更低水平的表示,稱爲 Lithium(注:鋰)。大多數 Lithium 的實現依賴於特定架構。寄存器分配發生在這個級別。

最終,Lithium 被編譯成機器代碼。隨後發生 OSR:堆棧上替換。在開始編譯和優化明顯長時間運行的方法前,咱們可能會運行它。V8 不會在再次開始執行優化版本時忘記那些緩慢的執行。而是轉換咱們全部的上下文(棧,寄存器)以便能在執行中切換到優化版本。這是個很是複雜的任務,記住在其餘的優化中,V8 最早作了代碼內聯。V8 不是惟一有這種能力的引擎。

還有種被稱爲反優化的安全措施能作反向轉換,回退到未優化代碼,以防引擎作出的假設再也不成立。

垃圾回收

在垃圾回收方面,V8 採用傳統分代方法標記和清掃來清理老的代。標記階段會暫停 JavaScript 的執行。爲了控制垃圾回收的開銷並使執行更加穩定,V8 採用增量標記:它不遍歷所有棧堆,而是嘗試標記每個可能的對象,它只遍歷棧堆的一部分,而後恢復正常執行。下一次垃圾回收暫停會在以前棧堆的中止位置繼續。這可以使正常執行期間只發生至關短的暫停。正如以前提到的,清理階段由單獨的線程處理。

Ignition 和 TurboFan

隨着2017年初 V8 5.9版本的發佈,一個新的執行管道被引入。新的管道在實際的JavaScript 應用中實現了更大的性能提高和的顯著的內存節省。

新的執行管道構建在 V8 的解釋器 IgnitionV8 最新的優化編譯器 TurboFan 之上。

你能夠在這裏查閱 V8 團隊關於這個主題的博文。

自從 V8 5.9版本發佈以來, V8 就再也不在 JavaScript 執行裏使用 full-codegenCrankshaft(自2010年來一直支撐着 V8 的技術),這是因爲 V8 團隊也在努力地跟上新的 JavaScript 語言特性的腳步和這些特性所需的優化。

這意味着未來在總體上 V8 將擁有更加簡單和更易於維護的架構。

2-7 Improvements on Web and Node.js benchmarks

這些提高僅僅是個開始。新的 IgnitionTurboFan 管道鋪墊了更遠的優化之路,將會推動 JavaScript 的性能並在接下來的幾年裏縮小 V8ChromeNode.js 中的足跡。

最後,這裏有幾條關於如何編寫更優化的、更好的 JavaScript 代碼的建議和技巧。雖然你能夠很容易地從上述的內容中獲得這些,爲了方便仍是把它們作了如下的總結:

怎麼編寫優化的JavaScript

  1. 對象屬性的順序:始終使用相同的順序初始化對象屬性,以便共享隱藏類和隨後的優化代碼。
  2. 動態屬性:在初始化完成以後添加對象動態屬性會強制改變隱藏類並使以前的隱藏類已優化的方法變慢。相反,在對象的構造器裏指定全部的屬性。
  3. 方法:重複執行相同方法的代碼會比僅執行一次許多不一樣的方法運行的更快(因爲內聯緩存)。
  4. 數組:避免使用鍵值不遞增的稀疏數組。並不是每一個元素都存在的稀疏數組是一個哈希表。訪問稀疏數組的元素將會花費更昂貴的開銷。此外,避免預先分配大數組。最好是按須要增長長度。最後,不要刪除數組中的元素。這會使數組變得稀疏。
  5. 帶標記的值V8 用32位字節表示對象和數字。其中使用了一個位來標識是對象(標識爲1)或是整數(標識爲0),因爲它們是31位的而被稱爲 SMISMall Integer)。若是一個數值大小超過了31位能夠表示的數字,V8 將會包裝它,將其轉換爲一個雙字節類型值並建立一個新的對象存入其中。儘可能使用31帶符號的數值避免 JS 對象的昂貴包裝操做。
相關文章
相關標籤/搜索