1. 程式人生 > >解讀 JavaScript 之 V8 引擎及優化程式碼的 5 個技巧

解讀 JavaScript 之 V8 引擎及優化程式碼的 5 個技巧

為什麼建立 V8 引擎?

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


V8 最初是被設計用來提高網頁瀏覽器內部 JavaScript 執行的效能。為了獲得更快的速度,V8 將 JavaScript 程式碼翻譯成更高效的機器程式碼,而不是使用直譯器來翻譯程式碼。它通過使用 JIT(Just-In-Time)編譯器(如 SpiderMonkey 或 Rhino(Mozilla)等許多現代 JavaScript 引擎)來將 JavaScript 程式碼編譯為機器程式碼。 這裡的主要區別在於 V8 不生成位元組碼或任何中間程式碼。

V8 曾有兩個編譯器

在 V8 的 5.9 版本出來之前(今年早些時候釋出),引擎使用了兩個編譯器:

  • full-codegen - 一個簡單而且速度非常快的編譯器,可以生成簡單且相對較慢的機器程式碼。

  • Crankshaft  - 一種更復雜(Just-In-Time)的優化編譯器,生成高度優化的程式碼。

V8 引擎也在內部使用多個執行緒:

  • 主執行緒完成您期望的任務:獲取程式碼,編譯並執行它

  • 還有一個單獨的執行緒用於編譯,以便主執行緒可以繼續執行,而前者正在優化程式碼

  • 一個 Profiler 執行緒,它會告訴執行時我們花了很多時間,讓 Crankshaft 可以優化它們

  • 一些執行緒處理垃圾收集器

當第一次執行 JavaScript 程式碼時,V8 利用 full-codegen 編譯器,直接將解析的 JavaScript 翻譯成機器程式碼而不進行任何轉換。這使得它可以非常快速地開始執行機器程式碼。請注意,V8 不使用中間位元組碼,從而不需要直譯器。

當你的程式碼運行了一段時間,分析器執行緒已經收集了足夠的資料來判斷哪個方法應該被優化。

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

內聯程式碼

第一個優化是提前儘可能多地內聯程式碼。內聯是將被呼叫函式的主體替換為呼叫站點(呼叫函式的程式碼行)的過程。這個簡單的步驟使得下面的優化更有意義。


隱藏類

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

大多數 JavaScript 直譯器使用類似字典的結構(基於雜湊函式)來儲存物件屬性值在記憶體中的位置。這種結構使得在 JavaScript 中檢索一個屬性的值比在 Java 或 C# 這樣的非動態程式語言中的計算量要大得多。在 Java 中,所有的物件屬性都是在編譯之前由一個固定的物件決定的,並且不能在執行時動態新增或刪除(當然,C#的動態型別是另一個主題)。因此,屬性的值(或指向這些屬性的指標)可以作為連續的緩衝區儲存在記憶體中,每個值之間有一個固定的偏移量。偏移量的長度可以很容易地根據屬性型別來確定,而在執行時屬性型別可以改變的 JavaScript 中這是不可能的。

由於使用字典查詢記憶體中物件屬性的位置效率非常低,因此 V8 使用了不同的方法:隱藏類。隱藏類與 Java 等語言中使用的固定物件(類)的工作方式類似,除了隱藏類是在執行時建立的這點區別。現在,讓我們看看他們實際的例子:

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 將建立第二個隱藏的類,名為“C1”,它基於“C0”。 “C1”描述了可以找到屬性x的在記憶體中的位置(相對於物件指標)。在這種情況下,“x”被儲存在0處,這意味著當在記憶體中將點物件看作一段連續儲存空間時,第一個地址將對應於屬性“x”。 V8 也會用“class transition”來更新“C0”,如果一個屬性“x”被新增到一個點物件時,隱藏類應該從“C0”切換到“C1”。下面的點物件的隱藏類現在是“C1”。


每當一個新的屬性被新增到一個物件時,舊的隱藏類將被更新為到新的隱藏類的轉換路徑。隱藏的類轉換非常重要,因為它們允許隱藏的類在以相同方式建立的物件之間共享。如果兩個物件共享一個隱藏類,並將相同的屬性新增到這兩個物件,則轉換將確保兩個物件接收相同的新隱藏類和所有優化程式碼。

當語句 “this.y = y” 被執行時,會重複同樣的過程(在 “Point” 函式內部,“this.x = x”語句之後)。

