【譯】JavaScript的工做原理:V8引擎內部+關於如何編寫優化代碼的5個技巧

幾個星期前,咱們開始了深刻了解JavaScript及實際是如何運做的系列文章,咱們認爲經過了解JavaScript的構建模塊以及它們如何共同發揮做用,您將可以編寫更好的代碼和應用程序。前端

本系列的 第一篇文章重點介紹了引擎,運行時和調用堆棧的概述。第二篇文章將深刻探討谷歌V8 JavaScript引擎的內部部分。

概覽

JavaScript引擎是一個程序或執行JavaScript代碼的解釋器。JavaScript引擎能夠理解爲標準解釋器,或運行時編譯器,它以某種形式將JavaScript編譯爲字節碼。java

  • V8——由Google開發的開源軟件,用C ++編寫
  • Rhino——由Mozilla Foundation管理,開源,徹底用Java開發
  • SpiderMonkey ——第一個支持Netscape Navigator的JavaScript引擎,如今支持Firefox
  • JavaScriptCore——開源,以Nitro銷售,由Apple爲Safari開發
  • KJS - KDE的引擎,最初由Harri Porten爲KDE項目的Konqueror Web瀏覽器開發
  • Chakra (JScript9) —— Internet Explorer
  • Chakra (JavaScript) ——Microsoft Edge
    git

  • Nashorn——由甲骨文Java語言和工具組開源做爲OpenJDK的一部分
    github

  • JerryScript ——是物聯網的輕量級引擎web

V8爲何被創造出來?

V8引擎是由谷歌用C ++編寫構建的開源程序。它在Google Chrome中被使用。可是,與其餘引擎不一樣,V8也被用於流行的Node.js運行時。


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

V8曾經有兩個編譯器

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

  • full-codegen——一個簡單而快速的編譯器,能夠生成簡單但未被優化的機器代碼。
  • Crankshaft——一種更復雜的(即時)優化編譯器,可生成高度優化的代碼。

在V8引擎裏面也使用了多個線程:瀏覽器

  • 主線程:獲取代碼,編譯代碼而後執行它
  • 還有一個被用來編譯的單獨的線程,所以主線程能夠繼續執行,而它也同時能夠優化代碼
  • 一個分析線程,它將告訴運行時哪些方法耗費了大量的時間,以便 Crankshaft能夠優化它們
  • 一些線程做用是處理掃描垃圾收集器

當JavaScript代碼首次執行的時候,V8利用full-codegen直接將解析後的JavaScript轉換爲機器代碼而無需其餘中間過程的任何轉換。這使它能夠很是快速地開始執行機器代碼。請注意,V8不使用中間字節碼錶示,所以無需解釋器。緩存

當你的代碼運行了一段時間以後,這個分析線程已經收集了足夠多的數據來告訴應該優化哪一個方法。bash

接下來,Crankshaft優化從另外一個線程開始,它把JavaScript抽象語法樹轉化爲名爲Hydrogen的高級靜態單賦值(SSA)表示,並嘗試優化Hydrogen圖表,大多數優化都是在這個級別完成的。

內聯

第一個優化是提早嵌入儘量多的代碼。嵌入就是被調用函數替換調用方法(調用函數的代碼行)的過程。這個簡單的步驟讓後面的優化更有意義。


隱藏類

JavaScript是一門基於原型的語言,它沒有建立類,對象被建立是基於引用的,JavaScript也是一種動態編程語言,這意味着能夠在實例化後輕鬆地在對象中添加或刪除屬性。

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

因爲使用字典結構去查找屬性值在內存當中的位置是很是低效的,V8使用來一個不一樣的方法去替代:隱藏類。隱藏類的做用相似於在Java語言中的固定對象模版(Classes),除非它們是在運行時建立的。讓咱們看看它們其實是什麼樣的:

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」添加到Point對象,則隱藏類應從「C0」切換到「C1」。下面的Point對象的隱藏類如今是「C1」。


每次將新屬性添加到對象時,舊的隱藏類都會被更新到指向新隱藏類的轉換路徑。隱藏類轉換很是重要,由於它們容許在以相同方式建立的對象之間共享隱藏類(好比實例化兩個Point對象,他們的共同隱藏類是C0)。若是兩個對象共享一個隱藏類而且同一屬性被添加到它們中,則轉換將確保兩個對象都接收相同的新隱藏類(好比都添加「x」屬性,就會都指向C1)以及全部的優化代碼

當「this.y=y」被執行的時候,這個過程是重複進行的(Point函數裏面的「this.y=y」),若是屬性「y」被添加到Point上,類轉換將會基於「C1」生成「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優化動態類型語言的另外一種方法稱爲內聯緩存,內聯緩存依賴於觀察到對相同方法的重複調用每每發生在同一類型的對象上。能夠在此處找到對內聯緩存的深刻解釋。

咱們將討論一些內聯緩存的概念(若是您沒有時間查看上面的深刻解釋)。

那麼它是怎樣工做的?

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

那麼隱藏類和內聯緩存是如何相關的概念又是怎樣的呢?

