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

本文做者:ThornWu(湯圓),HIGO 前端工程師。前端

做者有話說

「V8 是怎麼跑起來的」 系列是我學習 V8 過程當中的總結。從一年前正式成爲前端工程師開始,我便有意識地瞭解和學習 V8。我也發現,在技術社區中鮮有內容新鮮的、原創度高的中文資料,因而開始將我學習過程當中的總結分享出來。node

因爲工做繁忙,我已經半年沒有更新博客。這個系列的引子是 4 月寫的一篇 《V8 是怎麼跑起來的 —— V8 中的對象表示》,咱們經過使用 Chrome DevTools 驗證的方式介紹了 V8 中的對象表示。git

本文是這個系列真正意義的第一篇文章。文章的定位是這個系列的大綱,將按照 JavaScript 在 V8 中的執行流程,順序介紹每一步的操做,並澄清一個社區中流傳甚廣的 「錯誤」。本文不會過於深究其中的細節(後續篇章將展開),您能夠在評論中留下您想了解 V8 引擎的部分,也許下一篇選題會採納並優先介紹。github

img

祝閱讀愉快。算法

1. 爲何是 V8

Any application that can be written in JavaScript, will eventually be written in JavaScript.瀏覽器

相信不少的朋友都聽過前端界的一個著名定律,叫作 Atwood’s Law。2007 年,Jeff Atwood 提出 「全部能夠用 JavaScript 編寫的應用程序最終都會用 JavaScript 編寫」。轉眼 12 年過去,如今,咱們的確能夠看到,JavaScript 在瀏覽器端、服務端、桌面端、移動端、IoT 領域都發揮着做用。緩存

另外一方面,截至目前(2019-11-08),Chrome 在全平臺的市場佔有率已經達到 64.92%(數據來源:StatCounter)。做爲 Chrome 的 JavaScript 引擎,V8 在 Chrome 擴大市場佔有率方面也起到十分關鍵的做用。bash

做爲最強大的 JavaScript 引擎之一,V8 一樣是無處不在。在瀏覽器端,它支撐着 Chrome 以及衆多 Chromium 內核的瀏覽器運行。在服務端,它是 Node.js 及 Deno 框架的執行環境。在桌面端和 IoT 領域,也一樣有 V8 的一席之地。前端工程師

2. 關於 V8 的知識點

V8 是使用 C++ 編寫的高性能 JavaScriptWebAssembly 引擎,支持包括咱們熟悉的 ia3二、x6四、arm 在內的八種處理器架構。數據結構

V8 的發佈週期

  • 大約每隔六週,就會有一個新的 V8 版本推出
  • V8 版本與 Chrome 版本對應,如 V8 v7.8 對應 Chrome 78

V8 的競品

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

V8 的重要部件

  • Ignition(基線編譯器)
  • TurboFan(優化編譯器)
  • Orinoco(垃圾回收器)
  • Liftoff(WebAssembly 基線編譯器)

Liftoff 是從 V8 6.8 開始啓用的針對 WebAssembly 的基線編譯器。儘管 6.8 版本是在 2018 年 8 月推出的,但目前社區上有不少在這個時間後發佈的介紹 V8 的文章尚未說起 Liftoff。文章中是否包含 Liftoff 也能夠做爲文章內容是否陳舊的標誌。

因爲 WebAssembly 不屬於本文的討論範圍,下文將省略關於 Liftoff 的介紹。

3. V8 的 JavaScript 執行管道

早期 V8 執行管道由基線編譯器 Full-Codegen 與優化編譯器 CrankShaft 組成。(V8 執行管道通過屢次調整,本文只選取早期執行管道中較爲關鍵的一個階段,對執行管道演進過程感興趣的同窗能夠經過 V8 相關演講進行了解)。

img

其中,基線編譯器更注重編譯速度,而優化編譯器更注重編譯後代碼的執行速度。綜合使用基線編譯器和優化編譯器,使 JavaScript 代碼擁有更快的冷啓動速度,在優化後擁有更快的執行速度。

