V8 是怎麼跑起來的 - V8 的 JavaScript 執行管道 2021

語言: CN / TW / HK

本文基於 V8 v9.5.172 版本進行測試。

距離我的上一篇 V8 文章已經過去了近兩年的時間。在這段時間裡,我很高興看到中文社群中有越來越多討論 V8 的內容。美中不足的是,一些已經存在很久的、新的內容仍然沒有出現在中文社群。本文基於 2019 年的文章進行內容大規模的重構和更新,旨在為各位讀者帶來儘可能新的、全面的內容。祝閱讀愉快~

image

1. 閱讀前準備

閱讀之前,推薦大家準備好 d8,它是 V8 的開發者命令列工具,文中有基於 d8 的一些實驗。

推薦閱讀《Building V8 with GN》,下載原始碼並編譯構建 V8(注意構建時需要指定為 debug 模式),可完成本文所有實驗。 Bash gm x64.debug # 指定構建為 debug 模式

你也可以通過直接下載的方式,使用開箱即用的 d8,也可完成本文絕大部分實驗。

2. 關於 V8

V8 是使用 C++ 編寫的高效能 JavaScriptWebAssembly 引擎,支援包括我們熟悉的 ia32、x64、arm 在內的十種處理器架構。在瀏覽器端,它支撐著 Chrome 以及眾多 Chromium 核心的瀏覽器執行。在服務端,它是 Node.js 及 Deno 的執行環境。

釋出週期

  • 大約每隔四周,就會有一個新的 V8 版本推出(V8 v9.5 及之後為四周,在這之前為六週)
  • V8 版本與 Chrome 版本對應,如 V8 v9.5 對應 Chrome 95

獨立的核心模組

  • Ignition(基線編譯器)
  • SparkPlug(比 Ignition 更快的非優化編譯器)
  • TurboFan(優化編譯器)
  • Liftoff(WebAssembly 基線編譯器)
  • Orinoco(垃圾回收器)

其它 JavaScript 引擎

  • Chakra(前 Edge JavaScript 引擎)
  • JavaScript Core(Safari)
  • SpiderMonkey(Firefox)

下圖為 V8 和其它 JavaScript 引擎在編譯器管道(Compiler pipeline)上的對比。 d74ed6b7-0942-4d5a-b6e8-d1a7b69a099f 圖源 - Javascript Engines: The Good Parts

JavaScript 在不同引擎中的編譯器管道大同小異。在 V8 中,解析器會將 JavaScript 原始碼轉換成 AST,基線編譯器將 AST 編譯為位元組碼。之後,在滿足一定條件時,位元組碼將被優化編譯器編譯生成優化的機器碼。

3. 執行管道全貌

JavaScript 的執行過程可以簡化理解為 “編譯-執行-垃圾回收” 。在本文中,我們將聚焦於編譯與垃圾回收,其中較多的篇幅將討論編譯器(管道)的內容。

下圖為 V8 編譯器管道的架構演進圖。 822857df-977f-4fae-a461-981643121df2 圖源 - TurboFan: A new code generation architecture for V8

早期 V8 僅有一個 Codegen 編譯器,負責將解析器生成的 AST 直接編譯成機器碼,雖然執行速度快,但是優化有限。

兩年之後,由基線編譯器 Full-Codegen 和優化編譯器 Crankshaft 構成的新編譯器管道出現。基線編譯器更注重編譯速度,而優化編譯器更注重編譯後代碼的執行速度。綜合使用基線編譯器和優化編譯器,使 JavaScript 程式碼擁有更快的冷啟動速度,在優化後擁有更快的執行速度。

儘管此時 V8 已有基線和優化編譯器之分,但這個架構仍存在諸多問題。例如,Crankshaft 只能優化 JavaScript 的一個子集;編譯管道中層與層之間缺乏隔離,在某些情況下甚至需要同時為多個處理器架構編寫彙編程式碼等等。

於是,V8 在 Crankshaft 的基礎上,引入了另一個優化編譯器 TurboFan。TurboFan 通過引入了分層的設計,降低了處理器架構的適配成本,也能優化 ES6 並向前相容更多的未來特性。

但此時,基線編譯器 Full-Codegen 生成的是未優化的機器碼,佔用了 V8 中大約 30% 的堆空間,即使這些程式碼只執行一次也不會釋放,對於記憶體的消耗較大。

