JavaScript 工作原理之二-如何在 V8 引擎中書寫最優程式碼的 5 條小技巧(譯)

tristan發表於2018-04-26

原文請查閱這裡,略有刪減。

本系列持續更新中,Github 地址請查閱這裡

這是 JavaScript 工作原理的第二章。

本章將會深入谷歌 V8 引擎的內部結構。我們也會為如何書寫更好的 JavaScript 程式碼提供幾條小技巧-SessionStack 開發小組在構建產品的時候所遵循的最佳實踐。

概述

一個 JavaScript 引擎就是一個程式或者一個解釋程式,它執行 JavaScript 程式碼。一個 JavaScript 引擎可以用標準解釋程式或者即時編譯器來實現,即時編譯器即以某種形式把 JavaScript 解釋為位元組碼。

以下是一系列實現 JavaScript 引擎的熱門工程:

  • V8-由谷歌開源的以 C++ 語言編寫
  • Rhin-由 Mozilla 基金會主導,開源的,完全使用 Java 開發。
  • SpiderMonkey-初代 JavaScript 引擎,由在之前由網景瀏覽器提供技術支援,現在由 Firefox 使用。
  • JavaScriptCore-開源,以 Nitro 的名稱來推廣,並由蘋果為 Safari 開發。
  • KJS-KDE 引擎,起先是由 Harri Porten 為 KDE 工程的 Konqueror 瀏覽器所開發。
  • Chakra (JScript9)-IE
  • Chakra (JavaScript)-Microsoft Edge
  • Nashorn-作為 OpenJDK 的一部分來開源,由 Oracle Java 語言和 Tool Group 編寫。
  • JerryScript-一款輕量級的物聯網引擎。

V8 引擎的由來

V8 引擎是由谷歌開源並以 C++ 語言編寫。Google Chrome 內建了這個引擎。而 V8 引擎不同於其它引擎的地方在於,它也被應用於時下流行的 Node.js 執行時中。

JavaScript 工作原理之二-如何在 V8 引擎中書寫最優程式碼的 5 條小技巧(譯)

起先 V8 是被設計用來優化網頁瀏覽器中的 JavaScript 的執行效能。為了達到更快的執行速度,V8 把 JavaScript 程式碼轉化為更加高效的機器碼而不是使用解釋程式。它通過實現一個即時編譯器在執行階段把 JavaScript 程式碼編譯為機器碼,就像諸如 SpiderMonkey or Rhino (Mozilla) 等許多現代 JavaScript 引擎所做的那樣。主要的區別在於 V8 不產生位元組碼或者任何的中間碼。

V8 曾經擁有兩個編譯器

在 V8 5.9誕生(2017 年初) 之前,引擎擁有兩個編譯器:

  • full-codegen-一個簡單且快速的編譯器用來產出簡單且執行相對緩慢的機器碼。
  • Crankshaft-一個更復雜(即時)優化的編譯器用來產生高效的程式碼。

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

  • 主執行緒做你所期望的事情-抓取程式碼,編譯後執行
  • 有獨立的執行緒來編譯程式碼,所以主執行緒可以保持執行而前者正在優化程式碼
  • 一個用於效能檢測的執行緒會告訴執行時我們在哪個方法上花了太多的時間,以便於讓 Crankshaft 來優化這些程式碼
  • 有幾個執行緒用來處理垃圾回收器的清理工作。

當第一次執行 JavaScript 程式碼的時候,V8 使用 full-codegen 直接把解析的 JavaScript 程式碼解釋為機器碼,中間沒有任何轉換。這使得它一開始非常快速地執行機器碼。注意到 V8 沒有使用中間位元組碼來表示,這樣就不需要直譯器了。

當程式碼已經執行一段時間後,效能檢測器執行緒已經收集了足夠多的資料來告訴 Crankshaft 哪個方法可以被優化。

接下來,在另一個執行緒中開始進行 Crankshaft 程式碼優化。它把 JavaScript 語法抽象樹轉化為一個被稱為 Hydrogen 的高階靜態單賦值並且試著優化這個 Hydrogen 圖表。大多數的程式碼優化是發生在這一層。

內聯

第一個優化方法即是提前儘可能多地內聯程式碼。內聯指的是把呼叫地址(函式被呼叫的那行程式碼)置換為被呼叫函式的函式體的過程。這個簡單的步驟使得接下來的程式碼優化更有意義。

