譯—JavaScript是如何工作的(2):V8引擎內部+優化程式碼的5個技巧

ziling_dzl發表於2019-03-25

幾周之前,我們開始了一系列旨在深入挖掘JavaScript及其實際工作原理的文章:我們認為通過了解JavaScript的構建塊以及它們如何共同發揮作用,你將能夠編寫更好的程式碼和應用程​​序。                                                                                                                                      

第一篇文章集中於提供引擎,執行時和呼叫堆疊的概述。第二篇文章將深入探討谷歌V8 JavaScript引擎的內部。我們還將提供一些關於如何編寫更好的JavaScript程式碼的小建議—最佳實踐就是我們的團隊構建的SessionStack,在開發時我們就遵循了這些tips。

概述

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

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

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

V8引擎是如何產生的?

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

譯—JavaScript是如何工作的(2):V8引擎內部+優化程式碼的5個技巧

V8最初是被設計來提高JavaScript在web瀏覽器中執行的效能的。為了提高速度,V8將JavaScript程式碼轉換為更高效的機器程式碼,而不是使用直譯器。它通過實現JIT(Just-in-Time) 編譯器在執行時將JavaScript程式碼編譯成機器程式碼來實現的,就像大多數現代JavaScript引擎所做的那樣,比如SpiderMonkey or Rhino (Mozilla)。主要區別是V8不產生位元組碼或任何中間程式碼。

V8曾經有兩個編譯器

  • full-codegen— 一個簡單而快速的編譯器,可以生成簡單且相對較慢的機器程式碼。
  • Crankshaft - 一種更復雜的(即時)優化編譯器,可生成高度優化的程式碼。
V8引擎內部還使用了幾個執行緒:
  • 主執行緒完成你的期望:獲取程式碼,編譯程式碼然後執行它
  • 還有一個單獨的執行緒用於編譯,以保證主執行緒可以在前者(應該指的是Crankshaft )優化程式碼的同時能夠繼續執行。
  • 一個Profiler執行緒,它將告訴執行時哪一個方法花費了大量時間,以便Crankshaft可以優化它們
  • 一些執行緒來處理垃圾收集器的掃描。
當第一次執行JavaScript程式碼的時候,V8利用全程式碼生成器,直接將解析後的JavaScript翻譯為機器程式碼而不做其他任何的轉換。這使它可以非常快速地開始執行機器程式碼。請注意,V8不使用中間位元組碼的這種方式使其不需要直譯器這種東西。


當程式碼執行一段時間後,探查執行緒(profiler thread)已經收集了足夠的資料來告訴(Crankshaft )應該優化哪個方法。

接下來, Crankshaft 優化 開啟了另一個執行緒。它將JavaScript抽象語法樹轉換為名為Hydrogen的高階靜態單指派(SSA)表現(a high-level static single-assignment (SSA) representation),並嘗試優化氫圖(Hydrogen graph)。大多數優化都是在這個級別完成的。

內聯(Inlining)

第一次優化是儘可能提前內聯更多的程式碼。內聯就是一個用函式體替換函式呼叫點(函式被呼叫的程式碼行)的過程。這個簡單的步驟使接下來的優化更有意義。

             譯—JavaScript是如何工作的(2):V8引擎內部+優化程式碼的5個技巧

Hidden class(隱藏類)

JavaScript是一門基於原型的語言:沒有類和物件是使用克隆過程建立的。JavaScript也是一種動態程式語言,這意味著在例項化後可以輕鬆地在物件中新增或刪除屬性。

大多數JavaScript直譯器使用類似字典的結構(基於hash function)來儲存物件屬性值在記憶體中的位置。這種結構使得在JavaScript中檢索屬性的值比在Java或C#等非動態程式語言中的計算成本更高。在Java裡,所有物件屬性都是在編譯之前由固定物件佈局確定的,並且無法在執行時動態新增或刪除(當然,C#也有動態型別,這又是另一個話題了。)結果是,屬性值(或指向這些屬性的指標)可以以兩兩之間有一個固定的偏移量(fixed-offset )作為連續緩衝區儲存在記憶體中。這些偏移(offset )的長度可以根據屬性型別輕鬆確定,而這在執行時可以更改屬性型別的JavaScript中是不可能的。

