V8 入門記錄一:初識

關於 V8

我想前端從業人員或多或少會聽說過這個詞,但是他具體是什麼, 怎麼入門, 怎麼學習是一個較高的門檻,本文就 V8 入門,來做一個記錄,也方便大家的學習。

V8 是 Google 用 C++ 編寫的開源高性能 JavaScript 和 WebAssembly 引擎。它被用於 Chrome 瀏覽器和 Node.js 等。它實現了 ECMAScript 和 WebAssembly,可在 Windows 7 或更高版本、macOS 10.12+ 和使用 x64、IA-32、ARM 或 MIPS 處理器的 Linux 系統上運行。V8 可獨立運行,也可嵌入到任何 C++ 應用程序中。

js 引擎

JavaScript 本質上是一種解釋型語言,與編譯型語言不同的是它需要一遍執行一邊解析,而編譯型語言在執行時已經完成編譯,可直接執行,有更快的執行速度(如上圖所示)。

爲了提高性能,引入了 Java 虛擬機和 C++ 編譯器中的衆多技術。

現在 JavaScript 引擎的執行過程大致是:
源代碼-→抽象語法樹-→字節碼-→JIT-→本地代碼

V8更加直接的將抽象語法樹通過JIT技術轉換成本地代碼,放棄了在字節碼階段可以進行的一些性能優化,但保證了執行速度。在V8生成本地代碼後,也會通過 Profiler 採集一些信息,來優化本地代碼。

這便是現在的執行過程:

源代碼-→抽象語法樹-→JIT-→本地代碼

v5.9 版本之前,V8 使用兩個編譯器:

  • full-codegen:一個簡單快速的編譯器,用於生成簡單但相對較慢的機器代碼
  • Crankshaft:一個複雜的 (JIT) 優化的編譯器,用於生成高度優化的代碼

執行

V8 引擎內部會使用幾個線程:

  • 主線程完成用戶期望的工作:獲取代碼,編譯,執行
  • 單獨的編譯線程:在主線程執行的同時,進行代碼優化
  • 單獨的profiler線程:提供給 Crankshaft 判斷哪些方法更耗時以進行優化
  • 垃圾收集器(Garbage Collector)進行垃圾清理的額外一些線程

首次執行 JavaScript 代碼時,V8 使用 full-codegen,它直接將已解析的 JavaScript 轉換爲機器代碼,而無需任何中間轉換,這使它可以“非常快地”開始執行機器代碼。V8 沒有使用中間字節碼錶示,因而消除了對解釋器的需求。

代碼運行了一段時間後,profiler 線程收集到了足夠的數據,可以判斷對哪些方法應該進行優化。

接下來,另一個線程開始 Crankshaft 優化。它將 JavaScript 抽象語法樹轉換爲叫做 Hydrogen 的高級靜態單分配(SSA, static single-assignment)表示形式,並嘗試優化該 Hydrogen graph。大多數優化都是在這一等級上完成的。

v8的具體優化方案

隱藏類 (Hidden class)

對於動態類型語言來說,由於類型的不確定性,在方法調用過程中,語言引擎每次都需要進行動態查詢,這就造成大量的性能消耗,從而降低程序運行的速度。而在靜態語言中,可以直接通過偏移量查詢來查詢對象的屬性值。

既然靜態語言的查詢效率這麼高,那麼是否能將這種靜態的特性引入到 V8 中呢?

而V8引擎就引入了隱藏類(Hidden Class)的機制,起到給對象分組的作用。

在初始化對象的時候,V8引擎會創建一個隱藏類,隨後在程序運行過程中每次增減屬性,就會創建一個新的隱藏類或者查找之前已經創建好的隱藏類。每個隱藏類都會記錄對應屬性在內存中的偏移量,從而在後續再次調用的時候能更快地定位到其位置。

function Point(x, y) {
    this.x = x;
    this.y = y;
}
var p1 = new Point(1, 2);

調用 new Point(1,2) 時,V8引擎將創建一個名爲 C0 的隱藏類。

image

Point 還沒有定義任何屬性,因此 C0 是空的。

一旦執行第一條語句 this.x = x(在 Point 函數內部),V8引擎將在 C0 的基礎上創建第二個名爲 C1 的隱藏類。

C1 描述了屬性 x 在內存中的位置(相對於對象指針)。 在本例中,x 存儲在偏移 0 處,這意味着當以連續緩衝區的形式查看內存中的 Point 對象時,第一個偏移將對應於屬性 x

V8引擎還將通過 "類轉換" 更新 C0,即如果在 Point 對象中添加了屬性 x,則隱藏類將從 "C0 "轉換爲 "C1"。

下面這個 Point 對象的隱藏類現在是 C1

image

每當一個對象添加一個新屬性時,舊的隱藏類就會更新爲新的隱藏類。 隱藏類轉換之所以重要,是因爲它允許以相同方式創建的對象共享隱藏類

如果兩個對象共享一個隱藏類,並且兩個對象都添加了相同的屬性,那麼轉換將確保兩個對象都獲得相同的新隱藏類以及隨之而來的所有優化代碼。

