[譯] JavaScript 如何工作:在 V8 引擎裡 5 個優化程式碼的技巧

小烜同學發表於2017-11-19

JavaScript 如何工作:在 V8 引擎裡 5 個優化程式碼的技巧

  幾個星期前我們開始了一個旨在深入挖掘 JavaScript 以及它是如何工作的系列文章。我們通過了解它的底層構建以及它是怎麼發揮作用的,可以幫助我們寫出更好的程式碼與應用。

  第一篇文章 主要關注引擎、執行時以及呼叫棧的概述。第二篇文章將會深入到 Google 的 JavaScript V8 引擎的內部。 我們還提供了一些關於如何編寫更好的 JavaScript 程式碼的快速技巧 —— 我們 SessionStack 開發團隊在開發產品的時候遵循的最佳實踐。

概述

  JavaScript 引擎 是執行 JavaScript 程式碼的程式或者說是直譯器。JavaScript 引擎能夠被實現成標準直譯器或者是能夠將 JavaScript 以某種方式編譯為位元組碼的即時編譯器。

  下面是一些比較火的實現 JavaScript 引擎的專案:

  • V8 — 由 Google 開發,使用 C++ 編寫的開源引擎
  • Rhino — 由 Mozilla 基金會管理,完全使用 Java 開發的開源引擎
  • SpiderMonkey — 第一個 JavaScript 引擎,在當時支援了 Netscape Navigator,現在是 Firefox 的引擎
  • JavaScriptCore — 由蘋果公司為 Safari 瀏覽器開發,並以 Nitro 的名字推廣的開源引擎。
  • KJS — KDE 的引擎,最初是由 Harri Porten 為 KDE 專案的 Konqueror 網路瀏覽器開發
  • Chakra (JScript9) — IE 引擎
  • Chakra (JavaScript) — 微軟 Edge 的引擎
  • Nashorn — 開源引擎,由 Oracle 的 Java 語言工具組開發,是 OpenJDK 的一部分
  • JerryScript — 這是物聯網的一個輕量級引擎

為什麼要建立 V8 引擎?

  V8 引擎是由 Google 用 C++ 開發的開源引擎,這個引擎也在 Google chrome 中使用。和其他的引擎不同的是,V8 引擎也用於執行 Node.js。

[譯] JavaScript 如何工作:在 V8 引擎裡 5 個優化程式碼的技巧

  V8 最初被設計出來是為了提高瀏覽器內部 JavaScript 的執行效能。為了獲取更快的速度,V8 將 JavaScript 程式碼編譯成了更加高效的機器碼,而不是使用直譯器。它就像 SpiderMonkey 或者 Rhino (Mozilla) 等許多現代JavaScript 引擎一樣,通過運用即時編譯器將 JavaScript 程式碼編譯為機器碼。而這之中最主要的區別就是 V8 不生成位元組碼或者任何中間程式碼。

V8 曾經有兩個編譯器

  在 V8 的 v5.9 版本出來之前(今年早些時候釋出的)有兩個編譯器:

  • full-codegen — 一個簡單並且速度非常快的編譯器,可以生成簡單但相對比較慢的機器碼。
  • Crankshaft — 一個更加複雜的 (即時) 優化編譯器,生成高度優化的程式碼。

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

  • 主執行緒完成你所期望的任務:獲取你的程式碼,然後編譯執行
  • 還有一個單獨的執行緒用於編譯,以便主執行緒可以繼續執行,而前者就能夠優化程式碼
  • 一個 Profiler (分析器) 執行緒,它會告訴執行時在哪些方法上我們花了很多的時間,以便 Crankshaft 可以去優化它們
  • 還有一些執行緒處理垃圾回收掃描

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

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

  然後, Crankshaft 在另一個執行緒開始優化。它將 JavaScript 抽象語法樹轉換成一個叫 Hydrogen 的高階靜態單元分配表示(SSA),並且嘗試去優化這個 Hydrogen 圖。大多數優化都是在這個級完成。

程式碼嵌入 (Inlining)

  首次優化就是儘可能的提前嵌入更多的程式碼。程式碼嵌入就是將使用函式的地方(呼叫函式的那一行)替換成呼叫函式的本體。這簡單的一步就會使接下來的優化更加有用。