由於使用字典來查詢記憶體中物件屬性的位置是非常低效的,V8使用了一個不同的方法來代替:hidden classes(隱藏類)。隱藏類的工作方式類似於Java等語言中使用的固定物件佈局(類),除非它們是在執行時建立的。現在,讓我們看看它們實際上是什麼樣的:

function(x,y){
    this.x=x;
    this.y=y
}

var p1 = new Point(1,2)複製程式碼


一旦“new Point(1,2)”呼叫發生,V8將建立一個名為“C0”的隱藏類。


譯—JavaScript是如何工作的(2):V8引擎內部+優化程式碼的5個技巧

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

一旦執行了第一個語句“this.x = x”(在“Point”函式內),V8將建立一個名為“C1”的第二個隱藏類,它基於“C0”。“C1”描述了可以找到屬性x的儲存器中的位置(相對於物件指標)。在這種情況下,“x”儲存在偏移0處,這意味著當在儲存中檢視作為連續緩衝區儲存的Point 物件時,第一個偏移將對應於屬性“x”。(這句我覺得翻譯可能有不準的地方,原句是:In this case, “x” is stored at offset 0, which means that when viewing a point object in the memory as a continuous buffer, the first offset will correspond to property “x”.)

V8也會用“類轉換”(“class transition” )更新“C0”,該類轉換指出如果將屬性“x”新增到Point 物件,則隱藏類應該從“C0”切換到“C1”。下面的Point物件的隱藏類現在是“C1”。

譯—JavaScript是如何工作的(2):V8引擎內部+優化程式碼的5個技巧

每次將新屬性新增到物件時,舊的隱藏類都會更新為新隱藏類的轉換路徑。隱藏類轉換很重要,因為它們允許在以相同方式建立的物件之間共享隱藏類。如果兩個物件共享一個隱藏類並且同一屬性被新增到它們中,則轉換要確保兩個物件都接收相同的新隱藏類以及隨其附帶的所有優化程式碼。

執行語句“this.y = y”(在Point函式內,在“this.x = x”語句之後)時重複此過程。

一個新的隱藏類“C2”被建立了,一個新的宣告瞭如果將屬性“y”新增到Point物件(已包含屬性“x”),則隱藏的類應更改為“C2”的類轉換被新增給C1,並且Point物件的隱藏類更新為“C2”。

譯—JavaScript是如何工作的(2):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”以不同的隱藏類結束。在這些情況下,以相同的順序初始化動態屬性要好得多,以便可以重用隱藏的類。

內聯快取(Inline caching)

V8利用另一種技術優化動態型別語言,稱為內聯快取。內聯快取依賴於觀察到對相同型別的物件的重複呼叫傾向於發生在相同型別的物件上。

可以在這裡找到有關內聯快取的深入說明。

我們將討論內聯快取的一些基礎概念(如果你沒有時間瀏覽上面的深入解釋的文章)。

那麼它是怎麼工作的呢?V8維持一個在最近的方法呼叫中作為引數傳遞的物件型別的快取,並使用此資訊來假設將來作為引數傳遞的物件型別。如果V8能夠對將傳遞給方法的物件型別做出很好的假設,它可以繞過弄清楚如何訪問物件屬性的過程,而是使用先前查詢中儲存的資訊到物件的隱藏類。

那麼隱藏類和內聯快取的概念之間有什麼關係呢?每當在特定物件上呼叫方法時,V8引擎必須執行對該物件的隱藏類的查詢,以確定訪問特定屬性的偏移量。在將同一方法兩次成功呼叫到同一個隱藏類之後,V8省略了隱藏類查詢,只是簡單地將屬性的偏移量新增到物件指標本身。對於該方法接下來的呼叫,V8引擎假定隱藏類沒有改變,然後使用先前查詢中儲存的偏移量直接跳轉到特定屬性的記憶體地址。這大大提高了執行速度。

內聯快取也是為什麼相同型別的物件共享隱藏類非常重要的原因。如果你建立兩個相同型別且具有不同隱藏類的物件(如前面示例中所做的那樣),V8無法使用內聯快取,因為即使兩個物件屬於同一型別,其對應的隱藏類也會為其屬性分配不同的偏移量。