因此,V8 引入了位元組碼,並開發了相應的基線編譯器 Ignition。相比機器碼,位元組碼由於更加簡潔、緊湊,記憶體的消耗更小,約為等效基線機器程式碼的 50% 到 25%。同時,Igintion 的位元組碼可以直接被 TurboFan 用於生成優化的機器碼,反優化的機制也得到簡化,整體的架構更加清晰可維護。由於位元組碼生成速度比優化的機器碼更快,因而 Ignition 還可以縮短指令碼的冷啟動時間,進而提高網頁載入速度。

2017 年 5 月,V8 v5.9 正式預設開啟了基線編譯器 Ignition 和優化編譯器 TurboFan 構成的 JavaScript 編譯器管道,並移除了之前 Crankshaft 和 Full-Codegen 編譯器。

2018 年 8 月,V8 v6.9 版本推出了 Liftoff,標誌著 V8 開始同時支援 JavaScript 和 WebAssembly。

2021 年 5 月,V8 v9.1 推出了非優化編譯器 Sparkplug,用於在優化編譯器生成優化程式碼前,獲得更快的執行速度。

下面我們將圍繞一段程式碼,分析 JavaScript 在 V8 中是如何進行處理的。

function addTwo(a, b) { return a + b }

4. 解析器與 AST

當 V8 拿到 JavaScript 程式碼後,首先需要進行程式碼解析,流程如下圖所示。 image 圖源 - Blazingly fast parsing, part 1: optimizing the scanner

Token 由 Scanner(掃描器)進行分詞並生成,被 Parser(解析器)進行消費,處理成為 AST(Abstract Syntax tree,抽象語法樹)。AST 用於描述程式的結構,被基線編譯器 Ignition 消費並生成位元組碼。

由於解析程式碼需要時間,所以 JavaScript 引擎都會盡可能避免完全解析原始碼。另一方面,在一次使用者訪問中,頁面中會有很多程式碼不會被執行到,比如,通過使用者互動行為觸發的動作。

為了節省不必要的 CPU 和記憶體開銷,所有主流瀏覽器都實現了惰性解析(Lazy Parsing)。解析器不必為每個函式生成 AST,而是可以決定 “預解析”(Pre Parsing)或“完全解析”它所遇到的函式。

預解析會檢查原始碼的語法並丟擲語法錯誤,但不會解析函式中變數的作用域或生成 AST。完全解析則將分析函式體並生成原始碼對應的 AST 資料結構。

對於惰性解析感興趣的同學,可以自行閱讀 V8 相關文章

我們可以通過 d8 檢視程式碼的 AST 資訊。(注:此處只能使用 debug 模式的 d8,release 模式不支援 --print-ast 引數) JavaScript // example.js function addTwo(a, b) { return a + b } Bash d8 --print-ast example.js 輸出的資訊如下圖所示。可以看到,生成的資訊中沒有包含函式 addTwo 的 AST 結構,這是由於程式碼命中了惰性解析。 image 我們將程式碼修改為如下形式,增加了一行函式呼叫,再次執行 d8 命令。 JavaScript // example.js function addTwo(a, b) { return a + b } addTwo(1,2) 輸出的資訊如下圖所示。 image

根據 addTwo 的 AST 資訊,我們可以繪製如下的樹狀圖。其中一個子樹用於引數宣告,另一個子樹用於實際的函式體。 image

由於變數提升、eval 等原因,解析過程中無法知道哪些名稱對應於程式中的哪些變數,解析器最初會建立 VAR PROXY 節點,後續作用域解析步驟會將這些 VAR PROXY 節點連線到宣告的 VAR 節點,或者將它們標記為全域性查詢或動態查詢,這取決於解析器是否在周圍的某個作用域中看到了 eval表示式。

值得注意的是,V8 生成的 AST 與 AST Explorer 生成的 eslint/babel/ts 的 AST 有些差異,我在上一版的文章中也有相關的說明。

5. 基線編譯器 Ignition

V8 引入 JIT(Just In Time,即時編譯)技術,通過 Ignition 基線編譯器快速生成位元組碼進行執行。

位元組碼是機器碼的抽象,與系統架構無關。V8 的位元組碼可以看做是小的構建塊(building blocks),這些塊通過組合實現任意的 JavaScript 功能。V8 通過引入位元組碼,減少了記憶體的使用和解析的開銷,同時降低編譯的複雜度。

