1. 程式人生 > 實用技巧 >淺析瀏覽器是如何工作的:Chrome V8讓你更懂JavaScript(一)

淺析瀏覽器是如何工作的:Chrome V8讓你更懂JavaScript(一)

  V8 是由 Google 開發的開源 JavaScript 引擎,也被稱為虛擬機器,模擬實際計算機各種功能來實現程式碼的編譯和執行。

一、為什麼需要 JavaScript 引擎

  我們寫的 JavaScript 程式碼直接交給瀏覽器或者 Node 執行時,底層的 CPU 是不認識的,也沒法執行。CPU 只認識自己的指令集,指令集對應的是彙編程式碼。寫彙編程式碼是一件很痛苦的事情。並且不同型別的 CPU 的指令集是不一樣的,那就意味著需要給每一種 CPU 重寫彙編程式碼。

  JavaScirpt 引擎可以將 JS 程式碼編譯為不同 CPU(Intel, ARM 以及 MIPS 等)對應的彙編程式碼,這樣我們就不需要去翻閱每個 CPU 的指令集手冊來編寫彙編程式碼了。當然,JavaScript 引擎的工作也不只是編譯程式碼,它還要負責執行程式碼、分配記憶體以及垃圾回收。

1000100111011000  // 機器指令
mov ax,bx         // 彙編指令

  資料拓展:組合語言入門教程【阮一峰】|理解 V8 的位元組碼「譯」、https://zhuanlan.zhihu.com/p/28590489

1、熱門 JavaScript 引擎

  • V8 (Google),用 C++編寫,開放原始碼,由 Google 丹麥開發,是 Google Chrome 的一部分,也用於 Node.js。
  • JavaScriptCore (Apple),開放原始碼,用於 webkit 型瀏覽器,如 Safari ,2008 年實現了編譯器和位元組碼直譯器,升級為了 SquirrelFish。蘋果內部代號為“Nitro”的 JavaScript 引擎也是基於 JavaScriptCore 引擎的。
  • Rhino,由 Mozilla 基金會管理,開放原始碼,完全以 Java 編寫,用於 HTMLUnit
  • SpiderMonkey (Mozilla),第一款 JavaScript 引擎,早期用於 Netscape Navigator,現時用於 Mozilla Firefox。
  • Chakra (JScript 引擎),用於 Internet Explorer。
  • Chakra (JavaScript 引擎),用於 Microsoft Edge。
  • KJS,KDE 的 ECMAScript/JavaScript 引擎,最初由哈里·波頓開發,用於 KDE 專案的 Konqueror 網頁瀏覽器中。
  • JerryScript — 三星推出的適用於嵌入式裝置的小型 JavaScript 引擎。
  • 其他:Nashorn、QuickJS 、 Hermes

2、V8 引擎

  Google V8 引擎是用 C ++編寫的開源高效能 JavaScript 和 WebAssembly 引擎,它已被用於 Chrome 和 Node.js 等。可以執行在 Windows 7+,macOS 10.12+和使用 x64,IA-32,ARM 或 MIPS 處理器的 Linux 系統上。V8 最早被開發用以嵌入到 Google 的開源瀏覽器 Chrome 中,第一個版本隨著第一版Chrome於 2008 年 9 月 2 日釋出。但是 V8 是一個可以獨立執行的模組,完全可以嵌入到任何 C ++應用程式中。著名的 Node.js( 一個非同步的伺服器框架,可以在服務端使用 JavaScript 寫出高效的網路伺服器 ) 就是基於 V8 引擎的,Couchbase, MongoDB 也使用了 V8 引擎。

  和其他 JavaScript 引擎一樣,V8 會編譯 / 執行 JavaScript 程式碼,管理記憶體,負責垃圾回收,與宿主語言的互動等。通過暴露宿主物件 ( 變數,函式等 ) 到 JavaScript,JavaScript 可以訪問宿主環境中的物件,並在指令碼中完成對宿主物件的操作。

3、什麼是 D8

  d8 是一個非常有用的除錯工具,你可以把它看成是 debug for V8 的縮寫。我們可以使用 d8 來檢視 V8 在執行 JavaScript 過程中的各種中間資料,比如作用域、AST、位元組碼、優化的二進位制程式碼、垃圾回收的狀態,還可以使用 d8 提供的私有 API 檢視一些內部資訊。

  具體安裝方法、除錯命令、除錯方法、內部方法就不多說了

4、V8 引擎的內部結構

  V8 是一個非常複雜的專案,有超過 100 萬行 C++程式碼。它由許多子模組構成,其中這 4 個模組是最重要的:

1)Parser:負責將 JavaScript 原始碼轉換為 Abstract Syntax Tree (AST)

2)Ignition:interpreter,即直譯器,負責將 AST 轉換為 Bytecode,解釋執行 Bytecode;同時收集 TurboFan 優化編譯所需的資訊,比如函式引數的型別;

  直譯器執行時主要有四個模組,記憶體中的位元組碼、暫存器、棧、堆。

  通常有兩種型別的直譯器,基於棧 (Stack-based)和基於暫存器 (Register-based),基於棧的直譯器使用棧來儲存函式引數、中間運算結果、變數等;基於暫存器的虛擬機器則支援暫存器的指令操作,使用暫存器來儲存引數、中間計算結果。通常,基於棧的虛擬機器也定義了少量的暫存器,基於暫存器的虛擬機器也有堆疊,其區別體現在它們提供的指令集體系。大多數直譯器都是基於棧的,比如 Java 虛擬機器,.Net 虛擬機器,還有早期的 V8 虛擬機器。基於堆疊的虛擬機器在處理函式呼叫、解決遞迴問題和切換上下文時簡單明快。而現在的 V8 虛擬機器則採用了基於暫存器的設計,它將一些中間資料儲存到暫存器中。

  基於暫存器的直譯器架構:

3)TurboFan:compiler,即編譯器,利用 Ignitio 所收集的型別資訊,將 Bytecode 轉換為優化的彙編程式碼;

4)Orinoco:garbage collector,垃圾回收模組,負責將程式不再需要的記憶體空間回收。

  其中,Parser(解析器),Ignition(直譯器) 以及 TurboFan (編譯器)可以將 JS 原始碼編譯為彙編程式碼,其流程圖如下:

  簡單地說,Parser 將 JS 原始碼轉換為 AST,然後 Ignition 將 AST 轉換為 Bytecode,最後 TurboFan 將 Bytecode 轉換為經過優化的 Machine Code(實際上是彙編程式碼)。

  • 如果函式沒有被呼叫,則 V8 不會去編譯它。
  • 如果函式只被呼叫 1 次,則 Ignition 將其編譯 Bytecode 就直接解釋執行了。TurboFan 不會進行優化編譯,因為它需要 Ignition 收集函式執行時的型別資訊。這就要求函式至少需要執行 1 次,TurboFan 才有可能進行優化編譯。
  • 如果函式被呼叫多次,則它有可能會被識別為熱點函式,且 Ignition 收集的型別資訊證明可以進行優化編譯的話,這時 TurboFan 則會將 Bytecode 編譯為 Optimized Machine Code(已優化的機器碼),以提高程式碼的執行效能。

  圖片中的紅色虛線是逆向的,也就是說Optimized Machine Code 會被還原為 Bytecode,這個過程叫做 Deoptimization。這是因為 Ignition 收集的資訊可能是錯誤的,比如 add 函式的引數之前是整數,後來又變成了字串。生成的 Optimized Machine Code 已經假定 add 函式的引數是整數,那當然是錯誤的,於是需要進行 Deoptimization。

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

add(3, 5);
add('3', '5');

  在執行 C、C++以及 Java 等程式之前,需要進行編譯,不能直接執行原始碼;但對於 JavaScript 來說,我們可以直接執行原始碼(比如:node test.js),它是在執行的時候先編譯再執行,這種方式被稱為即時編譯(Just-in-time compilation),簡稱為 JIT。因此,V8 也屬於 JIT 編譯器

5、V8 是怎麼執行一段 JavaScript 程式碼的

1)在 V8 出現之前,所有的 JavaScript 虛擬機器所採用的都是解釋執行的方式,這是 JavaScript 執行速度過慢的一個主要原因。而 V8 率先引入了即時編譯(JIT)的雙輪驅動的設計(混合使用編譯器和直譯器的技術),這是一種權衡策略,混合編譯執行和解釋執行這兩種手段,給 JavaScript 的執行速度帶來了極大的提升。V8 出現之後,各大廠商也都在自己的 JavaScript 虛擬機器中引入了 JIT 機制,所以目前市面上 JavaScript 虛擬機器都有著類似的架構。另外,V8 也是早於其他虛擬機器引入了惰性編譯、內聯快取、隱藏類等機制,進一步優化了 JavaScript 程式碼的編譯執行效率。

2)V8 執行一段 JavaScript 的流程圖:

3)V8 本質上是一個虛擬機器,因為計算機只能識別二進位制指令,所以要讓計算機執行一段高階語言通常有兩種手段:

  • 第一種是將高階程式碼轉換為二進位制程式碼,再讓計算機去執行;
  • 另外一種方式是在計算機安裝一個直譯器,並由直譯器來解釋執行。

4)解釋執行和編譯執行都有各自的優缺點,解釋執行啟動速度快,但是執行時速度慢,而編譯執行啟動速度慢,但是執行速度快。為了充分地利用解釋執行和編譯執行的優點,規避其缺點,V8 採用了一種權衡策略,在啟動過程中採用瞭解釋執行的策略,但是如果某段程式碼的執行頻率超過一個值,那麼 V8 就會採用優化編譯器將其編譯成執行效率更加高效的機器程式碼。

5)總結

  V8 執行一段 JavaScript 程式碼所經歷的主要流程包括:

  (1)初始化基礎環境;

  (2)解析原始碼生成 AST 和作用域;

  (3)依據 AST 和作用域生成位元組碼;

  (4)解釋執行位元組碼;

  (5)監聽熱點程式碼;

  (6)優化熱點程式碼為二進位制的機器程式碼;

  (7)反優化生成的二進位制機器程式碼。

二、一等公民與閉包

1、一等公民的定義

  在程式語言中,一等公民可以作為函式引數,可以作為函式返回值,也可以賦值給變數。

  如果某個程式語言的函式,可以和這個語言的資料型別做一樣的事情,我們就把這個語言中的函式稱為一等公民。例如,字串在幾乎所有程式語言中都是一等公民,字串可以做為函式引數,字串可以作為函式返回值,字串也可以賦值給變數。對於各種程式語言來說,函式就不一定是一等公民了,比如 Java 8 之前的版本。

  對於 JavaScript 來說,函式可以賦值給變數,也可以作為函式引數,還可以作為函式返回值,因此 JavaScript 中函式是一等公民。

2、動態作用域與靜態作用域

  • 如果一門語言的作用域是靜態作用域,那麼符號之間的引用關係能夠根據程式程式碼在編譯時就確定清楚,在執行時不會變。某個函式是在哪宣告的,就具有它所在位置的作用域。它能夠訪問哪些變數,那麼就跟這些變數綁定了,在執行時就一直能訪問這些變數。即靜態作用域可以由程式程式碼決定,在編譯時就能完全確定。大多數語言都是靜態作用域的。
  • 動態作用域(Dynamic Scope)。也就是說,變數引用跟變數宣告不是在編譯時就繫結死了的。在執行時,它是在執行環境中動態地找一個相同名稱的變數。在 macOS 或 Linux 中用的 bash 指令碼語言,就是動態作用域的。

3、閉包的三個基礎特性

  • JavaScript 語言允許在函式內部定義新的函式
  • 可以在內部函式中訪問父函式中定義的變數
  • 因為 JavaScript 中的函式是一等公民,所以函式可以作為另外一個函式的返回值
// 閉包(靜態作用域,一等公民,呼叫棧的矛盾體)
function foo() {
  var d = 20;
  return function inner(a, b) {
    const c = a + b + d;
    return c;
  };
}
const f = foo();

  關於閉包,在此不再贅述,在此主要談下閉包給 Chrome V8 帶來的問題及其解決策略。

4、惰性解析

  所謂惰性解析是指解析器在解析的過程中,如果遇到函式宣告,那麼會跳過函式內部的程式碼,並不會為其生成 AST 和位元組碼,而僅僅生成頂層程式碼的 AST 和位元組碼。

  在編譯 JavaScript 程式碼的過程中,V8 並不會一次性將所有的 JavaScript 解析為中間程式碼,這主要是基於以下兩點:

  • 首先,如果一次解析和編譯所有的 JavaScript 程式碼,過多的程式碼會增加編譯時間,這會嚴重影響到首次執行 JavaScript 程式碼的速度,讓使用者感覺到卡頓。因為有時候一個頁面的 JavaScript 程式碼很大,如果要將所有的程式碼一次性解析編譯完成,那麼會大大增加使用者的等待時間;
  • 其次,解析完成的位元組碼和編譯之後的機器程式碼都會存放在記憶體中,如果一次性解析和編譯所有 JavaScript 程式碼,那麼這些中間程式碼和機器程式碼將會一直佔用記憶體。

  基於以上的原因,所有主流的 JavaScript 虛擬機器都實現了惰性解析。

  閉包給惰性解析帶來的問題:上文的 d 不能隨著 foo 函式的執行上下文被銷燬掉。

5、預解析器

  V8 引入預解析器,比如當解析頂層程式碼的時候,遇到了一個函式,那麼預解析器並不會直接跳過該函式,而是對該函式做一次快速的預解析。