JavaScript 工作原理之二-如何在 V8 引擎中書寫最優程式碼的 5 條小技巧(譯)

隱藏類

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

JavaScript 工作原理之二-如何在 V8 引擎中書寫最優程式碼的 5 條小技巧(譯)

因為還沒有為類 Point 建立屬性,所以 "C0" 是空的。

一旦第一條語句 "this.x = x" 開始執行(在 Point 函式中), V8 將會基於 "C0" 建立第二個隱藏類。"C1" 描述了可以找到 x 屬性的記憶體地址(相對於物件指標)。本例中,"x" 儲存在位移 0 中,這意味著當以記憶體中連續的緩衝區來檢視點物件的時候,位移起始處即和屬性 "x" 保持一致。V8 將會使用 "類轉換" 來更新 "C0","類轉換" 即表示屬性 "x" 是否被新增進點物件,隱藏類將會從 "C0" 轉為 "C1"。以下的點物件的隱藏類現在是 "C1"。

JavaScript 工作原理之二-如何在 V8 引擎中書寫最優程式碼的 5 條小技巧(譯)

每當物件新增新的屬性,使用轉換路徑來把舊的隱藏類更新為新的隱藏類。隱藏類轉換是重要的,因為它們使得以同樣方式建立的物件可以共享隱藏類。如果兩個物件共享一個隱藏類並且兩個物件新增了相同的屬性,轉換會保證兩個物件收到相同的新的隱藏類並且所有的優化過的程式碼都會包含這些新的隱藏類。

當執行 "this.y = y" 語句的時候,會重複同樣的過程(還是在 Point 函式中,在 "this.x = x" 語句之後)。

一個被稱為 "C2" 的隱藏類被創造出來,一個類轉換被新增進 "C1" 中表示屬性 "y" 是否被新增進點物件(已經擁有屬性 "x")之後隱藏會更改為 "C2",然後點物件的隱藏類會更新為 "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" 會使用不同的隱藏類。在這種情況下,更好的方法是以相同的順序初始化動態屬性以便於複用隱藏類。

內聯快取

V8 利用了另一項優化動態型別語言的技術叫做內聯快取。內聯快取依賴於對於同樣型別的物件的同樣方法的重複呼叫的觀察。這裡有一份深入闡述內聯快取的文章

我們將會接觸到內聯快取的大概概念(萬一你沒有時間去通讀以上的深入理解內聯快取的文章)。

它是如何工作的呢?V8 會維護一份傳入最近呼叫方法作為引數的物件型別的快取,然後使用這份資訊假設在未來某個時候這個物件型別將會被傳入這個方法。如果 V8 能夠很好地預判即將傳入方法的物件型別,它就可以繞過尋找如何訪問物件屬性的過程,代之以使用儲存的來自之前查詢到的物件隱藏類的資訊。

所以隱藏類的概念和內聯快取是如何聯絡在一起的呢?每當在一個指定的物件上呼叫方法的時候,V8 引擎不得不執行查詢物件隱藏類的操作,用來取得訪問指定屬性的位移。在兩次對於相同隱藏類的相同方法的成功呼叫之後,V8 忽略隱藏類的查詢並且只是簡單地把屬性的位移新增給物件指標自身。在之後所有對這個方法的呼叫,V8 引擎假設隱藏類沒有改變,然後使用之前查詢到的位移來直接跳轉到指定屬性的記憶體地址。這極大地提升了程式碼執行速度。

記憶體快取也是為什麼同樣型別的物件共享隱藏類是如此重要的原因。當你建立了兩個同樣型別的物件而使用不同的隱藏類(正如之前的例子所做的那樣),V8 將不可能使用記憶體快取,因為即使相同型別的兩個物件,他們對應的隱藏類為他們的屬性分派不同的地址位移。

JavaScript 工作原理之二-如何在 V8 引擎中書寫最優程式碼的 5 條小技巧(譯)

這兩個物件基本上是一樣的但是建立 "a" 和 "b" 的順序是不同的

編譯為機器碼

一旦優化了 Hydrogen 圖表,Crankshaft 會把它降級為低階的展現叫做 Lithium。大多數 Lithium 的實現都是依賴於指定的架構的。暫存器分配發生在這一層。