在 V8 中,由 AST 生成位元組碼的過程通過 Ignition 進行實現。Igntion 是具有累加器的、基於暫存器的直譯器。這裡的暫存器不同於物理暫存器,是一種虛擬的實現。

image 圖源 - Ignition: Jump-starting an Interpreter for V8

Ignition 位元組碼流水線如上圖所示。JavaScript 程式碼會通過 BytecodeGenerator 轉換成為位元組碼,BytecodeGenerator 是一個 AST 遍歷器,針對不同的 AST 節點型別,實現了不同的位元組碼轉換規則,如下圖所示。 image

在這之後,Ignition 會對位元組碼進行一系列的優化。其中,Register Optimizer(暫存器優化器)用於優化不必要的暫存器載入和儲存操作;Peephole Optimizer(窺孔優化器)用於將一組指令優化為效能更好的等效指令;Dead-code Elimination(死程式碼消除)用於移除無法執行到的程式碼。

通過 d8 命令,我們可以獲得函式的位元組碼,如下圖所示。 Bash d8 --print-bytecode example.js image

當我們呼叫 addTwo(1,2) 時,a0、a1 暫存器中已經通過 LdaSmi 分別載入了小整數 1 和 2。通過呼叫 Ldar a1,將 2 載入到累加器中;再呼叫 Add a0, [0],把累加器中的值和 a0 中的值相加,累加器中得到新的值 —— 3;最後執行 Return 將累加器中的值返回。

image 值得注意的是 Add a0, [0],這裡的 [0]是反饋向量的索引。位元組碼解釋執行過程中的分析資訊都儲存在反饋向量中,反饋向量將為後續優化編譯器 TurboFan 提供優化資訊。

Ignition 常用的位元組碼如下圖所示,所有的位元組碼可以在 V8 原始碼 中找到,感興趣的同學可以自行檢視。

image 圖源 - Ignition: Jump-starting an Interpreter for V8

在實際使用的過程中,V8 團隊發現很多函式只在應用初始化時執行,但函式的位元組碼會一直存在於 V8 的堆空間中。為了減少 V8 記憶體開銷,V8 v7.4 版本引入了 Bytecode flushing 技術。V8 會對函式的使用情況進行追蹤,每次垃圾回收時都會增加函式的計數,並在函式執行時將計數值置零,當計數值超過閾值時會對記憶體進行回收處理。

6. 優化編譯器 TurboFan

泛化性越強的程式碼效能越差,反之,編譯器需要考慮的函式型別變化越少,生成的程式碼就越小、越快。

眾所周知,JavaScript 是弱型別語言。ECMAScript 標準中有大量的多義性和型別判斷,通過基線編譯器 Ignition 生成的程式碼執行效率不夠高。

舉個例子,+ 運算子的運算元就可能是整數、浮點數、字串、布林值以及其它的引用型別,它們之間更是可以形成不同的排列組合。

JavaScript function addTwo(a, b) { return a + b; } addTwo(2, 3); // 3 addTwo(8.6, 2.2); // 10.8 addTwo("hello ", "world"); // "hello world" addTwo("true or ", false); // "true or false" // 還有很多組合...

但這並不意味著 JavaScript 程式碼沒有辦法被優化。對於特定的程式邏輯,其接收的引數型別往往是固定的。因此,V8 的優化編譯器 TurboFan 會通過內聯快取(Inline Cache)在執行時收集型別反饋(Type Feedback),將熱點程式碼優化編譯成執行效率更高的機器碼。

由於篇幅問題,內聯快取在此不做展開。單方面友情推薦知乎 @hijiangtao 的譯作 JavaScript 引擎基礎:Shapes 和 Inline Caches,感興趣的話也可以閱讀我的另一篇文章 V8 是怎麼跑起來的 —— V8 中的物件表示

為了驗證程式碼的優化過程,我們將測試程式碼修改為: ```JavaScript // example.js function addTwo (a, b) { return a + b; }

for (let j = 0; j < 100000; j++) { if (j < 80000) { addTwo(10, 10); } else { addTwo('hello', 'world'); } } ```

image 圖源 - A Tale Of TurboFan

