JavaScript工做機制:V8 引擎內部機制及如何編寫優化代碼的5個訣竅

概述

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

下面是實現了JavaScript引擎的一個熱門項目列表:git

  • V8 — 開源,由Google開發,用C++編寫的
  • Rhin o — 由Mozilla基金所管理,開源,徹底用Java開發
  • SpiderMonkey —第一個JavaScript引擎,最先用在Netscape Navigator上,如今用在Firefox上。
  • JavaScriptCore — 開源,以Nitro銷售,由蘋果公司爲Safari開發
  • KJS —KDE的引擎最初由Harri Porten開發,用於KDE項目的Konqueror瀏覽器
  • Chakra (JScript9) — Internet Explorer
  • Chakra (JavaScript) — Microsoft Edge
  • Nashorn — 開源爲OpenJDK的一部分,由Oracle的Java語言和工具組開發
  • JerryScript — 是用於物聯網的輕量級引擎

建立V8引擎的由來

Google構建的V8引擎是開源的,用C++編寫的。該引擎被用在Google Chrome中。不過,與其餘引擎不一樣的是,V8還被用做很受歡迎的Node.js的運行時。github

V8最初是設計用來提高Web瀏覽器中JavaScript執行的性能。爲了得到速度,V8將JavaScript代碼轉換爲更高效的機器碼,而不是使用解釋器。它經過實現像不少現代JavaScript引擎(好比SpiderMonkey或Rhino)所用的JIT(即時)編譯器,從而將JavaScript代碼編譯成機器碼。這裏主要區別在於V8不會產生字節碼或任何中間代碼。web

V8曾經有兩個編譯器

在V8 的5.9版(今年早些時候發佈)出現以前,V8引擎用了兩個編譯器:編程

  • full-codegen – 一個簡單而超快的編譯器,能夠生成簡單而相對較慢的機器碼。
  • Crankshaft – 一個更復雜(即時)的優化的編譯器,能夠生成高度優化的代碼。

V8引擎還在內部使用多個線程:數組

  • 主線程執行咱們想讓它乾的活:獲取代碼,編譯而後執行它
  • 還有一個單獨的線程用於編譯,這樣在主線程繼續執行的同時,單獨的線程能同時在優化代碼
  • 一個Profiler線程,用於讓運行時知道哪些方法花了大量時間,這樣Crankshaft就能夠對它們進行優化
  • 幾個線程用於處理垃圾收集器清掃

第一次執行JavaScript代碼時,V8會利用 full-codegen 直接將解析的JavaScript翻譯爲機器碼,而無需任何轉換。這就讓它能很是快地開始執行機器碼。請注意,因爲V8不會使用中間字節碼錶示,這樣就無需解釋器。瀏覽器

代碼運行了一段時間後,Profiler線程已經收集了足夠的數據來判斷應該優化哪一個方法。緩存

接下來, Crankshaft優化 從另外一個線程中開始。它將JavaScript抽象語法樹翻譯爲稱爲Hydrogen 的高級靜態單賦值(SSA)表示,並嘗試優化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 。 C1 描述了內存中的位置(相對於對象指針),屬性 x 在這個位置能夠找到。此時, x 存儲在偏移地址0處,就是說,當將內存中的 point 對象做爲連續緩衝器來查看時,第一個偏移地址就對應於屬性 x 。V8也會用「類轉換」來更新 C0 ,指出若是將一個屬性 x 添加到點對象,那麼隱藏類應該從 C0 切換到 C1 。下面的 point 對象的隱藏類如今是 C1 。

每當向對象添加一個新屬性時,舊的隱藏類就被用一個轉換路徑更新爲新的隱藏類。隱藏類轉換很重要,由於它們可讓隱藏類在以相同方式建立的對象之間共享。若是兩個對象共享一個隱藏類,而且將相同的屬性添加到這兩個對象中,那麼轉換會確保兩個對象都接收到相同的新隱藏類和它附帶的全部優化過的代碼。

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

這時,又建立一個名爲 C2 的新隱藏類,類轉換被添加到 C1 ,表示若是將屬性 y 添加到 Point 對象(已包含屬性 x ),那麼隱藏類應更改成 C2 ,同時 point 對象的隱藏類被更新爲 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利用另外一種稱爲內聯緩存(inline caching)的技術來優化動態類型語言。內聯緩存來自於觀察的結果:對同一方法的重複調用每每發生在同一類型的對象上。關於內聯緩存的深刻解釋能夠在 這裏 找到。

下面咱們打算談談內聯緩存的通常概念(若是您沒有時間閱讀上面的深刻解釋的話)。

那麼它是如何工做的呢?V8維護在最近的方法調用中做爲參數傳遞的對象類型的緩存,並使用該信息對未來做爲參數傳遞的對象類型作出假設。若是V8可以對傳遞給方法的對象類型作出一個很好的假設,那麼它能夠繞過算出如何訪問對象的屬性的過程,轉而使用先前查找對象的隱藏類時所存儲的信息。

