[譯] JavaScript 如何工做:在 V8 引擎裏 5 個優化代碼的技巧

JavaScript 如何工做:在 V8 引擎裏 5 個優化代碼的技巧

  幾個星期前咱們開始了一個旨在深刻挖掘 JavaScript 以及它是如何工做的系列文章。咱們經過了解它的底層構建以及它是怎麼發揮做用的,能夠幫助咱們寫出更好的代碼與應用。javascript

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

概述

  JavaScript 引擎 是執行 JavaScript 代碼的程序或者說是解釋器。JavaScript 引擎可以被實現成標準解釋器或者是可以將 JavaScript 以某種方式編譯爲字節碼的即時編譯器。前端

  下面是一些比較火的實現 JavaScript 引擎的項目:java

  • 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。react

  V8 最初被設計出來是爲了提升瀏覽器內部 JavaScript 的執行性能。爲了獲取更快的速度,V8 將 JavaScript 代碼編譯成了更加高效的機器碼,而不是使用解釋器。它就像 SpiderMonkey 或者 Rhino (Mozilla) 等許多現代JavaScript 引擎同樣,經過運用即時編譯器將 JavaScript 代碼編譯爲機器碼。而這之中最主要的區別就是 V8 不生成字節碼或者任何中間代碼。android

V8 曾經有兩個編譯器

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

  • full-codegen — 一個簡單而且速度很是快的編譯器,能夠生成簡單但相對比較慢的機器碼。
  • Crankshaft — 一個更加複雜的 (即時) 優化編譯器,生成高度優化的代碼。

  V8 引擎在內部也使用了多個線程:git

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

  當第一次執行 JavaScript 代碼的時候,V8 利用 full-codegen 直接將解析的 JavaScript 代碼不通過任何轉換翻譯成機器碼。這使得它能夠 很是快速 的開始執行機器碼,請注意,V8 不使用任何中間字節碼錶示,從而不須要解釋器。github

  當你的代碼已經運行了一段時間了,分析器線程已經收集了足夠的數據來告訴運行時哪一個方法應該被優化。web

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

代碼嵌入 (Inlining)

  首次優化就是儘量的提早嵌入更多的代碼。代碼嵌入就是將使用函數的地方(調用函數的那一行)替換成調用函數的本體。這簡單的一步就會使接下來的優化更加有用。

隱藏類 (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」 的隱藏類。

  運行到這裏,Point 尚未定義任何的屬性,因此 「C0」 是空的。

  當第一條語句 「this.x = x」 開始執行 (在 「Point」 函數中), V8 將會基於 「C0」 建立第二個隱藏類叫作 「C1」。「C1」 描述了屬性值 x 在內存中的位置(相對於對象指針)。在這個例子中, 「x」 被存在 偏移值 爲 0 的地方, 這意味着當在內存中把 point 對象視爲一段連續的 buffer 時,它的第一個偏移量對應的屬性就是 「x」。V8 也會使用類轉換更新 「C0」,若是一個屬性 「x」 被添加到這個 point 對象中,隱藏類就會從 「C0」 切換到 「C1」。那麼,如今這個point 對象的隱藏類就是 「C1」 了。

  每當一個新屬性添加到對象,老的隱藏類就會經過一個轉換路徑更新成一個新的隱藏類。隱藏類轉換很是重要,由於它們容許以相同方法建立的對象共享隱藏類。若是兩個對象共享一個隱藏類,並給它們添加相同的屬性,隱藏類轉換可以確保這兩個對象都得到新的隱藏類以及與之相關聯的優化代碼。

  當執行語句 「this.y = y」 (一樣,在 Point 函數內部,「this.x = x」 語句以後) 時,將重複此過程。

  一個新的隱藏類 「C2」 被建立了,若是屬性 「y」 被添加到 Point 對象(已經包含了 「x」 屬性),一樣的過程,類型轉換被添加到 「C1」 上,而後隱藏類開始更新成 「C2」,而且 Point 對象的隱藏類就要更新成 「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 會以不一樣的類轉換路徑結束,隱藏類也不一樣。其實,在這兩個例子中咱們能夠看到,最好的方式是使用相同的順序初始化動態屬性,這樣的話隱藏類就可以複用了。

內聯緩存 (Inline caching)

  V8 還利用另外一種叫內聯緩存的技術來優化動態類型語言。內聯緩存依賴於咱們觀察到:同一個方法的重複調用是發生在相同類型的對象上的。關於內聯緩存更深層次的解讀請看這裏

  咱們來大體瞭解一下內聯緩存的基本概念 (若是你沒有時間去閱讀上面的深層次的解讀)。

  那麼它是如何工做的呢?V8 維護了一個對象類型的緩存,存儲的是在最近的方法調用中做爲參數傳遞的對象類型,而後 V8 會使用這些信息去預測未來什麼類型的對象會再次做爲參數進行傳遞。若是 V8 對傳遞給方法的對象的類型作出了很好的預測,那麼它就可以繞開獲取對象屬性的計算過程,取而代之的是使用先前查找這個對象的隱藏類時所存儲的信息。

  那麼隱藏類和內聯緩存的概念是怎麼聯繫在一塊兒的呢?不管何時當一個特定的對象上的方法被調用時,V8 引擎都會查找這個對象的隱藏類以便肯定獲取特定屬性的偏移值。當對於同一個隱藏類兩次成功的調用了同一個方法時,V8 就會略過查找隱藏類,將這個屬性的偏移值添加到對象自己的指針上。對於將來這個方法的全部調用,V8 引擎都會假設隱藏類沒有改變,而是直接跳到特定屬性在內存中的位置,這是經過以前查找時存儲的偏移值作到的。這極大的提升了 V8 的執行速度。

  同時,內聯緩存也是同類型對象共享隱藏類如此重要的緣由。若是咱們使用不一樣的隱藏類建立了兩個同類型的對象(就如同咱們前面作的那樣),V8 就不能使用內聯緩存,由於即便兩個對象是相同的,可是它們對應的隱藏類對它們的屬性分配了不一樣的偏移值。

  這兩個對象基本相同,可是屬性 「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 將擁有更簡單、更易維護的架構。

  在 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 應用的正常運行。 這兒有個免費的計劃可讓你 開始

更多資源


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

相關文章
相關標籤/搜索