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

妙堂傳道者發表於2018-10-23

幾個星期前,我們開始了深入瞭解JavaScript及實際是如何運作的系列文章,我們認為通過了解JavaScript的構建模組以及它們如何共同發揮作用,您將能夠編寫更好的程式碼和應用程式。

本系列的第一篇文章重點介紹了引擎,執行時和呼叫堆疊的概述。第二篇文章將深入探討谷歌V8 JavaScript引擎的內部部分。

概覽

JavaScript引擎是一個程式或執行JavaScript程式碼的直譯器。JavaScript引擎可以理解為標準直譯器,或執行時編譯器,它以某種形式將JavaScript編譯為位元組碼。

  • 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

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

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

V8為什麼被創造出來?

V8引擎是由谷歌用C ++編寫構建的開源程式。它在Google Chrome中被使用。但是,與其他引擎不同,V8也被用於流行的Node.js執行時。

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

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不使用中間位元組碼錶示,因此無需直譯器。

當你的程式碼執行了一段時間之後,這個分析執行緒已經收集了足夠多的資料來告訴應該優化哪個方法。

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

內聯

第一個優化是提前嵌入儘可能多的程式碼。嵌入就是被呼叫函式替換呼叫方法(呼叫函式的程式碼行)的過程。這個簡單的步驟讓後面的優化更有意義。

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

隱藏類

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”的隱藏類。【譯】JavaScript的工作原理:V8引擎內部+關於如何編寫優化程式碼的5個技巧

尚未為Point定義任何屬性,因此“C0”為空。

一旦第一行程式碼this.x = x被執行(Point方法裡面),V8將建立第二個基於“C0”的隱藏類“C1”,“C1”描述了可以找到屬性x在記憶體中的位置(相對於物件指標),這種情況下,“x”被儲存在偏移0處,這意味著當將記憶體中的Point物件視為連續緩衝區時,第一偏移位置將對應於屬性“x”。V8還將使用“類轉換”更新“C0”,類轉換表示如果將屬性“x”新增到Point物件,則隱藏類應從“C0”切換到“C1”。下面的Point物件的隱藏類現在是“C1”。

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

每次將新屬性新增到物件時,舊的隱藏類都會被更新到指向新隱藏類的轉換路徑。隱藏類轉換非常重要,因為它們允許在以相同方式建立的物件之間共享隱藏類(比如例項化兩個Point物件,他們的共同隱藏類是C0)。如果兩個物件共享一個隱藏類並且同一屬性被新增到它們中,則轉換將確保兩個物件都接收相同的新隱藏類(比如都新增“x”屬性,就會都指向C1)以及所有的優化程式碼

當“this.y=y”被執行的時候,這個過程是重複進行的(Point函式裡面的“this.y=y”),如果屬性“y”被新增到Point上,類轉換將會基於“C1”生成“C2”隱藏類,point物件的隱藏類將會更新到“C2”。

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

隱藏類轉換是決於屬性新增到物件的順序,看下下面的程式碼:

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

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

這兩個物件基本相同,但“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將擁有更簡單和更易維護的架構。

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

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的過程》,此賬戶也將同步更新

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


相關文章