當執行語句 this.y = y 時(同樣是在 Point 函數內部,this.x = x 語句之後),這一過程將重複執行。

創建了一個名爲 C2 的新隱藏類,並在 C1 中添加了一個類轉換。
image

隱藏類的轉換取決於向對象添加屬性的順序。

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;

現在,你會認爲 p1p2 都會使用相同的隱藏類和轉換嗎?

其實不然。對於 p1,首先要添加屬性 a,然後再添加屬性 b。而對於 p2,首先會分配 b,然後是 a
因此,由於過渡路徑不同,p1p2 最終會擁有不同的隱藏類。
在這種情況下,最好以相同的順序初始化動態屬性,這樣隱藏類就可以重複使用。

想要知道更多隱藏類的信息可查看文章:
https://juejin.cn/post/7064390052519870501

內聯緩存

V8 利用了另一種用於優化動態類型語言的技術,稱爲內聯緩存。內聯緩存依賴於對作用在相同類型對象的、重複調用相同方法的趨勢觀察。更深入的討論內聯緩存說明,可以點擊這裏查看。如果您沒時間詳細閱讀深入說明,我們這裏也將介紹一些內聯緩存的一般概念:

那麼它是如何工作的呢?

V8 會對近期方法調用中作爲參數傳遞的對象類型進行緩存,並利用這些信息對未來作爲參數傳遞的對象類型做出假設。如果 V8 能夠很好地推測出傳遞給方法的對象類型,那麼它就可以繞過計算如何訪問對象屬性的過程,轉而使用以前查詢到的存儲信息來訪問對象的隱藏類。