TurboFan 的執行流程如上圖所示,生成程式碼的過程依賴於一種基於圖的 IR(Intermediate representation,中間表示),叫做 “Sea of nodes”,它是一種結合控制流和資料流的圖。

d8 中也提供了相應的工具檢視 Sea of nodes 的圖。我們首先需要使用 --trace-turbo 引數執行我們的指令碼檔案。 Bash d8 --trace-turbo example.js 執行過後,當前目錄下會生成 turbo.cfgturbo-xxx-xx.json 檔案。此時,我們就可以通過 V8 的線上工具服務,視覺化地檢視 Sea of nodes 的圖。

開啟上述連結,並找到 V8 v9.5 版本的 Turbolizer 工具,之後將上一步生成的 json 檔案匯入,就可以在瀏覽器中看到 Sea of nodes 圖。 image

位元組碼通過 TurboFan 的編譯器前端轉換為中間程式碼,經過優化、指令選擇、指令排程、暫存器分配、彙編和反彙編等步驟,在編譯器後端生成最終的機器碼。在暫存器分配上,TurboFan 選擇的是比圖著色法效能更好的線性掃描演算法

下面,我們針對 TurboFan 的 IR 和編譯器的程式碼優化措施、熱點程式碼的優化與反優化進一步展開進行討論。

TurboFan IR

image 圖源 - TurboFan JIT Design

TurboFan 中引入了分層編譯器的設計,通過 JavaScript 層、Intermediate 層(V8 部分文件中也叫 Simple 層)和 Machine 層的分層 IR 設計,在高階和低階編譯器優化之間實現了清晰地分離。

在 TurboFan 中,與體系結構相關的是 IR 中的 Machine 層,對應於 TurboFan 的後端。體系結構相關的程式碼只需編寫一次,有效地提升了系統可擴充套件性,降低了關聯模組的耦合度及系統的複雜度。

分層的示意圖如下:

image

image

舉個例子,有 A、B、C 三個特性需要遷移到兩個處理器平臺。在引入 IR 之前,需要有 3 * 2 = 6 種程式碼實現,在引入 IR 之後,需要 3 + 2 = 5 種程式碼實現。可以看出,一個是乘法的關係,一個是加法的關係。當需要實現很多特性並適配多種處理器架構時,引入 IR 的優勢便大大增加了。

編譯器的程式碼優化措施

內聯(Inlining)

內聯就是將小規模的函式在呼叫的位置展開,節省函式呼叫的開銷,尤其是針對頻繁呼叫的函式。

```JavaScript // https://docs.google.com/presentation/d/1UXR1H2elTdAYJJ0Eed7lUctCVUserav9sAYSidxp8YE/edit#slide=id.g284582328f_0_43 function add(x, y) { return x + y; }

function three() { return add(1, 2); } ```

例如,上面的函式經過內聯後,可以得到以下的函式: JavaScript function three_add_inlined() { var x = 1; var y = 2; var add_return_value = x + y; return add_return_value; } 內聯不僅可以減少函式的開銷,也讓更多優化過程變得高效,如常數摺疊(Constant folding)、強度消減(Strength reduction)、冗餘消除(Redundancy elimination)、逃逸分析(Escape analysis)與標量替換(Scalar Replacement)。

比如,上述的函式通過常數摺疊,可以進一步優化為: JavaScript function three_add_const_folder() { return 3; }

逃逸分析與標量替換

逃逸分析用於確定一個物件的生命週期是否僅限於當前函式,可以用於判斷能否進行標量替換。

JavaScript // https://docs.google.com/presentation/d/1UXR1H2elTdAYJJ0Eed7lUctCVUserav9sAYSidxp8YE/edit#slide=id.g2957a3ab8f_0_292 class Point { constructor(x, y) { this.x = x; this.y = y; } distance(that) { return Math.abs(this.x - that.x) + Math.abs(this.y - that.y); } } function manhattan(x1, y1, x2, y2) { const a = new Point(x1, y1); const b = new Point(x2, y2); return a.distance(b); } 上述的函式通過內聯,可以轉換為: JavaScript function manhattan_inl(x1, y1, x2, y2) { const a = {x: x1, y: y1}; const b = {x: x2, y: y2}; return Math.abs(a.x - b.x) + Math.abs(a.y - b.y); } 通過逃逸分析,可以發現 a、b 的生命週期僅存在於 manhattan_inl,因此函式可以通過標量替換優化為: JavaScript function manhattan_ea(x1, y1, x2, y2) { var a_x = x1; var a_y = y1; var b_x = x2; var b_y = y2; return Math.abs(a_x - b_x) + Math.abs(a_y - b_y) }