這個架構存在諸多問題,例如,Crankshaft 只能優化 JavaScript 的一個子集;編譯管道中層與層之間缺少隔離,在某些狀況下甚至須要同時爲多個處理器架構編寫彙編代碼等等。

爲了解決架構混亂和擴展困難的問題,通過多年演進,V8 目前造成了由解析器、基線編譯器 Ignition 和優化編譯器 TurboFan 組成的 JavaScript 執行管道。

img

解析器將 JavaScript 源代碼轉換成 AST,基線編譯器將 AST 編譯爲字節碼,當代碼知足必定條件時,將被優化編譯器從新編譯生成優化的字節碼。

這裏咱們不得不提一下分層思想。在執行管道改進的過程當中,經過引入 IR(Intermediate representation,中間表示),有效地提高了系統可擴展性,下降了關聯模塊的耦合度及系統的複雜度。

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

下面咱們將結合一段代碼,分析 JavaScript 在 V8 中是如何進行處理的。

// example1.js
function addTwo(a, b) {
  return a + b
}
複製代碼

4. 解析器與 AST

解析代碼須要時間,因此 JavaScript 引擎會盡量避免徹底解析源代碼文件。另外一方面,在一次用戶訪問中,頁面中會有不少代碼不會被執行到,好比,經過用戶交互行爲觸發的動做。

正由於如此,全部主流瀏覽器都實現了惰性解析(Lazy Parsing)。解析器沒必要爲每一個函數生成 AST(Abstract Syntax tree,抽象語法樹),而是能夠決定「預解析」(Pre-parsing)或「徹底解析」它所遇到的函數。

預解析會檢查源代碼的語法並拋出語法錯誤,但不會解析函數中變量的做用域或生成 AST。徹底解析則將分析函數體並生成源代碼對應的 AST 數據結構。相比正常解析,預解析的速度快了 2 倍。

生成 AST 主要通過兩個階段:分詞和語義分析。AST 旨在經過一種結構化的樹形數據結構來描述源代碼的具體語法組成,經常使用於語法檢查(靜態代碼分析)、代碼混淆、代碼優化等。

咱們能夠藉助 AST Explorer 工具生成 JavaScript 代碼的 AST。

// example1.js
function addTwo(a, b) {
  return a + b
}
複製代碼

img

須要注意的是,上圖僅描述 AST 的大體結構。V8 有一套本身的 AST 表示方式,生成的 AST 結構有所差別。

5. 基線編譯器 Ignition 與字節碼

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