每當在特定對象上調用方法時,V8引擎必須執行對該對象的隱藏類的查找,以肯定訪問特定屬性的偏移量。
在將同一方法成功調用兩次到同一個隱藏類以後,V8會省略了隱藏類的查找,只是將屬性的偏移量添加到對象指針自己。
對於該方法的全部未來的調用,V8引擎假定它的隱藏類未更改,並使用先前查找中存儲的偏移直接跳轉到特定屬性的內存地址。這大大提升了執行速度。

內聯緩存也是爲何相同類型的對象共享隱藏類很是重要的緣由。

若是你建立兩個相同類型和不一樣隱藏類的對象(正如咱們以前的例子中所作的那樣),V8將沒法使用內聯緩存,由於即便這兩個對象屬於同一類型,它們對應的隱藏類也會對其屬性分配不一樣的偏移量。


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

 

編譯到機器代碼

一旦Hydrogen圖表優化完成,Crankshaft將其下降到被稱爲Lithium的低級別表示。
大多數Lithium實現都是依賴於總體架構的。寄存器分配發生在這一層上。

最後,Lithium被編譯成機器代碼。而後發生了一些叫作OSR的事情:堆棧替換(OSR)。
當咱們開始編譯和優化一個明顯耗時的方法時,咱們極可能以前一直在運行它。V8不會將它以前執行的很慢的代碼拋在一邊,再從新執行優化後的代碼。相反,他會對這些慢代碼所擁有的所有上下文(堆棧,寄存器)作一個轉換,以便可以在執行這些慢代碼的過程當中直接切換到優化後的版本。
這是一項很是複雜的任務,請記住,在其餘優化中,V8最初已經內聯了代碼。V8並非惟一可以作到這一點的引擎。

有一種稱爲去優化的保護措施能夠進行相反的轉換,並在引擎做出的假設再也不適用的狀況下恢復到非優化代碼。

垃圾回收

對於垃圾回收,V8是使用了傳統的分代式標記清除垃圾回收機制來清除老一代,標記階段JavaScript會中止執行,爲了控制GC(垃圾回收)的成本和代碼執行的穩定,V8是用來增量標記:和遍歷整個堆、試圖標記每個可能的對象不一樣,它只是標記堆的一部分,而後恢復正常的執行,下一次GC將從上一次中止的地方繼續遍歷,在執行的時間段裏,它容許短暫的暫停,如前文所說,這個清除階段在單獨的線程中進行的。



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

這個新的管線是在V8的解釋器Ignition和V8最新的優化編譯器TurboFan之上構建的,

你能夠在此查看V8團隊有關該主題的博文

自從V8的5.9版本發佈以後,因爲V8團隊力爭和新的JavaScript語言特性以及針對這些新特性所須要的優化保持一致,full-codegen和Crankshaft(這兩項技術從2010年開始爲V8服務)再也不被V8用來運行JavaScript。

這意味着整個V8將擁有更簡單和更易維護的架構。


Web和Node.js基準上的改進

這些優化只是剛剛開始,新的Ignition和TurboFan管線爲將來的優化鋪平了道路,將來JavaScript的性能會有更加巨大的提高,並能讓V8在Chrome和Node.js中節約資源。

最後,這裏提供一些小技巧,幫助你們寫出更優化的、更優質的JavaScript。從上文中您必定能夠輕鬆地總結出一些技巧,不過爲了方便,仍然爲您提供一份總結。

怎麼寫出最佳JavaScript代碼

1.對象屬性的順序:永遠用相同的順序爲您的對象屬性實例化,這樣隱藏類和隨後的優化代碼才能共享。

2.動態屬性:在對象實例化後爲其新增屬性會致使隱藏類變化,從而會減慢爲舊隱藏類所優化的方法的執行。因此,儘可能在構造函數中分配對象的全部屬性。

3.方法:重複執行相同方法的代碼會比不一樣的方法只執行一次的代碼運行得更快(因爲內聯緩存的緣由)。

4.數組:避免使用keys不是遞增數字的稀疏數組(sparse arrays)。並不爲每一個元素分配內存的稀疏數組實質上是一個hash表。這種數組中的元素比一般數組的元素會花銷更大才能獲取到。此外,避免使用預申請的大型數組。最好隨着須要慢慢增長數組的大小。最後,不要刪除數組中的元素,因這會使得keys變得稀疏。

5.標記值:V8用32個比特來表示對象和數字。它使用1個比特來區分是一個對象(flag = 1)仍是一個整型(flag = 0)(被稱爲SMI或SMall Integer,小整型,因其只有31比特來表示值)。而後,若是一個數值大於31比特,V8就會給這個數字進行裝箱操做(boxing),將其變成double型,並建立一個新的對象將這個double型數字放入其中。因此,爲了不代價很高的boxing操做,儘可能使用31比特的有符號數。

後續文檔翻譯會陸續跟進!!

歡迎關注玄說前端公衆號,後續將推出系列文章《一個大型圖形化應用0到1的過程》,此帳戶也將同步更新

相關文章
相關標籤/搜索