通過標量替換,可以減少不必要的物件屬性訪問開銷,將屬性訪問轉換為開銷更小的普通變數訪問。在提高執行速度的同時,也可以減少垃圾回收的壓力。

熱點程式碼的優化與反優化

注:該小節涉及的優化與上一小節的優化沒有直接關係,這裡指的是熱點程式碼是否會被整體優化,而不是某個具體的實現如何被優化。

對於重複執行的程式碼,如果多次執行都傳入型別相同的引數,那麼 V8 會假設之後每一次執行的引數型別也是相同的,並對程式碼進行優化。優化後的程式碼會保留基本的型別檢查,如果之後的每次執行引數型別未改變,V8 將一直執行優化過的程式碼。

如果優化後的程式碼不滿足假設性條件,則優化的程式碼無法執行,V8 將會“撤銷”之前的優化操作,這一步稱為“反優化”(Deoptimization),下次型別反饋時會參考這個意料之外的結果,使用更通用的資料型別來描述。

在 d8 中,優化和反優化過程的輸出資訊可以分別通過 --trace-opt--trace-deopt 引數控制。

```JavaScript // example.js function addTwo (a, b) { return a + b; }

for (let j = 0; j < 100000; j++) { if (j < 80000) { addTwo(10, 10); } else { addTwo('hello', 'world'); } } ```

d8 --trace-opt --trace-deopt example.js

[marking 0x0d4e08293421 <JSFunction (sfi = 0xd4e08293291)> for optimized recompilation, reason: hot and stable] [compiling method 0x0d4e08293421 <JSFunction (sfi = 0xd4e08293291)> (target TURBOFAN) using TurboFan OSR] [optimizing 0x0d4e08293421 <JSFunction (sfi = 0xd4e08293291)> (target TURBOFAN) - took 5.114, 11.420, 0.371 ms] [bailout (kind: deopt-soft, reason: Insufficient type feedback for call): begin. deoptimizing 0x0d4e08293421 <JSFunction (sfi = 0xd4e08293291)>, opt id 0, node id 66, bytecode offset 65, deopt exit 2, FP to SP delta 96, caller SP 0x7ffeec6d2528, pc 0x0d4e0090536e] [marking 0x0d4e08293465 <JSFunction addTwo (sfi = 0xd4e082932e9)> for optimized recompilation, reason: small function] [compiling method 0x0d4e08293465 <JSFunction addTwo (sfi = 0xd4e082932e9)> (target TURBOFAN) using TurboFan] [optimizing 0x0d4e08293465 <JSFunction addTwo (sfi = 0xd4e082932e9)> (target TURBOFAN) - took 1.320, 3.947, 0.207 ms] [completed optimizing 0x0d4e08293465 <JSFunction addTwo (sfi = 0xd4e082932e9)> (target TURBOFAN)] [marking 0x0d4e08293421 <JSFunction (sfi = 0xd4e08293291)> for optimized recompilation, reason: hot and stable] [compiling method 0x0d4e08293421 <JSFunction (sfi = 0xd4e08293291)> (target TURBOFAN) using TurboFan OSR] [optimizing 0x0d4e08293421 <JSFunction (sfi = 0xd4e08293291)> (target TURBOFAN) - took 4.136, 12.252, 0.400 ms] 在這段程式碼中,我們執行了 100,000 次 + 操作,其中前 80,000 次是兩個整數相加,後 20,000 次是兩個字串相加。

通過跟蹤 V8 的優化記錄,我們可以可以看到輸出的第 4 行,程式碼第 10 行(第 80,001 次執行時)由於引數型別由整數變為字串,觸發了反優化操作。而當新的程式碼再次變為熱點程式碼後,程式碼又將重新被優化。

反優化時,V8 會迭代所有優化過的 JavaScript 函式,解除函式指向優化和反優化程式碼物件的連結。規模較大、優化較多的 JavaScript 函式將成為效能瓶頸。儘管 V8 對部分反優化步驟進行惰性處理(Lazy deoptimization),但反優化的開銷還是比較昂貴的,在實際編寫函式時要儘量避免觸發反優化。

