JavaScript 工做原理之二-如何在 V8 引擎中書寫最優代碼的 5 條小技巧(譯)

原文請查閱 這裏,略有刪減。

本系列持續更新中,Github 地址請查閱這裏javascript

這是 JavaScript 工做原理的第二章。html

本章將會深刻谷歌 V8 引擎的內部結構。咱們也會爲如何書寫更好的 JavaScript 代碼提供幾條小技巧-SessionStack 開發小組在構建產品的時候所遵循的最佳實踐。java

概述

一個 JavaScript 引擎就是一個程序或者一個解釋程序,它運行 JavaScript 代碼。一個 JavaScript 引擎能夠用標準解釋程序或者即時編譯器來實現,即時編譯器即以某種形式把 JavaScript 解釋爲字節碼。git

如下是一系列實現 JavaScript 引擎的熱門工程:github

  • V8-由谷歌開源的以 C++ 語言編寫
  • Rhin-由 Mozilla 基金會主導,開源的,徹底使用 Java 開發。
  • SpiderMonkey-初代 JavaScript 引擎,由在以前由網景瀏覽器提供技術支持,如今由 Firefox 使用。
  • JavaScriptCore-開源,以 Nitro 的名稱來推廣,並由蘋果爲 Safari 開發。
  • KJS-KDE 引擎,起先是由 Harri Porten 爲 KDE 工程的 Konqueror 瀏覽器所開發。
  • Chakra (JScript9)-IE
  • Chakra (JavaScript)-Microsoft Edge
  • Nashorn-做爲 OpenJDK 的一部分來開源,由 Oracle Java 語言和 Tool Group 編寫。
  • JerryScript-一款輕量級的物聯網引擎。

V8 引擎的由來

V8 引擎是由谷歌開源並以 C++ 語言編寫。Google Chrome 內置了這個引擎。而 V8 引擎不一樣於其它引擎的地方在於,它也被應用於時下流行的 Node.js 運行時中。編程

起先 V8 是被設計用來優化網頁瀏覽器中的 JavaScript 的運行性能。爲了達到更快的執行速度,V8 把 JavaScript 代碼轉化爲更加高效的機器碼而不是使用解釋程序。它經過實現一個即時編譯器在運行階段把 JavaScript 代碼編譯爲機器碼,就像諸如 SpiderMonkey or Rhino (Mozilla) 等許多現代 JavaScript 引擎所作的那樣。主要的區別在於 V8 不產生字節碼或者任何的中間碼。數組

V8 曾經擁有兩個編譯器

在 V8 5.9誕生(2017 年初) 以前,引擎擁有兩個編譯器:瀏覽器

  • full-codegen-一個簡單且快速的編譯器用來產出簡單且運行相對緩慢的機器碼。
  • Crankshaft-一個更復雜(即時)優化的編譯器用來產生高效的代碼。

V8 引擎內部也使用多個線程:緩存

  • 主線程作你所指望的事情-抓取你的代碼,編譯後執行
  • 有獨立的線程來編譯代碼,因此主線程能夠保持執行而前者正在優化代碼
  • 一個用於性能檢測的線程會告訴運行時咱們在哪一個方法上花了太多的時間,以便於讓 Crankshaft 來優化這些代碼
  • 有幾個線程用來處理垃圾回收器的清理工做。

當第一次執行 JavaScript 代碼的時候,V8 使用 full-codegen 直接把解析的 JavaScript 代碼解釋爲機器碼,中間沒有任何轉換。這使得它一開始很是快速地運行機器碼。注意到 V8 沒有使用中間字節碼來表示,這樣就不須要解釋器了。安全

當你的代碼已經執行一段時間後,性能檢測器線程已經收集了足夠多的數據來告訴 Crankshaft 哪一個方法能夠被優化。

接下來,在另外一個線程中開始進行 Crankshaft 代碼優化。它把 JavaScript 語法抽象樹轉化爲一個被稱爲 Hydrogen 的高級靜態單賦值而且試着優化這個 Hydrogen 圖表。大多數的代碼優化是發生在這一層。

內聯

第一個優化方法便是提早儘量多地內聯代碼。內聯指的是把調用地址(函數被調用的那行代碼)置換爲被調用函數的函數體的過程。這個簡單的步驟使得接下來的代碼優化更有意義。

隱藏類

JavaScript 是基於原型的語言:當進行克隆的時候不會有建立類和對象。JavaScript 也是一門動態編程語言,這意味着在它實例化以後,能夠任意地添加或者移除屬性。

大多數的 JavaScript 解釋器使用類字典的結構(基於哈希函數)在內存中存儲對象屬性值的內存地址(即對象的內存地址)。這種結構使得在 JavaScript 中獲取屬性值比諸如 Java 或者 C# 的非動態編程語言要更耗費時間。在 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" 的隱藏類。

由於尚未爲類 Point 建立屬性,因此 "C0" 是空的。

一旦第一條語句 "this.x = x" 開始執行(在 Point 函數中), V8 將會基於 "C0" 建立第二個隱藏類。"C1" 描述了能夠找到 x 屬性的內存地址(相對於對象指針)。本例中,"x" 存儲在位移 0 中,這意味着當之內存中連續的緩衝區來查看點對象的時候,位移起始處即和屬性 "x" 保持一致。V8 將會使用 "類轉換" 來更新 "C0","類轉換" 即表示屬性 "x" 是否被添加進點對象,隱藏類將會從 "C0" 轉爲 "C1"。如下的點對象的隱藏類如今是 "C1"。