1)判斷當前函式是不是存在一些語法上的錯誤,發現了語法錯誤,那麼就會向 V8 丟擲語法錯誤;

2)檢查函式內部是否引用了外部變數,如果引用了外部的變數,預解析器會將棧中的變數複製到堆中,在下次執行到該函式的時候,直接使用堆中的引用,這樣就解決了閉包所帶來的問題。

6、V8 內部是如何儲存物件的:快屬性和慢屬性

// 下面的程式碼會輸出什麼:
// test.js
function Foo() {
  this[200] = 'test-200';
  this[1] = 'test-1';
  this[100] = 'test-100';
  this['B'] = 'bar-B';
  this[50] = 'test-50';
  this[9] = 'test-9';
  this[8] = 'test-8';
  this[3] = 'test-3';
  this[5] = 'test-5';
  this['D'] = 'bar-D';
  this['C'] = 'bar-C';
}
var bar = new Foo();

for (key in bar) {
  console.log(`index:${key}  value:${bar[key]}`);
}
//輸出:
// index:1  value:test-1
// index:3  value:test-3
// index:5  value:test-5
// index:8  value:test-8
// index:9  value:test-9
// index:50  value:test-50
// index:100  value:test-100
// index:200  value:test-200
// index:B  value:bar-B
// index:D  value:bar-D
// index:C  value:bar-C

  在ECMAScript 規範中定義了數字屬性應該按照索引值大小升序排列,字串屬性根據建立時的順序升序排列

  在這裡我們把物件中的數字屬性稱為排序屬性,在 V8 中被稱為 elements,字串屬性就被稱為常規屬性,在 V8 中被稱為 properties

  在 V8 內部,為了有效地提升儲存和訪問這兩種屬性的效能,分別使用了兩個線性資料結構來分別儲存排序屬性和常規屬性

  同時 v8 將部分常規屬性直接儲存到物件本身,我們把這稱為物件內屬性 (in-object properties),不過物件內屬性的數量是固定的,預設是 10 個

function Foo(property_num, element_num) {
  //新增可索引屬性
  for (let i = 0; i < element_num; i++) {
    this[i] = `element${i}`;
  }
  //新增常規屬性
  for (let i = 0; i < property_num; i++) {
    let ppt = `property${i}`;
    this[ppt] = ppt;
  }
}
var bar = new Foo(10, 10);

  可以通過 Chrome 開發者工具的 Memory 標籤,捕獲檢視當前的記憶體快照。通過增大第一個引數來檢視儲存變化。

  我們將儲存線上性資料結構中的屬性稱之為“快屬性”,因為線性資料結構中只需要通過索引即可以訪問到屬性,雖然訪問線性結構的速度快,但是如果從線性結構中新增或者刪除大量的屬性時,則執行效率會非常低,這主要因為會產生大量時間和記憶體開銷。

  因此,如果一個物件的屬性過多時,V8 就會採取另外一種儲存策略,那就是“慢屬性”策略,但慢屬性的物件內部會有獨立的非線性資料結構 (字典) 作為屬性儲存容器。所有的屬性元資訊不再是線性儲存的,而是直接儲存在屬性字典中。

  快屬性 - 線性資料結構 - 索引訪問快 - 但是新增刪除時,執行效率低

  慢屬性 - 字典 - 讀取效率低 - 提高了新增刪除時的執行效率

  v8 屬性儲存:

7、總結

  因為 JavaScript 中的物件是由一組組屬性和值組成的,所以最簡單的方式是使用一個字典來儲存屬性和值,但是由於字典是非線性結構,所以如果使用字典,讀取效率會大大降低。

  為了提升查詢效率,V8 在物件中添加了兩個隱藏屬性,排序屬性和常規屬性,element 屬性指向了 elements 物件,在 elements 物件中,會按照順序存放排序屬性。properties 屬性則指向了 properties 物件,在 properties 物件中,會按照建立時的順序儲存常規屬性。

  通過引入這兩個屬性,加速了 V8 查詢屬性的速度,為了更加進一步提升查詢效率,V8 還實現了內建內屬性的策略,當常規屬性少於一定數量時,V8 就會將這些常規屬性直接寫進物件中,這樣又節省了一箇中間步驟。

  但是如果物件中的屬性過多時,或者存在反覆新增或者刪除屬性的操作,那麼 V8 就會將線性的儲存模式降級為非線性的字典儲存模式,這樣雖然降低了查詢速度,但是卻提升了修改物件的屬性的速度。