V8引擎簡介

huanliu發表於2018-12-09

上一篇(JS引擎、執行時與呼叫棧概述)主要講了JS引擎、執行時與呼叫棧的概述。本篇文章將會深入到谷歌V8 JavaScript引擎的核心部分。我們也會提供一些怎樣寫出更好的JavaScript程式碼的建議。

概覽

一個JavaScript引擎是執行JavaScript程式碼的一個程式或者說一個直譯器。一個JavaScript引擎可以實現成一個標準的直譯器,或者以某種形式將JavaScript編譯成位元組碼的即時編譯器。

以下是一個比較流行的實現JavaScript引擎的專案列表:

  • V8--開源,由谷歌開發,用C++編寫
  • Rhino--由Mozilla Foundation管理,開源,完全用Java開發
  • SpiderMonkey--第一個JavaScript引擎,以前用於Netscape瀏覽器,現在用於FireFox瀏覽器
  • JavaScriptCore--開源,作為Nitro銷售,由Apple為Safari開發
  • KJS--KDE的引擎,最初由Harri Porten為KDE專案的Konqueror web瀏覽器開發
  • Chakra(JScript9)--Internet Explorer
  • Chakra(JavaScript)--Microsoft Edge
  • Nashorn--作為OpenJDK的一部分開源,由Oracle Java Languages and Tool Group 編寫
  • JerryScript--一個物聯網輕量引擎

為什麼要建立V8引擎?

由谷歌構建的V8引擎是用C++編寫的開源專案,用於谷歌Chrome內部。然而不像其他引擎,V8也被用於流行的Node.js執行時。

V8引擎簡介

V8最開始是為了提高執行在瀏覽器內部的JavaScript執行效能而設計的。為了提高速度,V8將JavaScript程式碼轉換成更有效率的機器碼,而不是使用一個直譯器。就像其他一些JavaScript引擎比如SpiderMonkey或Rhino (Mozilla)所做的一樣,V8實現了一個即時(JIT)編譯器在程式碼執行時將JavaScript程式碼編譯成機器碼。這裡最主要的區別是V8不生成位元組碼或其他中間程式碼。

V8以前有兩種編譯器

在V8 5.9版本出來之前,引擎使用了兩種編譯器:

  • full-codegen--一個生成簡單和相對較慢機器碼的簡單,速度很快的編譯器
  • Crankshaft--一個生成高度優化程式碼的更復雜的(JIT)優化編譯器

V8引擎內部也使用了幾個執行緒:

  • 主執行緒所做的事情就是你所期待的那樣:獲取你的程式碼,編譯它然後執行它
  • 也存在另一個執行緒用來編譯,那樣當前面正在優化程式碼的時候,主執行緒可以繼續執行
  • 效能分析執行緒告訴執行時哪些方法消耗了很多時間,那樣Crankshaft就能去優化它們了
  • 一些處理垃圾回收的執行緒

最初執行JavaScript程式碼的時候,V8使用full-codegen直接將JavaScript轉換成機器碼而沒做任何轉化,這讓引擎能很快開始執行程式碼。值得注意的是V8不使用中間位元組碼,那就不需要一個直譯器了。

當你的程式碼執行一段時間後,效能分析執行緒收集到了足夠的資料來告知哪些方法需要優化。

接下來,Crankshaft開始在另一個執行緒優化了。它將JavaScript抽象語法樹轉換成一個叫做Hydrogen的高階靜態單賦值形式並試圖優化這個Hydrogen圖,大多數優化都是在這個階段進行的。

內聯

第一步優化是提前內聯儘可能多的程式碼啊。內聯是將呼叫地址(函式被呼叫的程式碼行)替換成被呼叫的函式體。這一簡單的步驟是接下來的優化更有意義。

V8引擎簡介

隱藏的類(Hidden class)

JavaScript是一門基於原型的語言:沒有類,物件是使用克隆來構造的。JavaScript是一門動態程式語言,意味著屬性可以在物件初始化完成後很容易的被新增或已移除。

大多數JavaScript直譯器使用類字典結構(基於雜湊函式)在記憶體中儲存物件屬性值的位置。這個結構使得在JavaScript中檢索一個屬性的值比在其他非靜態語言如Java或C#需要更多的計算。在Java中,所有的物件屬性在編譯前就已經被一個固定的物件決定了,而且不能在執行時被動態新增或刪除(好吧,C#有一個動態型別,而這是另外一個話題了)。結果是,屬性值(或者指向那些屬性的指標)可以儲存在記憶體中一個連續的緩衝區中,彼此之間偏移量是固定的。這個偏移的長度根據屬性的型別可以很容易的確定,然而在屬性型別在執行時可以改變的JavaScript中是不可能的。