[譯] JavaScript 如何工作:在 V8 引擎裡 5 個優化程式碼的技巧

隱藏類 (Hidden class)

  JavaScript 是一門基於原型的語言: 沒有類和物件是通過克隆來建立的。同時 JavaScript 也是一門動態語言,這意味著在例項化之後也能夠方便的從物件中新增或者刪除屬性。

  大多數 JavaScript 直譯器使用類似字典的結構 (基於雜湊函式) 去儲存物件屬性值在記憶體中的位置。這種結構使得在 JavaScript 中檢索一個屬性值比在像 Java 或者 C# 這種非動態語言中計算量大得多。在 Java 中, 編譯之前所有的屬性值以一種固定的物件佈局確定下來了,並且在執行時不能動態的增加或者刪除 (當然,C# 也有 動態型別,但這是另外一個話題了)。因此,屬性值 (或者說指向這些屬性的指標) 能夠以連續的 buffer 儲存在記憶體中,並且每個值之間有一個固定的偏移量。根據屬性型別可以很容易地確定偏移量的長度,而在 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” 的隱藏類。

[譯] JavaScript 如何工作:在 V8 引擎裡 5 個優化程式碼的技巧

  執行到這裡,Point 還沒有定義任何的屬性,所以 “C0” 是空的。

  當第一條語句 “this.x = x” 開始執行 (在 “Point” 函式中), V8 將會基於 “C0” 建立第二個隱藏類叫做 “C1”。“C1” 描述了屬性值 x 在記憶體中的位置(相對於物件指標)。在這個例子中, “x” 被存在 偏移值 為 0 的地方, 這意味著當在記憶體中把 point 物件視為一段連續的 buffer 時,它的第一個偏移量對應的屬性就是 “x”。V8 也會使用類轉換更新 “C0”,如果一個屬性 “x” 被新增到這個 point 物件中,隱藏類就會從 “C0” 切換到 “C1”。那麼,現在這個point 物件的隱藏類就是 “C1” 了。

[譯] JavaScript 如何工作:在 V8 引擎裡 5 個優化程式碼的技巧

  每當一個新屬性新增到物件,老的隱藏類就會通過一個轉換路徑更新成一個新的隱藏類。隱藏類轉換非常重要,因為它們允許以相同方法建立的物件共享隱藏類。如果兩個物件共享一個隱藏類,並給它們新增相同的屬性,隱藏類轉換能夠確保這兩個物件都獲得新的隱藏類以及與之相關聯的優化程式碼。

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

  一個新的隱藏類 “C2” 被建立了,如果屬性 “y” 被新增到 Point 物件(已經包含了 “x” 屬性),同樣的過程,型別轉換被新增到 “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 會以不同的類轉換路徑結束,隱藏類也不同。其實,在這兩個例子中我們可以看到,最好的方式是使用相同的順序初始化動態屬性,這樣的話隱藏類就能夠複用了。

內聯快取 (Inline caching)

  V8 還利用另一種叫內聯快取的技術來優化動態型別語言。內聯快取依賴於我們觀察到:同一個方法的重複呼叫是發生在相同型別的物件上的。關於內聯快取更深層次的解讀請看這裡

  我們來大致瞭解一下內聯快取的基本概念 (如果你沒有時間去閱讀上面的深層次的解讀)。

  那麼它是如何工作的呢?V8 維護了一個物件型別的快取,儲存的是在最近的方法呼叫中作為引數傳遞的物件型別,然後 V8 會使用這些資訊去預測將來什麼型別的物件會再次作為引數進行傳遞。如果 V8 對傳遞給方法的物件的型別做出了很好的預測,那麼它就能夠繞開獲取物件屬性的計算過程,取而代之的是使用先前查詢這個物件的隱藏類時所儲存的資訊。

  那麼隱藏類和內聯快取的概念是怎麼聯絡在一起的呢?無論什麼時候當一個特定的物件上的方法被呼叫時,V8 引擎都會查詢這個物件的隱藏類以便確定獲取特定屬性的偏移值。當對於同一個隱藏類兩次成功的呼叫了同一個方法時,V8 就會略過查詢隱藏類,將這個屬性的偏移值新增到物件本身的指標上。對於未來這個方法的所有呼叫,V8 引擎都會假設隱藏類沒有改變,而是直接跳到特定屬性在記憶體中的位置,這是通過之前查詢時儲存的偏移值做到的。這極大的提高了 V8 的執行速度。

  同時,內聯快取也是同型別物件共享隱藏類如此重要的原因。如果我們使用不同的隱藏類建立了兩個同型別的物件(就如同我們前面做的那樣),V8 就不能使用內聯快取,因為即使兩個物件是相同的,但是它們對應的隱藏類對它們的屬性分配了不同的偏移值。