優化之痛

TurboFan 的優化對效能帶來了很大的收益,但實際優化過程依賴型別反饋,邏輯十分複雜也不受控,因此非常容易產生各種漏洞和 Bug。

在實際生產環境中,我們團隊遇到過一個問題是 —— 使用者在開啟網頁一段時間後,服務的輪詢地址變成了另外的值。團隊內 LeuisKen 同學排查發現,在 v8 v6.7 版本可以穩定復現這個 Bug

一個可復現的程式碼如下(已進行脫敏處理): ```JavaScript var i = "abcdefg" var n = "comments" var t = "article"

for (let j = 0; j < 10000; j++) { var o = "/api/v1/".concat(t, "/").concat(n, "?commentId=").concat(i); if (j % 1000 === 0) { console.log(o); } } ``` image

從執行結果可以看到,對於同樣的邏輯,在程式執行重複執行的第 5001 和第 6001 次,分別輸出了截然不同的內容。

除此之外,文章末尾的擴充套件閱讀也收錄了兩篇深信服團隊關於 TurboFan 漏洞的分析,感興趣的同學可以看看。

7. Orinoco 與垃圾回收

當記憶體不再需要的時候,會被週期性執行的垃圾回收器回收。

V8 的垃圾回收主要有三個階段 1. 標記:確定存活/死亡物件 2. 清除:回收死亡物件所佔用的記憶體 3. 整理:壓縮、整理碎片記憶體

世代假說

世代假說(generational hypothesis),也稱為弱分代假說(weak generational hypothesis)。這個假說表明,大多數新生的物件在分配之後就會死亡,而老的物件通常傾向於在程式執行週期中永存。

V8 的垃圾回收基於世代假說,將記憶體分為新生代和老生代。

image 圖源 - Trash talk: the Orinoco garbage collector

如圖所示,新生代內部進一步細分為 Nursery 和 Intermediate 子世代(劃分只是邏輯上的)。新生物件會被分配到新生代的 Nursery 子世代。若物件在第一次垃圾回收中存活,它的標誌位將發生改變,進入邏輯上的 Intermediate 子世代,在物理儲存上仍存在於新生代中。如果該物件在下一次垃圾回收中再次存活,就會進入老生代。物件從新生代進入到老生代的過程叫做晉升(promotion)。

V8 在新生代和老生代中採用了不同的垃圾回收策略,使垃圾回收更有針對性、更加高效。同時,V8 對新生代和老生代的記憶體大小也進行了限制。

名稱 | 主要演算法 | 最大容量 -- | -- | -- 新生代 | Scavenge | 2 * 16MB(64位)/ 2 * 8MB(32位) 老生代 | 標記清除、標記整理 | 4096MB(64位)/ 2048MB(32 位)

需要注意的是,隨著記憶體增大,垃圾回收的次數會減少,但每次所需的時間也會增加,將會對應用的效能和響應能力產生負面影響,因此記憶體並不是越大越好。

新生代

新生代使用 Scavenge 演算法(一種複製演算法),其核心思想是以空間換時間。

V8 將新生代拆分為大小相同的兩個半空間,分別稱為 Form 空間 和 To 空間。垃圾回收時,V8 會檢查 From 空間中的存活物件,將這些物件複製到 To 空間。當所有存活物件都移動到 To 空間後,V8 將直接釋放 From 空間。每次完成複製後,From 和 To 空間的位置將發生互換。

image

image

當一個物件經過一次複製依然存活,該物件將被移動到老生代,這個過程稱為晉升。

老生代

根據世代假說,老生代的物件傾向於在程式執行的生命週期中永存,即它們很少需要被回收。這意味著,在老生代使用複製演算法是低效的。V8 在老生代中使用了標記清除和標記整理演算法進行垃圾回收。

標記清除(Mark-Sweep)

標記清除的原理十分簡單。垃圾回收器從根節點開始,標記根直接引用的物件,然後遞迴標記這些物件的直接引用物件。物件的可達性將作為是否“存活”的依據。

image

標記清除演算法所花費的時間與存活物件的數量成正比。

標記整理(Mark-Compact)

標記整理演算法是複製演算法和標記清除演算法的結合。

當我們進行標記清除後,就可能產生記憶體碎片,這些碎片對我們程式進行記憶體分配是不利的。