那麼隱藏類和內聯緩存的概念是如何關聯的呢?不管什麼時候在特定對象上調用方法,V8引擎必須對該對象的隱藏類執行查找,以肯定訪問特定屬性的偏移地址。在對同一個隱藏類的同一方法進行了兩次成功的調用以後,V8就省掉了隱藏類查找,只將屬性的偏移地址添加到對象指針自己上。對於全部未來對該方法的調用,V8引擎都會假定隱藏類沒有改變,並使用先前查找中存儲的偏移地址直接跳轉到特定屬性的內存地址。這會大大提升執行速度。

內聯緩存也是爲何同一類型的對象共享隱藏類很是重要的緣由。若是您建立相同類型的兩個對象,可是用的是不一樣的隱藏類(如前面的示例),那麼V8將沒法使用內聯緩存,由於即便兩個對象的類型相同,可是它們的對應隱藏類也會爲其屬性分配不一樣的偏移地址。

兩個對象基本相同,可是「a」和「b」屬性是按照不一樣的順序建立的。

編譯到機器碼

一旦Hydrogen圖被優化,Crankshaft將其下降到一個稱爲Lithium的較低級別表示。大多數Lithium實現都是針對架構的。寄存器分配發生在這一級。

最後,Lithium被編譯成機器碼。而後其餘事情,也就是OSR(當前棧替換,on-stack replacement),發生了。在咱們開始編譯和優化一個明顯要長期運行的方法以前,咱們可能會運行它。V8不會蠢到忘記它剛剛慢慢執行的代碼,因此它不會再用優化版本又執行一遍,而是將轉換全部已有的上下文(棧、寄存器),以便咱們能夠在執行過程當中間就切換到優化版本。這是一個很是複雜的任務,請記住,除了其餘優化以外,V8最開始時已經內聯了代碼。V8並不是惟一可以作到這一點的引擎。

有一種稱爲去優化的保護措施,會做出相反的轉換,並恢復爲非優化代碼,以防引擎的假設再也不成立。

垃圾回收

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

Ignition 和 TurboFan

隨着2017年早些時候版本5.9的發佈,V8引入了一個新的執行管道。這個新的管道在真實的JavaScript應用程序中實現了更大的性能提高和顯著的內存節省。

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

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

自從5.9版本發佈以來,V8再也不用full-codeget 和 Crankshaft(自2010年以來V8所用的技術)執行JavaScript,由於V8團隊一直在努力跟上新的JavaScript語言特性,而這些特性須要優化。

這意味着V8總體下一步會有更簡單和更易維護的架構。

在Web和Node.js基準測試上的提高

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

最後,這裏有一些關於如何編寫良好優化、更佳的JavaScript的訣竅。固然,從上面的內容不可貴到這些訣竅,不過,爲了方便起見,這裏仍是給出一個摘要:

如何編寫優化的JavaScript

  1. 對象屬性的順序 :始終以相同的順序實例化對象屬性,以即可以共享隱藏類和隨後優化的代碼。
  2. 動態屬性 :在實例化後向對象添加屬性會強制修改隱藏類,減慢爲以前的隱藏類優化了的方法。因此應該在構造函數中指定對象的全部屬性。
  3. 方法 :重複執行相同方法的代碼將比只執行一次的代碼(因爲內聯緩存)運行得快。
  4. 數組 :避免鍵不是增量數字的稀疏數組。元素不全的稀疏數組是一個 哈希表, 而訪問這種數組中的元素更昂貴。另外,儘可能避免預分配大數組。最好隨着發展而增加。最後,不要刪除數組中的元素。它會讓鍵變得稀疏。
  5. 標記值 :V8用32位表示對象和數字。它用一位來判斷是對象(flag = 1)仍是整數(flag=0)(這個整數稱爲SMI(SMall Integer,小整數),由於它是31位)。而後,若是一個數值大於31位,V8將會對數字裝箱,將其轉化爲 double,並建立一個新對象將該數字放在裏面。因此要儘量使用31位有符號數字,從而避免昂貴的轉換爲JS對象的裝箱操做。

咱們在SessionStack中試圖在編寫高度優化的JavaScript代碼中遵循這些最佳實踐。緣由是一旦將SessionStack集成到產品web應用程序中,它就開始記錄全部內容:全部DOM更改、用戶交互、JavaScript異常、棧跟蹤、失敗的網絡請求和調試消息。用SessionStack,您能夠將Web應用中的問題重放爲視頻,並查看用戶發生的一切。而全部這些都是在對您的web應用程序的性能不會產生影響的狀況下發生的。

相關文章
相關標籤/搜索