每當對象添加新的屬性,使用轉換路徑來把舊的隱藏類更新爲新的隱藏類。隱藏類轉換是重要的,由於它們使得以一樣方式建立的對象能夠共享隱藏類。若是兩個對象共享一個隱藏類而且兩個對象添加了相同的屬性,轉換會保證兩個對象收到相同的新的隱藏類而且全部的優化過的代碼都會包含這些新的隱藏類。

當運行 "this.y = y" 語句的時候,會重複一樣的過程(仍是在 Point 函數中,在 "this.x = x" 語句以後)。

一個被稱爲 "C2" 的隱藏類被創造出來,一個類轉換被添加進 "C1" 中表示屬性 "y" 是否被添加進點對象(已經擁有屬性 "x")以後隱藏會更改成 "C2",而後點對象的隱藏類會更新爲 "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 引擎假設隱藏類沒有改變,而後使用以前查找到的位移來直接跳轉到指定屬性的內存地址。這極大地提高了代碼運行速度。

內存緩存也是爲何一樣類型的對象共享隱藏類是如此重要的緣由。當你建立了兩個一樣類型的對象而使用不一樣的隱藏類(正如以前的例子所作的那樣),V8 將不可能使用內存緩存,由於即便相同類型的兩個對象,他們對應的隱藏類爲他們的屬性分派不一樣的地址位移。

這兩個對象基本上是同樣的可是建立 "a" 和 "b" 的順序是不一樣的

編譯爲機器碼

一旦優化了 Hydrogen 圖表,Crankshaft 會把它降級爲低級的展示叫作 Lithium。大多數 Lithium 的實現都是依賴於指定的架構的。寄存器分配發生在這一層。

最後,Lithium 會被編譯爲機器碼。以後其它被稱爲 OSR 的事情發生了:堆棧替換。在開始編譯和優化一個明顯的耗時的方法以前,過去極有可能去運行它。V8 不會忘記代碼執行緩慢的地方,而再次使用優化過的版本代碼。相反,它會轉換全部的上下文(堆棧,寄存器),這樣就能夠在執行過程當中切換到優化的版本代碼。這是一個複雜的任務,你只須要記住的是,在其它優化過程當中,V8 會初始化內聯代碼。V8 並非惟一擁有這項能力的引擎。

這裏有被稱爲逆優化的安全防禦,以防止當引擎所假設的事情沒有發生的時候,能夠進行逆向轉換和把代碼反轉爲未優化的代碼。

垃圾回收

V8 使用傳統的標記-清除技術來清理老舊的內存以進行垃圾回收。標記階段會停止 JavaScript 的運行。爲了控制垃圾回收的成本而且使得代碼執行更加穩定,V8 使用增量標記法:不遍歷整個內存堆,試圖標記每一個可能的對象,它只是遍歷一部分堆,而後重啓正常的代碼執行。下一個垃圾回收點將會從上一個堆遍歷停止的地方開始執行。這會在正常的代碼執行過程當中有一個很是短暫的間隙。以前提到過,清除階段是由單獨的線程處理的。

Ignition 和 TurboFan

隨着 2017 早些時候 V8 5.9 版本的發佈,帶來了一個新的執行管道。新的管道得到了更大的性能提高和在現實 JavaScript 程序中,顯著地節省了內存。

新的執行管道是創建在新的 V8 解釋器 Ignition 和 V8 最新的優化編譯器 TurboFan 之上的。

你能夠查看 V8 小組的博文

自從 V8 5.9 版本發佈以來,full-codegen 和 Crankshaft(V8 從 2010 開始使用至今) 再也不被 V8 用來運行JavaScript,由於 V8 小組正努力跟上新的 JavaScript 語言功能以及爲這些功能所作的優化。

這意味着接下來整個 V8 將會更加精簡和更具可維護性。

網頁和 Node.js benchmarks 評分的提高

這些提高只是一個開始。新的 Ignition 和 TurboFan 管道爲將來的優化做鋪墊,它會在將來幾年內提高 JavaScript 性能和縮減 Chrome 和 Node.js 中的 V8 痕跡。

最後,這裏有一些如何寫出優化良好的,更好的 JavaScript 代碼。你能夠很容易地從以上的內容中總結出來,然而,爲了方便你,下面有份總結:

如何寫優化的 JavaScript 代碼

  • 對象屬性的順序:老是以相同的順序實例化你的對象屬性,這樣你的隱藏類及以後的優化代碼均可以被共享。
  • 動態屬性:實例化以後爲對象添加屬性會導致爲以前隱藏類優化的方法變慢。相反,在對象構造函數中賦值對象的全部屬性。
  • 方法:重複執行相同方法的代碼會比每次運行不一樣的方法的代碼更快(多虧了內聯緩存)。
  • 數列:避免使用鍵不是遞增數字的稀疏數列。稀疏數列中沒有包含每一個元素的數列稱爲一個哈希表。訪問該數列中的元素會更加耗時。一樣地,試着避免預先分配大型數組。最好是隨着你使用而遞增。最後,不要刪除數列中的元素。這會讓鍵稀疏。
  • 標記值:V8 用 32 位來表示對象和數字。它使用一位來辨別是對象(flag=1)或者是被稱爲 SMI(小整數) 的整數(flag=0),之因此是小整數是由於它是 31 位的。以後,若是一個數值比 31 位還要大,V8 將會裝箱數字,把它轉化爲浮點數而且建立一個新的對象來存儲這個數字。儘量試着使用 31 位有符號數字來避免建立 JS 對象的耗時裝箱操做。

本系列持續更新中,Github 地址請查閱這裏

相關文章
相關標籤/搜索