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

發表於2017-08-27

幾周前,我們開始寫旨在深入挖掘JavaScript及其工作機制的一系列文章:我們認為,通過了解JavaScript的構造單元以及這些構造單元如何組織在一起,您就能夠編寫更好的程式碼和應用程式。

該系列的第一篇文章重點是提供一個對引擎、執行時和呼叫棧的概述。這第二篇文章將會深入Google V8 JavaScript引擎的內部。我們還將提供如何編寫更佳 JavaScript 程式碼的一些小技巧 – 這也是我們 SessionStack 開發團隊在構建產品時遵循的最佳實踐。

概述

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

下面是實現了JavaScript引擎的一個熱門專案列表:

  • V8 — 開源,由Google開發,用C++編寫的
  • Rhino — 由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的執行時。

V8最初是設計用來提升Web瀏覽器中JavaScript執行的效能。為了獲得速度,V8將JavaScript程式碼轉換為更高效的機器碼,而不是使用直譯器。它通過實現像很多現代JavaScript引擎(比如SpiderMonkey或Rhino)所用的JIT(即時)編譯器,從而將JavaScript程式碼編譯成機器碼。這裡主要區別在於V8不會產生位元組碼或任何中間程式碼。

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這樣的語言中使用的固定物件佈局(類),只不過隱藏類是在執行時建立的。下面,我們來看看它們到底是什麼樣子:

一旦new Point(1, 2)呼叫發生了,V8就會建立一個稱為C0的隱藏類。

因為還沒有給Point定義屬性,所以C0為空。

一旦執行了第一條語句this.x = x(在Point函式中),V8就會建立一個基於C0的第二個隱藏類C1C1描述了記憶體中的位置(相對於物件指標),屬性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

隱藏類轉換取決於將屬性新增到物件的順序。看下面的程式碼片段:

現在,你可能會認為p1p2會使用相同的隱藏類和轉換。嗯,這是錯的。對於p1,首先是新增屬性a,然後是屬性b。不過,對於p2,先是給b賦值,然後才是a。因此,由於轉換路徑不同,p1p2最終會有不同的隱藏類。在這種情況下,以相同的順序初始化動態屬性要更好,這樣隱藏類才可以被重用。

內聯快取

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應用程式的效能不會產生影響的情況下發生的。

資源

相關文章