一個名為“C2”的新隱藏類會被建立,如果將一個屬性 “y” 新增到一個 Point 物件(已經包含屬性“x”),一個類轉換會新增到“C1”,則隱藏類應該更改為“C2”,點物件的隱藏類更新為“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 :on-stack replacement(堆疊替換)。在我們開始編譯和優化一個明確的長期執行的方法之前,我們可能會執行堆疊替換。 V8 不只是緩慢執行堆疊替換,並再次開始優化。相反,它會轉換我們擁有的所有上下文(堆疊,暫存器),以便在執行過程中切換到優化版本上。這是一個非常複雜的任務,考慮到除了其他優化之外,V8 最初還將程式碼內聯。 V8 不是唯一能夠做到的引擎。

有一種叫做去優化的保護措施來做出相反的變換,並且在假設引擎優化無效的情況下,還原回非優化的程式碼。

垃圾收集

對於垃圾收集,V8 採用了傳統的分代式掃描方式來清理老一代。標記階段應該停止 JavaScript 的執行。為了控制 GC 成本並使執行更加穩定,V8 使用了漸進式標記:而不是走遍整個堆內容,試圖示記每一個可能的物件。它只走一部分堆內容,然後恢復正常執行。下一個 GC 將從先前堆走過的地方繼續執行。這允許在正常執行期間非常短的暫停。如前所述,掃描階段由不同的執行緒處理。

Ignition 和 TurboFan

隨著 2017 年早些時候 V8 5.9 的釋出,一個新的執行管道被引入。這個新的管道在實際的 JavaScript 應用程式中實現了更大的效能改進和顯著的記憶體節省。

新的執行流程是建立在 Ignition( V8 的直譯器)和 TurboFan( V8 的最新優化編譯器)之上的。

你可以檢視 V8 團隊關於這個話題的部落格文章。

自從 V8 5.9 版本問世以來,由於 V8 團隊一直努力跟上新的 JavaScript 語言特性以及這些特性所需要的優化,V8 團隊已經不再使用 full-codegen 和 Crankshaft(自 2010 年以來為 V8 技術所服務)。

這意味著 V8 整體上將有更簡單和更易維護的架構。


在 Web 和 Node.js 效能上的提升

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

最後,這裡有一些關於如何編寫優化的、更好的 JavaScript 的技巧。你可以很容易地從上面的內容中得到這些,不過,這裡有一個為你提供便利的總結:

如何編寫優化的 JavaScript

  1. 物件屬性的順序:始終以相同的順序例項化物件屬性,以便共享的隱藏類和隨後優化的程式碼可以共享之。

  2. 動態屬性:在例項化之後向物件新增屬性將強制執行隱藏的類更改,並降低之前隱藏類所優化的所有方法的執行速度。相反,在其建構函式中分配所有物件的屬性。

  3. 方法:重複執行相同方法的程式碼將比僅執行一次的多個不同方法(由於內聯快取)的程式碼執行得更快。

  4. 陣列:避免稀疏陣列,其中鍵值不是自增的數字。並沒有儲存所有元素的稀疏陣列是雜湊表。這種陣列中的元素訪問開銷較高。另外,儘量避免預分配大陣列。最好是按需增長。最後,不要刪除陣列中的元素。這會使鍵值變得稀疏。

  5. 標記值:V8 使用 32 位表示物件和數值。由於數值是 31 位的,它使用了一位來區分它是一個物件(flag = 1)還是一個稱為 SMI(SMall Integer)整數(flag = 0)。那麼,如果一個數值大於 31 位,V8會將該數字裝箱,把它變成一個雙精度數,並建立一個新的物件來存放該數字。儘可能使用 31 位有符號數字,以避免對 JS 物件的高開銷的裝箱操作。

我們在 SessionStack 中試圖編寫高度優化的 JavaScript 程式碼時遵循這些最佳實踐。 原因是,一旦將 SessionStack 整合到你的產品級的 Web 應用程式中,它就會開始記錄所有的東西:所有的 DOM 更改、使用者互動、JavaScript 異常、堆疊跟蹤、網路請求失敗、除錯訊息等。

通過 SessionStack ,你可以以視訊的方式重現問題,並檢視發生在使用者身上的所有事情。所有這些都必須在對你的網路應用程式的效能沒有任何影響的情況下進行的。