最後,Lithium 會被編譯為機器碼。之後其它被稱為 OSR 的事情發生了:堆疊替換。在開始編譯和優化一個明顯的耗時的方法之前,過去極有可能去執行它。V8 不會忘記程式碼執行緩慢的地方,而再次使用優化過的版本程式碼。相反,它會轉換所有的上下文(堆疊,暫存器),這樣就可以在執行過程中切換到優化的版本程式碼。這是一個複雜的任務,你只需要記住的是,在其它優化過程中,V8 會初始化內聯程式碼。V8 並不是唯一擁有這項能力的引擎。

這裡有被稱為逆優化的安全防護,以防止當引擎所假設的事情沒有發生的時候,可以進行逆向轉換和把程式碼反轉為未優化的程式碼。

垃圾回收

V8 使用傳統的標記-清除技術來清理老舊的記憶體以進行垃圾回收。標記階段會中止 JavaScript 的執行。為了控制垃圾回收的成本並且使得程式碼執行更加穩定,V8 使用增量標記法:不遍歷整個記憶體堆,試圖示記每個可能的物件,它只是遍歷一部分堆,然後重啟正常的程式碼執行。下一個垃圾回收點將會從上一個堆遍歷中止的地方開始執行。這會在正常的程式碼執行過程中有一個非常短暫的間隙。之前提到過,清除階段是由單獨的執行緒處理的。

Ignition 和 TurboFan

隨著 2017 早些時候 V8 5.9 版本的釋出,帶來了一個新的執行管道。新的管道獲得了更大的效能提升和在現實 JavaScript 程式中,顯著地節省了記憶體。

新的執行管道是建立在新的 V8 直譯器 Ignition 和 V8 最新的優化編譯器 TurboFan 之上的。

你可以檢視 V8 小組的博文

自從 V8 5.9 版本釋出以來,full-codegen 和 Crankshaft(V8 從 2010 開始使用至今) 不再被 V8 用來執行JavaScript,因為 V8 小組正努力跟上新的 JavaScript 語言功能以及為這些功能所做的優化。

這意味著接下來整個 V8 將會更加精簡和更具可維護性。

JavaScript 工作原理之二-如何在 V8 引擎中書寫最優程式碼的 5 條小技巧(譯)

網頁和 Node.js benchmarks 評分的提升

這些提升只是一個開始。新的 Ignition 和 TurboFan 管道為未來的優化作鋪墊,它會在未來幾年內提升 JavaScript 效能和縮減 Chrome 和 Node.js 中的 V8 痕跡。

最後,這裡有一些如何寫出優化良好的,更好的 JavaScript 程式碼。你可以很容易地從以上的內容中總結出來,然而,為了方便你,下面有份總結:

如何寫優化的 JavaScript 程式碼

  • 物件屬性的順序:總是以相同的順序例項化物件屬性,這樣隱藏類及之後的優化程式碼都可以被共享。
  • 動態屬性:例項化之後為物件新增屬性會致使為之前隱藏類優化的方法變慢。相反,在物件建構函式中賦值物件的所有屬性。
  • 方法:重複執行相同方法的程式碼會比每次執行不同的方法的程式碼更快(多虧了內聯快取)。
  • 數列:避免使用鍵不是遞增數字的稀疏數列。稀疏數列中沒有包含每個元素的數列稱為一個雜湊表。訪問該數列中的元素會更加耗時。同樣地,試著避免預先分配大型陣列。最好是隨著你使用而遞增。最後,不要刪除數列中的元素。這會讓鍵稀疏。
  • 標記值:V8 用 32 位來表示物件和數字。它使用一位來辨別是物件(flag=1)或者是被稱為 SMI(小整數) 的整數(flag=0),之所以是小整數是因為它是 31 位的。之後,如果一個數值比 31 位還要大,V8 將會裝箱數字,把它轉化為浮點數並且建立一個新的物件來儲存這個數字。儘可能試著使用 31 位有符號數字來避免建立 JS 物件的耗時裝箱操作。

打個廣告 ^.^

今日頭條招人啦!傳送簡歷到 likun.liyuk@bytedance.com ,即可走快速內推通道,長期有效!國際化PGC部門的JD如下:c.xiumi.us/board/v5/2H…,也可內推其他部門!

本系列持續更新中,Github 地址請查閱這裡

相關文章