字節碼是機器碼的抽象。若是字節碼的設計與物理 CPU 的計算模型相同,那麼將字節碼編譯成機器代碼就會更加容易。這就是爲何解釋器一般是寄存器或堆棧機器。Ignition 是一個帶有累加器的寄存器。(《Understanding V8’s Bytecode》

和以前的基線編譯器 Full-Codegen 相比,Ignition 生成的是體積更小的字節碼(Full-Codegen 生成的是機器碼)。字節碼能夠直接被優化編譯器 TurboFan 用於生成圖(TurboFan 對代碼的優化基於圖),避免優化編譯器在優化代碼時須要對 JavaScript 源代碼從新進行解析。

使用 d8 工具(V8 的開發者 Shell,可經過編譯 V8 源碼獲得,編譯流程請參照《Building V8 with GN》)能夠查看 Ignition 編譯生成的字節碼。

d8 --print-bytecode example1.js
複製代碼
[generated bytecode for function:  (0x2d5c6af1efe9 <SharedFunctionInfo>)]
Parameter count 1
Register count 3
Frame size 24
         0x2d5c6af1f0fe @    0 : 12 00             LdaConstant [0]
         0x2d5c6af1f100 @    2 : 26 fb             Star r0
         0x2d5c6af1f102 @    4 : 0b                LdaZero 
         0x2d5c6af1f103 @    5 : 26 fa             Star r1
         0x2d5c6af1f105 @    7 : 27 fe f9          Mov <closure>, r2
         0x2d5c6af1f108 @   10 : 61 2c 01 fb 03    CallRuntime [DeclareGlobals], r0-r2
         0x2d5c6af1f10d @   15 : a7                StackCheck 
         0x2d5c6af1f10e @   16 : 0d                LdaUndefined 
         0x2d5c6af1f10f @   17 : ab                Return 
Constant pool (size = 1)
0x2d5c6af1f0b1: [FixedArray] in OldSpace
 - map: 0x2d5c38940729 <Map>
 - length: 1
           0: 0x2d5c6af1f021 <FixedArray[4]>
Handler Table (size = 0)
複製代碼

Ignition 中全部的字節碼操做符能夠在 V8 源碼 中找到,感興趣的同窗能夠自行查看。

6. 優化編譯器 TurboFan 與優化和去優化

編譯器須要考慮的函數輸入類型變化越少,生成的代碼就越小、越快。

衆所周知,JavaScript 是弱類型語言。ECMAScript 標準中有大量的多義性和類型判斷,所以經過基線編譯器生成的代碼執行效率低下。

舉個例子,+ 運算符的一個操做數就多是整數、浮點數、字符串、布爾值以及其它的引用類型,更別提它們之間的各類組合(能夠感覺一下 ECMAScript 標準中對於 + 的定義)。

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 引入了類型反饋技術。在進行運算的時候,V8 使用類型反饋對全部參數進行動態檢查。

簡單來講,對於重複執行的代碼,若是屢次執行都傳入類型相同的參數,那麼 V8 會假設以後每一次執行的參數類型也是相同的,並對代碼進行優化。優化後的代碼中會保留基本的類型檢查。若是以後的每次執行參數類型未改變,V8 將一直執行優化過的代碼。而當以後某一次執行時傳入的參數類型發生變化時,V8 將會「撤銷」以前的優化操做,這一步稱爲「去優化」(Deoptimization)。

下面咱們稍微修改一下上面的代碼,分析其在 V8 中的優化過程。

// example2.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 example2.js
複製代碼
[marking 0x2ecfb2a5f229 <JSFunction (sfi = 0x2ecfb2a5f049)> for optimized recompilation, reason: hot and stable]
[compiling method 0x2ecfb2a5f229 <JSFunction (sfi = 0x2ecfb2a5f049)> using TurboFan OSR]
[optimizing 0x2ecfb2a5f229 <JSFunction (sfi = 0x2ecfb2a5f049)> - took 5.268, 5.305, 0.023 ms]
[deoptimizing (DEOPT soft): begin 0x2ecfb2a5f229 <JSFunction (sfi = 0x2ecfb2a5f049)> (opt #0) @2, FP to SP delta: 96, caller sp: 0x7ffee48218c8]
            ;;; deoptimize at <example2.js:10:5>, Insufficient type feedback for call
  reading input frame  => bytecode_offset=80, args=1, height=5, retval=0(#0); inputs:
      0: 0x2ecfb2a5f229 ;  [fp -  16]  0x2ecfb2a5f229 <JSFunction (sfi = 0x2ecfb2a5f049)>
      1: 0x2ecfbcf815c1 ;  [fp +  16]  0x2ecfbcf815c1 <JSGlobal Object>
      2: 0x2ecfb2a418c9 ;  [fp -  80]  0x2ecfb2a418c9 <NativeContext[253]>
      3: 0x2ecf2a140d09 ; (literal  4) 0x2ecf2a140d09 <Odd Oddball: optimized_out>
      4: 0x000000027100 ; rcx 80000
      5: 0x2ecfb2a5f299 ; (literal  6) 0x2ecfb2a5f299 <JSFunction addTwo (sfi = 0x2ecfb2a5f0b1)>
      6: 0x2ecfb2a5efd1 ; (literal  7) 0x2ecfb2a5efd1 <String[#5]: hello>
      7: 0x2ecfb2a5efe9 ; (literal  8) 0x2ecfb2a5efe9 <String[#5]: world>
      8: 0x2ecf2a140d09 ; (literal  4) 0x2ecf2a140d09 <Odd Oddball: optimized_out>
  translating interpreted frame  => bytecode_offset=80, variable_frame_size=48, frame_size=104
    0x7ffee48218c0: [top +  96] <- 0x2ecfbcf815c1 <JSGlobal Object> ;  stack parameter (input #1)
    -------------------------
    0x7ffee48218b8: [top +  88] <- 0x00010bd36b5a ;  caller's pc 0x7ffee48218b0: [top + 80] <- 0x7ffee48218d8 ; caller's fp
    0x7ffee48218a8: [top +  72] <- 0x2ecfb2a418c9 <NativeContext[253]> ;  context (input #2)
    0x7ffee48218a0: [top +  64] <- 0x2ecfb2a5f229 <JSFunction (sfi = 0x2ecfb2a5f049)> ;  function (input #0)
    0x7ffee4821898: [top +  56] <- 0x2ecfb2a5f141 <BytecodeArray[99]> ;  bytecode array
    0x7ffee4821890: [top +  48] <- 0x00000000010a <Smi 133> ;  bytecode offset
    -------------------------
    0x7ffee4821888: [top +  40] <- 0x2ecf2a140d09 <Odd Oddball: optimized_out> ;  stack parameter (input #3)
    0x7ffee4821880: [top +  32] <- 0x000000027100 <Smi 80000> ;  stack parameter (input #4)
    0x7ffee4821878: [top +  24] <- 0x2ecfb2a5f299 <JSFunction addTwo (sfi = 0x2ecfb2a5f0b1)> ;  stack parameter (input #5)
    0x7ffee4821870: [top +  16] <- 0x2ecfb2a5efd1 <String[#5]: hello> ; stack parameter (input #6)
    0x7ffee4821868: [top +   8] <- 0x2ecfb2a5efe9 <String[#5]: world> ; stack parameter (input #7)
    0x7ffee4821860: [top +   0] <- 0x2ecf2a140d09 <Odd Oddball: optimized_out> ;  accumulator (input #8)
[deoptimizing (soft): end 0x2ecfb2a5f229 <JSFunction (sfi = 0x2ecfb2a5f049)> @2 => node=80, pc=0x00010bd394e0, caller sp=0x7ffee48218c8, took 0.331 ms]
[marking 0x2ecfb2a5f229 <JSFunction (sfi = 0x2ecfb2a5f049)> for optimized recompilation, reason: hot and stable]
[marking 0x2ecfb2a5f299 <JSFunction addTwo (sfi = 0x2ecfb2a5f0b1)> for optimized recompilation, reason: small function]
[compiling method 0x2ecfb2a5f299 <JSFunction addTwo (sfi = 0x2ecfb2a5f0b1)> using TurboFan]
[compiling method 0x2ecfb2a5f229 <JSFunction (sfi = 0x2ecfb2a5f049)> using TurboFan OSR]
[optimizing 0x2ecfb2a5f229 <JSFunction (sfi = 0x2ecfb2a5f049)> - took 0.161, 0.441, 0.018 ms]
[optimizing 0x2ecfb2a5f299 <JSFunction addTwo (sfi = 0x2ecfb2a5f0b1)> - took 0.096, 0.231, 0.007 ms]
[completed optimizing 0x2ecfb2a5f299 <JSFunction addTwo (sfi = 0x2ecfb2a5f0b1)>]
複製代碼

在這段代碼中,咱們執行了 100,000 次 + 操做,其中前 80,000 次是兩個整數相加,後 20,000 次是兩個字符串相加。

經過跟蹤 V8 的優化記錄,咱們能夠能夠看到,代碼第 10 行(即第 80,001 次執行時)因爲參數類型由整數變爲字符串,觸發了去優化操做。

須要注意的是,去優化的開銷昂貴,在實際編寫函數時要儘可能避免觸發去優化。

7. 垃圾回收

當內存再也不須要的時候,會被週期性運行的垃圾回收器回收。

任何垃圾回收器都有一些必須按期完成的基本任務。

  1. 肯定存活/死亡對象
  2. 回收/再利用死亡對象所佔用的內存
  3. 壓縮/整理內存(可選)

V8 的垃圾回收主要有三個階段:標記、清除和整理。

世代假說

世代假說(generational hypothesis),也稱爲弱分代假說(weak generational hypothesis)。這個假說代表,大多數新生的對象在分配以後就會死亡(「用後即焚」),而老的對象一般傾向於永生。

V8 的垃圾回收基於世代假說,將內存分爲新生代和老生代。

圖源: V8 博客

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

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

名稱 算法 大小
新生代 Parallel Scavenge 算法 32MB(64位)/ 16MB(32位)
老生代 標記清除、標記整理算法 1400MB(64位)/ 700MB(32 位)

須要注意的是,隨着內存增大,垃圾回收的次數會減小,但每次所需的時間也會增長,將會對應用的性能和響應能力產生負面影響,所以內存並非越大越好。

新生代

V8 使用 Parallel Scavenge(並行清理)算法,它與 Halstead 算法相似(在 V8 v6.2 版本以前使用的是類 Cheney 算法),其核心是複製算法。

複製算法是一種以空間換時間的方式。

V8 將新生代拆分爲大小相同的兩個半空間,分別稱爲 Form 空間 和 To 空間。垃圾回收時,V8 會檢查 From 空間中的存活對象,將這些對象複製到 To 空間。以後,V8 將直接釋放死亡對象所對應的空間。每次完成複製後,From 和 To 的位置將發生互換。

當一個對象通過一次複製依然存活,該對象將被移動到老生代,這個過程稱爲晉升。

老生代

根據世代假說,老生代的對象傾向於永生,即它們不多須要被回收,這意味着在老生代使用複製算法是不可行的。V8 在老生代中使用了標記清除和標記整理算法進行垃圾回收。

標記清除(Mark-Sweep)

標記清除已經誕生了半個多世紀。它的算法原理十分簡單。垃圾回收器從根節點開始,標記根直接引用的對象,而後遞歸標記這些對象的直接引用對象。對象的可達性做爲是否「存活」的依據。

img

標記清除算法所花費的時間與活動對象的數量成正比。

標記整理(Mark-Compact)

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

當咱們進行標記清除以後,就可能會產生內存碎片,這些碎片對咱們程序進行內存分配時不利的。

舉個極端的例子,在下圖中,藍色的對象是須要咱們分配內存的新對象,在內存整理以前,全部的碎片空間都沒法容納完整的對象,而在內存整理以後,碎片空間被合併成一個大的空間,也能容納下新對象。

img

標記整理算法的優缺點都十分明顯。它的優勢是,可以讓堆利用更加充分有效。它的缺點是,它須要額外的掃描時間和對象移動時間,而且花費的時間與堆的大小成正比。

最大保留空間 —— 一個社區流傳已久的 「錯誤」

V8 會在堆內存中爲新老生代預留空間,引伸出一個最大保留空間(Max Reserved)的概念。影響最大保留空間大小的因素主要有 max_old_generation_size_(老生代最大空間)和 max_semi_space_size_(新生代最大半空間)。其中,前者在 Node 中能夠經過 --max-old-space-size 指定。

社區中流傳已久的計算方式是 「最大保留空間 = 4 * 新生代最大半空間 + 老生代最大空間」,其源頭應該是來自樸靈老師的《深刻淺出 Node.js》。但從這本書出版後(2013 年 12 月)到如今,最大保留空間的計算方式實際上進行了兩次調整。

5.1.277 及以前版本(《深刻淺出 Node.js》對應的版本)

// Returns the maximum amount of memory reserved for the heap. For
// the young generation, we reserve 4 times the amount needed for a
// semi space. The young generation consists of two semi spaces and
// we reserve twice the amount needed for those in order to ensure
// that new space can be aligned to its size.
intptr_t MaxReserved() {
  return 4 * reserved_semispace_size_ + max_old_generation_size_;
}
複製代碼

5.1.278 版本

// Returns the maximum amount of memory reserved for the heap.
intptr_t MaxReserved() {
  return 2 * max_semi_space_size_ + max_old_generation_size_;
}
複製代碼

7.4.137 版本

size_t Heap::MaxReserved() {
  const size_t kMaxNewLargeObjectSpaceSize = max_semi_space_size_;
  return static_cast<size_t>(2 * max_semi_space_size_ +
                             kMaxNewLargeObjectSpaceSize +
                             max_old_generation_size_);
}
複製代碼

簡單來講,這兩次調整在數值上是將 「新生代最大半空間」 的係數從 4 倍變爲 2 倍再變爲 3 倍。

根據 Node.js 的 Release 記錄,以上 V8 版本與 Node.js 版本的對應關係爲:

V8 版本 Node.js 版本
5.1.277 及以前版本 6.4.0 及以前版本
5.1.278 - 7.4.136 6.4.0 以後,12.0.0 以前版本
7.4.137 及以後版本 12.0.0 及以後版本

考慮到 Node.js 6.4.0 版本發佈時間較早,爲 2016 年 8 月,目前 LTS 版本也再也不維護,能夠合理地推斷目前使用比例較大的計算方式爲第二種和第三種。然而,社區中的資料鮮有說起這兩次變動的(本人只找到一篇知乎專欄裏提到了第二種計算方式),與此同時仍有不少新發布的文章仍然使用第一種計算方式而沒有註明 Node.js 版本,容易讓讀者認爲最大保留空間計算方式沒有發生變化,大量過期的信息顯然已經形成了 「錯誤」。

8. 代碼緩存

在 Chrome 瀏覽器中有不少功能都或多或少影響了 JavaScript 的執行過程,其中一個功能是代碼緩存(Code Caching)。

在用戶訪問相同的頁面,而且該頁面關聯的腳本文件沒有任何改動的狀況下,代碼緩存技術會讓 JavaScript 的加載和執行變得更快。

圖源:V8 博客

代碼緩存被分爲 cold、warm、hot 三個等級。

  1. 用戶首次請求 JS 文件時(即 cold run),Chrome 將下載該文件並將其提供給 V8 進行編譯,並將該文件緩存到磁盤中。

  2. 當用戶第二次請求這個 JS 文件時(即 warm run),Chrome 將從瀏覽器緩存中獲取該文件,並將其再次交給 V8 進行編譯。在 warm run 階段編譯完成後,編譯的代碼會被反序列化,做爲元數據附加到緩存的腳本文件中。

  3. 當用戶第三次請求這個 JS 文件時(即 hot run),Chrome 從緩存中獲取文件和元數據,並將二者交給 V8。V8 將跳過編譯階段,直接反序列化元數據。

相關連接

參考資料

招賢納士

HIGO 是中國有名的全球時尚買手店,由美麗說創始人徐易容先生親自帶領,曾獨家冠名跑男第三季。咱們的夢想是讓中國擁有全世界最好的美感和設計。

咱們讚頌喜歡創造新東西的人們。他們熱誠、挑剔、有趣。咱們相信,他們會讓世界變得更美好。咱們是這樣的一羣人,若是你也是,歡迎你加入!

簡歷請投遞至 wenchenghua@higohappy.com

相關文章
相關標籤/搜索