var arr = [1, 2, 3, 4];
arr.forEach((item) => console.log(item.toString());

像上面這段代碼,數字1在第一次 toString() 方法時會發起一次動態查詢,並記錄查詢結果。當後續再調用 toString 方法時,引擎就能根據上次的記錄直接獲知調用點,不再進行動態查詢操作。

var arr = [1, '2', 3, '4'];
arr.forEach((item) => console.log(item.toString());

可以看到,調用 toString 方法的對象類型經常發生改變,這就會導致緩存失效。爲了防止這種情況發生,V8引擎採用了 polymorphic inline cache (PIC) 技術, 該技術不僅僅只緩存最後一次查詢結果,還會緩存多次的查詢結果(取決於記錄上限)。

關於 PIC 可查看此處

那麼,隱藏類和內聯緩存的概念有什麼關係呢?

每當調用特定對象的方法時,V8 引擎都要對該對象的隱藏類進行查找,以確定訪問特定屬性的偏移量。在對同一隱藏類成功調用兩次同一方法後,V8 會省略隱藏類查找,直接將屬性偏移量添加到對象指針本身。在以後對該方法的所有調用中,V8 引擎都會假定隱藏類沒有改變,並使用以前查找時存儲的偏移量直接跳轉到特定屬性的內存地址。這大大提高了執行速度。

如果你創建了兩個類型相同但隱藏類不同的對象(就像我們在前面的例子中所做的),V8 將無法使用內聯緩存,因爲即使這兩個對象的類型相同,它們對應的隱藏類分配給它們的屬性的偏移量也是不同的。

image

小結

  1. 使用字面量初始化對象時,要保證屬性的順序是一致的。
  2. 儘量在構造函數中使用字面量一次性初始化完整對象屬性。
  3. 重複執行相同方法的代碼會比只執行一次許多不同方法的代碼運行得更快。

編譯爲機器碼

Hydrogen graph 被優化時,Crankshaft 會將其轉成爲更低級別的表達形式(稱之爲 Lithium )。大多數 Lithium 實現都是特定於計算機架構,寄存器分配也在此級別進行。

Lithium 最終會被編譯爲機器碼。接下來是被稱爲 OSR 的堆棧替換。在我們開始編譯和優化明顯會長時間運行的方法之前,我們可能已經在運行它了。V8 不是先記錄執行緩慢的方法,然後再次啓動的時候使用優化版本。相反,他會將所有上下文(棧、寄存器)進行轉換,以便於我們能夠在執行過程中切換到優化後的代碼。再加上其他優化,V8 初始化時的內聯代碼是一項很複雜的任務。不過,V8 引擎也不是唯一能做到這點引擎。

有一些稱爲反優化 (deoptimization) 的保護措施,在假設引擎不使用的場景下,可以反向轉換成未優化的代碼。

垃圾回收

關於垃圾收集,V8 使用傳統的方法,通過標記清除 (mark-and-sweep) 的方式來清理垃圾 (old generation)。

標記階段 (marking phase) 需要停止 JavaScript 執行。爲了控制垃圾回收成本並使執行更加穩定,V8使用了增量標記:不是遍歷整個堆(嘗試對每個可能的對象進行標記),而是隻遍歷堆的一部分,然後恢復正常執行。下一次垃圾收集,會從上一次堆遍歷停止的位置繼續進行。這種方式允許在正常執行期間非常短的暫停。如前所述,清除階段 (sweep phase) 由單獨的線程處理。

IgnitionTurboFan的引入

2017 年早些時候發佈的 V8 5.9 引入了新的執行管道。在實際的 JavaScript 應用程序中,新管道實現了更大的性能提升和顯著的內存節省。

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

您可以點擊這裏查看 V8 團隊的相關博文。

自 V8 5.9 版本發佈以來,由於 V8 團隊一直在努力跟上 JavaScript 語言的新特性以及這些特性所需的優化,V8 不再使用 full-codegenCrankshaft(自 2010 年以來一直爲 V8 服務的技術)來執行 JavaScript。

這意味着今後 V8 的整體架構將更加簡單,可維護性更高。

image

這些改進僅僅是個開始。新的 IgnitionTurboFan 管道爲進一步優化鋪平了道路,這些優化將在未來幾年內提升 JavaScript 性能並減少 V8 在 Chrome 瀏覽器和 Node.js 中的佔用空間。

介紹

現在,V8 的每個組件都有一個特定的名稱,具體如下:

  • Ignition: V8 基於寄存器的快速低級解釋器,用於生成字節碼。
  • SparkPlug: V8 新的非優化 JavaScript 編譯器,通過迭代字節碼並在訪問每個字節碼時爲其輸出機器碼,從而根據字節碼進行編譯。
  • TurboFan:V8 的優化編譯器,可將字節碼轉換爲機器碼,並進行更多和更復雜的代碼優化。它還包括 JIT(即時編譯)。

綜上所述,V8 的編譯流程概覽如下:
image

TurboFan 如何發揮作用

識別代碼中的熱點函數後,Ignition 會將數據發送給 TurboFan 進行優化。TurboFan 會接收這些代碼,並開始運行一些神奇的優化,因爲它已經擁有了來自 Ignition 的假設數據。然後,它會用新的優化字節碼替換原來的字節碼,這個過程會在我們程序的整個生命週期中不斷重複。

這裏舉個例子:

function add(x, y) {
    return x + y;
}

add(1, 2);
add(12, 42);
add(17, 25);
add(451, 342);
add(8, 45);

將代碼轉換爲字節碼並運行後,Ignition 將執行以下冗長的加法過程:

image

現在,當我們多次調用這個帶有整數參數的函數時,Ignition 會將其歸類爲熱函數,並將收集到的信息發送給 TurboFanTurboFan 會針對整數優化這個函數,生成字節碼並將其替換爲原始字節碼。現在,當下次調用 add(21, 45) 函數時,所有這些冗長的步驟都將被省略,並能更快地得到結果。

後備機制

如果我們調用帶有字符串參數的 add 函數呢?爲了處理這種情況,TurboFan 會檢查傳遞的參數類型。如果類型與數字不同,它就會返回到點火器生成的原始字節碼,並再次執行這一漫長的過程。這個過程被稱爲去優化(Deoptimization)。

如果我們多次調用帶有字符串參數的 add 函數,Ignition 會將其視爲熱函數,並將其發送給 TurboFan,同時收集相關信息。TurboFan 還將針對字符串參數優化 add 函數,下次調用 add 函數時,將運行優化後的字節碼,從而提高性能。

建議將 JavaScript 變量視爲靜態類型變量,這樣才能保證代碼的性能。這不僅適用於原始類型,也適用於對象。

如何編寫最優化的 JavaScript

  1. 對象屬性的順序:始終按照相同的順序實例化對象屬性,以便共享隱藏類和隨後的優化代碼。
  2. 動態屬性:在實例化後向對象添加屬性會強制改變隱藏類,並減慢爲前一個隱藏類優化的任何方法的運行速度。因此,應在構造函數中分配對象的所有屬性。
  3. 方法:重複執行相同方法的代碼會比只執行一次不同方法的代碼運行得更快(由於內聯緩存)。
  4. 數組:避免使用鍵不是遞增數字的稀疏數組。稀疏數組中的每個元素都不在數組內,這就是哈希表。此類數組中的元素訪問成本較高。此外,儘量避免預先分配大型數組。最好是邊使用邊增長。最後,不要刪除數組中的元素。這會使鍵變得稀疏。
  5. 標記值: V8 用 32 位表示對象和數字。它使用一個位來識別是對象(標記 = 1)還是整數(標記 = 0),因爲它有 31 位,所以稱爲 SMI(SMall Integer)。然後,如果數值大於 31 位,V8 將對該數字進行框選,將其轉化爲 double,並創建一個新對象將該數字放入其中。請儘可能使用 31 位有符號數字,以避免將其裝入 JS 對象的昂貴裝箱操作。

結語

本文主要是 V8 相關概念的講述,並附上一些實例便於理解, 同時給出了從 V8 角度下代碼的最優編寫規則。

最後附上 JSIL 中看到的一句:

If your goal is to write the fastest possible JavaScript for your libraries or applications, you may need to go to those same lengths. For many applications, it's not worth the trouble - so resist the temptation to go nuts micro-optimizing something that really isn't particularly slow to begin with 😃

引用

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章