舉個極端的例子,在下圖中,藍色的物件是需要我們分配記憶體的新物件,在記憶體整理之前,所有的碎片空間(淺色部分)都無法容納完整的物件。而在記憶體整理之後,碎片空間被合併成一個大的空間,也能容納下這個新物件。

image

標記整理演算法的優缺點都十分明顯。它的優點是,能夠讓堆利用更加充分有效。它的缺點是,需要額外的掃描時間和物件移動時間,並且花費的時間與堆的大小成正比。

關於標記

本小節圖源 Concurrent marking in V8

V8 採用三色標記(Tri-color marking)法來識別記憶體垃圾,三種顏色通過兩個標誌位來區分,即白色(00)、灰色(10)、黑色(11)。

最初,所有物件都是白色的。標記將從根節點出發,每遍歷到一個節點,便將該節點變為灰色。 image

如果某個灰色節點的所有直接子節點都遍歷完成,該灰色節點將變為黑色。 image

如果不再有新的灰色節點,則標記結束,剩餘的白色節點不可訪問,可以被安全回收。 image

出於效能優化的考慮,V8 針對垃圾回收也做了很多優化(下一小節將展開介紹),可能導致垃圾回收的同時又分配新記憶體的情況。為了避免記憶體訪問衝突,V8 中實現了寫屏障(Write Barrier)機制。寫屏障的主要工作原理是確保黑色節點不能指向白色節點,如果在黑色節點下分配子節點,該子節點將強制從白色變為灰色。

垃圾回收過程的優化策略

本小節圖源 Trash talk: the Orinoco garbage collector

執行垃圾回收時,不可避免會暫停 JavaScript 的執行。另一方面,為了頁面流暢執行,我們通常希望頁面能以每秒 60 幀的幀率執行,即每幀約 16ms 渲染間隔。這意味著如果在垃圾回收加上程式碼執行時間超過 16ms,使用者將感受到卡頓的情況。

Orinoco 利用了並行、增量和併發的技術進行垃圾回收,以釋放主執行緒的壓力,使其有更多的時間用於正常的 JavaScript 程式碼執行。

並行是指將垃圾回收任務分配成工作量大致相等的若干任務,交給主執行緒和輔助執行緒同時執行。由於執行過程沒有 JavaScript 執行,所以實現較為簡單,只需確保執行緒之間進行同步即可。

image

增量是指主執行緒將原本大量、集中的垃圾回收任務進行拆分,少量、多次間歇性地執行。

image

併發是指主執行緒保持 JavaScript 執行不中斷,輔助執行緒完全在後臺執行垃圾回收。由於涉及主執行緒和輔助執行緒的讀寫競爭,是三種策略中最複雜的一種。

image

在新生代中,V8 採用的是並行的 Scavenge 演算法。 image

在老生代中,V8 採用的是併發標記,並行整理,併發回收的策略。

image


兩年前的文章裡還提到了一個社群中關於最大保留空間的計算“錯誤”,由於目前鮮少見到相關討論及篇幅限制,不再贅述,感興趣的同學可以自行查閱。

8. 更快的非優化編譯器 Sparkplug

在沒有足夠的型別反饋之前,TurboFan 無法提前進入優化;同時過早優化也可能導致優化了非熱點程式碼,造成資源浪費。另一方面,如果一直使用 Ignition 的位元組碼,又意味著程式碼執行效率不高。為了解決這個問題。V8 在 v9.1 引入了非優化編譯器 Sparkplug,它可以直接將位元組碼不經優化生成彙編程式碼。

注意這裡的“優化”,圍繞的點是 —— “是否通過型別反饋進行推測”。實際上,相比與 Ignition 位元組碼,Sparkplug 生成的彙編程式碼,在效能上是優化了的。

image 圖源 - Sparkplug, the new lightning-fast V8 baseline JavaScript compiler

Sparkplug 不會像大多數編譯器那樣生成任何中間表示,它可以看做是一個從 Ignition 位元組碼到 CPU 位元組碼的 “轉譯器”。它的編譯器是一個 for 迴圈巢狀 switch 語句,負責把每個位元組碼分配到與之對應的、固定的程式碼生成函式。