譯—JavaScript是如何工作的(2):V8引擎內部+優化程式碼的5個技巧

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

編譯到機器程式碼

氫圖(Hydrogen graph)優化後,Crankshaft將其降低到稱為鋰(Lithium)的低階別表示。大多數Lithium實現都是特定於某種結構的。暫存器的分配(Register allocation)就發生在此級別。

在最後,Lithium被編譯成機器程式碼。然後發生了一些其他的叫做OSR的事情:堆疊替換。在我們開始編譯和優化一個明顯長期執行的方法之前,我們可能正在執行它。V8不會忘記它只是慢慢執行以便再次使用優化版本。相反,它將轉換我們擁有的所有上下文(堆疊,暫存器),以便我們可以在執行過程中切換到優化版本。這是一項非常複雜的任務,請記住,除了其他優化之外,V8最初還內聯了程式碼。 V8並不是唯一能夠做到這一點的引擎。

有一種稱為去優化的保護措施可以進行相反的轉換,並在引擎的假設不再適用的情況下恢復到非優化程式碼。

垃圾收集(Garbage collection)

在垃圾處理這一塊,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總體來說將會擁有更簡單,更易維護的架構。

譯—JavaScript是如何工作的(2):V8引擎內部+優化程式碼的5個技巧

                                           Web和Node.js基準測試的改進

這些改進只是一個開始。新的Ignition和TurboFan管道為進一步優化鋪平了道路,這些優化將在未來幾年內提升JavaScript效能並縮小V8在Chrome和Node.js中的佔用空間。

最後,這裡有一些關於如何編寫優化良好的JavaScript的提示和技巧。你當然可以從上面的內容輕鬆地推匯出這些內容,但是,這裡有一個方便的總結:

How to write optimized JavaScript

  1. 物件屬性的順序:始終以相同的順序例項化您的物件屬性,以便可以共享隱藏的類和隨後優化的程式碼。
  2. 動態屬性:在例項化之後向物件新增屬性會強制隱藏類改變並減緩為先前隱藏類優化的任何方法。因此,在其建構函式中分配所有物件的屬性。
  3. 方法:重複執行相同方法的程式碼將比僅執行一次不同方法的程式碼執行得更快(由於內聯快取)。
  4. 陣列:避免鍵不是增量數的稀疏陣列。其中沒有每個元素的稀疏陣列是一個雜湊表(hash table)。這種陣列中的元素訪問起來更加昂貴(費時、麻煩)。另外,儘量避免預先分配的大陣列。隨著需要增長其長度更好。最後,不要刪除陣列中的元素。這會使鍵變得稀疏。
  5. 標記值Tagged values):V8的物件和數字都用32位來表示。它使用一個位來知道它是一個物件(flag = 1)還是一個稱為SMI(SMall Integer)的31位的整數(flag = 0)。然後,如果數值大於31位,V8會將數字打包,將其變為雙精度並建立一個新物件以將數字放入其中。嘗試儘可能使用31位帶符號的數字,以避免對JS物件進行昂貴的裝箱操作。
我們在SessionStack中嘗試遵循這些做法以寫出高度優化的JavaScript程式碼。原因是,一旦你將SessionStack整合到你的Web應用程式中,它將開始記錄一切:所有DOM更改,使用者互動,JavaScript異常,堆疊跟蹤,失敗的網路請求和除錯訊息。

使用SessionStack,你可以將網路應用中的問題作為視訊重播,並檢視使用者發生的所有事情。並且所有的這一切都不會影響你的web應用效能。

這裡可以免費試用。get started for free.

譯—JavaScript是如何工作的(2):V8引擎內部+優化程式碼的5個技巧

Resources

  • https://docs.google.com/document/u/1/d/1hOaE7vbwdLLXWj3C8hTnnkpE0qSa2P--dtDvwXXEeD0/pub
  • https://github.com/thlorenz/v8-perf
  • http://code.google.com/p/v8/wiki/UsingGit
  • http://mrale.ph/v8/resources.html
  • https://www.youtube.com/watch?v=UJPdhx5zTaw
  • https://www.youtube.com/watch?v=hWhMKalEicY



相關文章