[譯] JavaScript 如何工作:在 V8 引擎裡 5 個優化程式碼的技巧

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

編譯成機器程式碼

  一旦 Hydrogen 圖被優化,Crankshaft 就會把這個圖降低到一個比較低層次的表現形式 —— 叫做 Lithium。大多數 Lithium 實現都是面向特定的結構的。暫存器分配就發生在這一層次。

  最後,Lithium 被編譯成機器碼。然後,OSR就開始了:一種執行時替換正在執行的棧幀的技術(on-stack replacement)。在我們開始編譯和優化一個明顯耗時的方法時,我們可能會執行它。V8 不會把它之前執行的慢的程式碼拋在一旁,然後再去執行優化後的程式碼。相反,V8 會轉換這些程式碼的上下文(棧, 暫存器),以便在執行這些慢程式碼的途中轉換到優化後的版本。這是一個非常複雜的任務,要知道 V8 已經在其他的優化中將程式碼嵌入了。當然了,V8 不是唯一能做到這一點的引擎。

  V8 還有一種保護措施叫做反優化,能夠做相反的轉換,將程式碼逆轉成沒有優化過的程式碼以防止引擎做的猜測不再正確。

垃圾回收

  對於垃圾回收,V8 使用一種傳統的分代式標記清除的方式去清除老生代的資料。標記階段會阻止 JavaScript 的執行。為了控制垃圾回收的成本,並且使 JavaScript 的執行更加穩定,V8 使用增量標記:與遍歷全部堆去標記每一個可能的物件的不同,取而代之的是它只遍歷部分堆,然後就恢復正常執行。下一次垃圾回收就會從上一次遍歷停下來的地方開始,這就使得每一次正常執行之間的停頓都非常短。就像前面說的,清理的操作是由獨立的執行緒的進行的。

Ignition 和 TurboFan

  隨著 2017 年早些時候 V8 5.9 版本的釋出,一個新的執行管線被引入。這個新的執行管線在 實際的 JavaScript 應用中實現了更大的效能提升、顯著的節省了記憶體的使用。

  這個新的執行管線構建在 V8 的直譯器 Ignition 和 最新的優化編譯器 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 不是遞增的數字的稀疏陣列,這種 key 值不是遞增數字的稀疏陣列其實是一個 hash 表。在這種陣列中每一個元素的獲取都是昂貴的代價。同時,要避擴音前申請大陣列。最好的做法是隨著你的需要慢慢的增大陣列。最後,不要刪除陣列中的元素,因為這會使得 keys 變得稀疏。
  5. 標記值 (Tagged values): V8 用 32 位來表示物件和數字。它使用一位來區分它是物件 (flag = 1) 還是一個整型 (flag = 0),也被叫做小整型(SMI),因為它只有 31 位。然後,如果一個數值大於 31 位,V8 將會對其進行 box 操作,然後將其轉換成 double 型,並且建立一個新的物件來裝這個數。所以,為了避免代價很高的 box 操作,儘量使用 31 位的有符號數。

  我們在 SessionStack 會嘗試去遵循這些最佳實踐去寫出高質量、優化的程式碼。原因是一旦你將 SessionStack 整合到你的 web 應用中,它就會開始記錄所有東西:包括所有 DOM 的改變,使用者互動,JavaScript 異常,棧追蹤,網路請求失敗和 debug 資訊。有了 SessionStack 你就能夠把你 web 應用中的問題當成視訊,你可以看回放來確定你的使用者發生了什麼。而這一切都不會影響到你的 web 應用的正常執行。 這兒有個免費的計劃可以讓你 開始

[譯] JavaScript 如何工作:在 V8 引擎裡 5 個優化程式碼的技巧

更多資源


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章