Sparkplug 維護了一個與 Ignition 直譯器相容的棧幀,每當直譯器儲存一個暫存器值時,Sparkplug 也會同步儲存,直接反映直譯器的行為。這樣不僅可以簡化 Sparkplug 編譯,加快編譯速度,同時它與系統其餘部分的整合也幾乎沒有成本。另外,這也使得 OSR(On-Stack Replacement,棧替換,一種替換正在執行的函式棧幀的技術)的實現變得簡單。

Sparkplug 在設計上儘可能多地複用現有的機制(如內建函式、巨集彙編、堆疊幀),也儘可能減少體系結構相關的程式碼。同時,由於 Sparkplug 與上下文無關,因此程式碼可以被快取,也能跨頁面共享。

除此之外,由於 Sparkplug 應用了與 Ignition 相同的型別反饋策略,因此生成的 TurboFan 優化程式碼也是等效的。

9. WebAssembly 基線編譯器 Liftoff

Liftoff 是在 V8 v6.9 中啟用,在 V8 v8.5 中全平臺支援的 WebAssembly(以下簡稱 WASM)基線編譯器。

Liftoff 的目標是通過儘可能快地生成程式碼來減少 WASM 應用的啟動時間。

對於一段 WASM 程式碼,Liftoff 只會迭代遍歷一次程式碼,在解碼和驗證的同時立即為每個 WASM 指令生成機器程式碼(配合 Streaming API,WASM 程式碼也可以與 JavaScript 程式碼一樣實現邊下載邊編譯)。雖然 Lifoff 的執行速度非常快(大約每秒處理 10M),但幾乎沒有優化空間。

image 圖源 - Liftoff: a new baseline compiler for WebAssembly in V8

從上圖可以看到,Liftoff 程式碼生成無需通過生成 IR,但同時也少了優化的可能。

由於 WASM 是靜態型別的,不需要通過型別反饋生成優化程式碼。因此,在 Liftoff 編譯完成後,V8 會使用 TurboFan 重新編譯所有函式,由於 TurboFan 編譯時會對程式碼進行編譯優化,應用更好的暫存器分配策略,從而可以顯著加快程式碼執行速度。

每當一個函式在 TurboFan 中完成編譯,將立即替換掉 Liftoff 編譯的相同函式。之後該函式的所有呼叫將使用 TurboFan 編譯的程式碼(與 Sparkplug 替換 Ignition 不同,這個過程不是 OSR)。對於大型模組來說,V8 可能需要 30 秒到 1 分鐘才能將該模組完全編譯。

image 圖源 - CovalenceConf 2019: Bytecode Adventures with WebAssembly and V8

如果 WASM 模組使用 WebAssembly.compileStreaming 載入,那麼 TurboFan 生成的機器碼將被快取。當使用同一個 URL 再次獲取同一個 WASM 模組時(服務端需返回 304 Not Modified),該模組不會被編譯,而是從快取中載入

順便說一下,目前,在 V8 中,WASM 模組最多可使用 4GB 的記憶體。

10. 程式碼快取

在 Chrome 瀏覽器中有很多功能都或多或少影響了 JavaScript 的執行過程,其中一個便是程式碼快取(Code Caching),該功能在 V8 v6.6 版本中開啟。

在使用者訪問相同頁面,且該頁面關聯的指令碼檔案沒有任何改動的情況下,程式碼快取會讓 JavaScript 的載入和執行變得更快。

image

圖源:Code caching for JavaScript developers

程式碼快取被分為 cold、warm、hot 三個等級,存放在記憶體和磁碟中。磁碟上的程式碼快取由 Chrome 管理,以實現多個 V8 例項之前的快取共享。

  1. 使用者首次請求 JS 檔案時(即 cold run),Chrome 將下載該檔案並將其提供給 V8 進行編譯,並將檔案本身快取到磁碟中。

  2. 當用戶第二次請求這個檔案時(即 warm run),Chrome 將從瀏覽器快取中獲取該檔案,並將其再次交給 V8 進行編譯。在 warm run 階段編譯完成後,編譯的程式碼會被反序列化,作為元資料附加到快取的指令碼檔案中。

  3. 當用戶第三次請求這個 JS 檔案時(即 hot run),Chrome 從快取中獲取檔案和元資料,並將兩者交給 V8。V8 將跳過編譯階段,直接反序列化元資料。

相關連結

參考資料

擴充套件閱讀