因為使用字典在記憶體中查詢物件屬性的位置是很低效的,V8使用一個不同的方法來代替:隱藏類(hidden classes)。隱藏類很像在Java中使用的固定物件層(類),除了它們是在執行時建立的。現在讓我們來看一下它們是什麼樣的:

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

只要“new Point(1,2)”被執行,V8就會建立一個隱藏類叫做“C0”。

V8引擎簡介

Point中還沒有屬性,所以“C0”是空的。

只要第一個表示式“this.x = x”被執行了(在“Point”函式內部),V8就會建立第二個叫做“C1”的基於“C0”的隱藏類。“C1”描述屬性x在記憶體中的位置(關聯物件指標)。在這裡,“x”儲存在偏移量為0的位置上,這意味著當把point物件在記憶體中看作連續的緩衝區,第一個偏移量指向的是屬性“x”。V8也會用“類轉換”來更新“C0”,表明如果屬性“x”被加入到point物件中,隱藏類就會從 “C0”切換成“C1”。下圖中point物件的隱藏類現在已經是“C1”了。

V8引擎簡介

每次有新的屬性被加入到一個物件中,舊的隱藏類就會更新轉換路徑到新的隱藏類。隱藏類轉換是很重要的,因為它們可以讓隱藏類在以相同方式建立的物件中共享。如果兩個物件共享一個隱藏類,而且相同的屬性被加入到它們中,轉換就能保證兩個物件獲得相同的新隱藏類和隨之攜帶的所有優化過的程式碼。

當表示式“this.y = y”被執行的時候(也在Point函式中,“this.x = x”表示式之後),會重複上述過程。

一個叫做“C2”的隱藏類被建立了,一個型別轉換被加入到“C1”中,表明如果有屬性y加入到Point物件中(已經包含屬性“x”),那麼隱藏類將會變為“C2”,point物件的隱藏類更新為“C2”。

V8引擎簡介

隱藏類轉換依賴於屬性加入到物件中的順訊,看一下如下程式碼片段:

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將不會使用內聯快取,因為即使兩個物件型別相同,但它們對應的隱藏類給它們的屬性分配了不同的偏移量。

V8引擎簡介

兩個物件基本相同,但“a”和“b”屬性是以不同的順序建立的。

編譯成機器碼

只要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在未來將會擁有簡單的多和可維護度更高的體系結構。

V8引擎簡介
在Web和node.js基準線上的改善

這些改善只是開始。新的Ignition和TurboFan管道為將來的優化鋪好了道路,這樣會大幅度提高JavaScript的效能,使V8在接下來的許多年在Chrome和Node.js上踩下更堅實的足跡。

最後,這裡有一些怎樣寫出良好優化的更好的JavaScript的一些建議和技巧。你可以很容易根據上面的內容得出這些結論,這裡只是為了你的方便,總結一下:

怎樣寫出效能優化的程式碼

  1. 物件屬性的順序:總是以相同的順序例項化物件屬性,那樣隱藏類和接下來的優化程式碼能夠被共享。
  2. 動態屬性:在例項化後新增屬性到一個物件中將會強制改變隱藏類,拖慢之前為隱藏類優化過的任何方法。取而代之,在建構函式中為物件所有的屬性賦值。
  3. 方法:重複執行相同方法的程式碼會比每次執行不同的方法快一些(因為內聯快取)
  4. 陣列:避免使用key值不是遞增數字的稀疏陣列。不是每個元素都在內部的稀疏陣列是一個雜湊表。這種陣列的元素需要消耗更多資源才能訪問到。也要避擴音前分配大陣列,最好是用到才分配。最後,不要刪除陣列的元素,這樣會讓key變得稀疏。
  5. 標籤值:V8用32位來表示物件和數字。它用一位來表示是一個物件(flag = 1)或一個數字(flag = 0),被稱作是SMI(SMall Integer),因為它的31位。然後,如果一個數字值比31為要大,V8將會對number進行裝箱,將它轉換成一個double型別,建立一個新物件來把它放進去。在任何時候試著使用31位有符號數字來避免很昂貴的進入一個JS物件的裝箱操作。

本文翻譯自:blog.sessionstack.com/how-javascr…

相關文章