Js是怎樣運行起來的?

前言

不知道你們有沒有想過這樣一個問題,咱們所寫的 JavaScript 代碼是怎樣被計算機認識而且執行的呢?這中間的過程具體是怎樣的呢?有的同窗可能已經知道,Js 是經過 Js 引擎運行起來的,那麼html

  • 什麼是 Js 引擎?
  • Js 引擎是怎樣編譯執行和優化 Js 代碼的?

Js 引擎有不少種,好比 Chrome 使用的 V8 引擎,Webkit 使用的是 JavaScriptCore,React Native 使用的是 Hermes。今天咱們主要來分析一下比較主流的 V8 引擎是怎樣運行 Js 的。前端

V8 引擎

在介紹 V8 引擎的概念以前,咱們先來回顧一下編程語言。編程語言能夠分爲機器語言、彙編語言、高級語言。編程

  • 機器語言:由 0 和 1 組成的二進制碼,對於人類來講是很難記憶的,還要考慮不一樣 CPU 平臺的兼容性。
  • 彙編語言:用更容易記憶的英文縮寫標識符代替二進制指令,但仍是須要開發人員有足夠的硬件知識。瀏覽器

  • 高級語言:更簡單抽象且不須要考慮硬件,可是須要更復雜、耗時更久的翻譯過程才能被執行。緩存

到了這裏咱們知道,高級語言必定要轉化爲機器語言才能被計算機執行,並且越高級的語言轉化的時間越久。高級語言又能夠分爲解釋型語言、編譯型語言。markdown

  • 編譯型語言:須要編譯器進行一次編譯,被編譯過的文件能夠屢次執行。如 C++、C 語言。
  • 解釋型語言:不須要事先編譯,經過解釋器一邊解釋一邊執行。啓動快,但執行慢。

咱們知道 JavaScript 是一門高級語言,而且是動態類型語言,咱們在定義一個變量時不須要關心它的類型,而且能夠隨意的修改變量的類型。而在像 C++這樣的靜態類型語言中,咱們必須提早聲明變量的類型而且賦予正確的值才行。也正是由於 JavaScript 沒有像 C++那樣能夠事先提供足夠的信息供編譯器編譯出更加低級的機器代碼,它只能在運行階段收集類型信息,而後根據這些信息進行編譯再執行,因此 JavaScript 也是解釋型語言。網絡

這也就意味着 JavaScript 要想被計算機執行,須要一個可以快速解析而且執行 JavaScript 腳本的程序,這個程序就是咱們平時所說的 JavaScript 引擎。這裏咱們給出 V8 引擎的概念:V8 是 Google 基於 C++ 編寫的開源高性能 Javascript 與 WebAssembly 引擎。用於 Google Chrome(Google 的開源瀏覽器) 以及 Node.js 等。架構

CPU 是如何執行機器指令的?

將高級語言轉化爲機器語言以後,CPU 又是怎樣執行的呢?咱們以一段 C 代碼爲例:編程語言

int main() {

    int x = 1;

    int y = 2;

    int z = x + y;

    return z;

}}
複製代碼

先來看一下以上代碼被轉換爲機器語言是什麼樣子。下圖左側是用十六進制表示的二進制機器碼,中間部分是彙編代碼,右側是指令的含義。函數

CPU 執行機器指令的流程

  • 首先程序在執行以前會被裝進內存。
  • 系統會將二進制代碼中的第一條指令的地址寫入到 PC 寄存器中。

  • CPU 根據 PC 寄存器中的地址,從內存中取出指令。
  • 將下一條指令的地址更新到 PC 寄存器中。

  • 分析當前取出指令,並識別出不一樣的類型的指令,以及各類獲取操做數的方法。

  • 加載指令:從內存中複製指定長度的內容到通用寄存器中,並覆蓋寄存器中原來的內容。

  • 存儲指令:將寄存器中的內容複製到內存某個位置,並覆蓋掉內存中的這個位置上原來的內容。

上圖中 movl 指令後面的 %ecx 就是寄存器地址,-8(%rbp) 是內存中的地址,這條指令的做用是將寄存器中的值拷貝到內存中。

  • 更新指令:複製兩個寄存器中的內容到 ALU 中,也能夠是一塊寄存器和一塊內存中的內容到 ALU 中,ALU 將兩個字相加,並將結果存放在其中的一個寄存器中,並覆蓋該寄存器中的內容。

...

  • 執行指令完畢,進入下一個 CPU 時鐘週期。

V8 引擎的編譯流水線

接下來咱們先從宏觀的角度來看一下 V8 是怎麼執行 JavaScript 代碼的,而後再對每一步進行分析。

  • 初始化基礎環境;
  • 解析源碼生成 AST 和做用域;
  • 依據 AST 和做用域生成字節碼;
  • 解釋執行字節碼;監聽熱點代碼;
  • ...

完整的分析一段 JavaScript 代碼是怎樣被執行的

一、初始化基礎環境

V8 執行 Js 代碼是離不開宿主環境的,V8 的宿主能夠是瀏覽器,也能夠是 Node.js。下圖是瀏覽器的組成結構,其中渲染引擎就是平時所說的瀏覽器內核,它包括網絡模塊,Js 解釋器等。當打開一個渲染進程時,就爲 V8 初始化了一個運行時環境。運行時環境爲 V8 提供了堆空間,棧空間、全局執行上下文、消息循環系統、宿主對象及宿主 API 等。V8 的核心是實現了 ECMAScript 標準,此外還提供了垃圾回收器等內容。

二、解析源碼生成 AST 和做用域

基礎環境準備好以後,接下來就能夠向 V8 提交要執行的 JavaScript 代碼了。首先 V8 會接收到要執行的 JavaScript 源代碼,不過這對 V8 來講只是一堆字符串,V8 並不能直接理解這段字符串的含義,它須要結構化這段字符串。

function add(x, y) {

  var z = x+y

  return z

}

console.log(add(1, 2))
複製代碼

好比針對如上源代碼,V8 首先經過解析器(parser)解析成以下的抽象語法樹 AST:

[generating bytecode for function: add] --- AST --- FUNC at 12 . KIND 0 . LITERAL ID 1 . SUSPEND COUNT 0 . NAME "add" . PARAMS . . VAR (0x7fa7bf8048e8) (mode = VAR, assigned = false) "x" . . VAR (0x7fa7bf804990) (mode = VAR, assigned = false) "y" . DECLS . . VARIABLE (0x7fa7bf8048e8) (mode = VAR, assigned = false) "x" . . VARIABLE (0x7fa7bf804990) (mode = VAR, assigned = false) "y" . . VARIABLE (0x7fa7bf804a38) (mode = VAR, assigned = false) "z" . BLOCK NOCOMPLETIONS at -1 . . EXPRESSION STATEMENT at 31 . . . INIT at 31 . . . . VAR PROXY local[0] (0x7fa7bf804a38) (mode = VAR, assigned = false) "z" . . . . ADD at 32 . . . . . VAR PROXY parameter[0] (0x7fa7bf8048e8) (mode = VAR, assigned = false) "x" . . . . . VAR PROXY parameter[1] (0x7fa7bf804990) (mode = VAR, assigned = false) "y" . RETURN at 37 . . VAR PROXY local[0] (0x7fa7bf804a38) (mode = VAR, assigned = false) "z" 複製代碼

V8 在生成 AST 的同時,還生成了 add 函數的做用域:

Global scope:

function add (x, y) { // (0x7f9ed7849468) (12, 47)

  // will be compiled

  // 1 stack slots

  // local vars:

  VAR y;  // (0x7f9ed7849790) parameter[1], never assigned

  VAR z;  // (0x7f9ed7849838) local[0], never assigned

  VAR x;  // (0x7f9ed78496e8) parameter[0], never assigned

}
複製代碼

在解析期間,全部函數體中聲明的變量和函數參數,都被放進做用域中,若是是普通變量,那麼默認值是 undefined,若是是函數聲明,那麼將指向實際的函數對象。在執行階段,做用域中的變量會指向堆和棧中相應的數據。

三、依據 AST 和做用域生成字節碼

生成了做用域和 AST 以後,V8 就能夠依據它們來生成字節碼了。AST 以後會被做爲輸入傳到字節碼生成器 (BytecodeGenerator),這是 Ignition 解釋器中的一部分,用於生成以函數爲單位的字節碼。

[generated bytecode for function: add (0x079e0824fdc1 <SharedFunctionInfo add>)] Parameter count 3 Register count 2 Frame size 16 0x79e0824ff7a @ 0 : a7 StackCheck 0x79e0824ff7b @ 1 : 25 02 Ldar a1 0x79e0824ff7d @ 3 : 34 03 00 Add a0, [0] 0x79e0824ff80 @ 6 : 26 fb Star r0 0x79e0824ff82 @ 8 : 0c 02 LdaSmi [2] 0x79e0824ff84 @ 10 : 26 fa Star r1 0x79e0824ff86 @ 12 : 25 fb Ldar r0 0x79e0824ff88 @ 14 : ab Return Constant pool (size = 0) Handler Table (size = 0) Source Position Table (size = 0) 複製代碼

四、解釋執行字節碼

和 CPU 執行二進制機器代碼相似:使用內存中的一塊區域來存放字節碼;使通用寄存器用來存放一些中間數據;PC 寄存器用來指向下一條要執行的字節碼;棧頂寄存器用來指向當前的棧頂的位置。

  • StackCheck 字節碼指令就是檢查棧是否達到了溢出的上限。
  • Ldar 表示將寄存器中的值加載到累加器中。

  • Add 表示寄存器加載值並將其與累加器中的值相加,而後將結果再次放入累加器。

  • Star 表示 把累加器中的值保存到某個寄存器中。

  • Return 結束當前函數的執行,並將控制權傳回給調用方。返回的值是累加器中的值。

五、即時編譯

在解釋器 Ignition 執行字節碼的過程當中,若是發現有熱點代碼(HotSpot),好比一段代碼被重複執行屢次,這種就稱爲熱點代碼,那麼後臺的編譯器 TurboFan 就會把該段熱點的字節碼編譯爲高效的機器碼,而後當再次執行這段被優化的代碼時,只須要執行編譯後的機器碼就能夠了,這樣就大大提高了代碼的執行效率。這種字節碼配合解釋器和編譯器的技術被稱爲即時編譯(JIT)。

V8 的優化策略

下面咱們來看一下,V8 爲了提高解析和執行 Js 的速度,作了哪些優化。因爲篇幅關係,這裏只介紹 5 個優化點。

一、從新引入字節碼

早期的 V8 團隊認爲先生成字節碼再執行字節碼的方式會下降代碼的執行效率,因而直接將 JavaScript 代碼編譯成機器代碼。這樣作帶來的問題有兩點,一是須要較長的編譯時間,二是產生的二進制機器碼須要佔用較大的內存空間。 使用字節碼的話雖然犧牲了一點執行效率,可是節省了內存空間而且下降了編譯時間。此外,字節碼也下降了 V8 代碼的複雜度,使得 V8 移植到不一樣的 CPU 架構平臺更加容易。這是由於統一將字節碼轉換爲不一樣平臺的二進制代碼要比編譯器編寫不一樣 CPU 體系的二進制代碼更加容易。

二、延遲解析

經過 V8 的編譯流程咱們能夠看出,V8 執行 JavaScript 代碼須要通過編譯和執行兩個階段。

  • 編譯過程:是指 V8 將 JavaScript 代碼轉換爲字節碼,或者二進制機器代碼的階段。
  • 執行階段:是指解釋器解釋執行字節碼,或者是 CPU 直接執行二進制機器代碼的階段。

V8 並不會一次性將全部的 JavaScript 解析爲中間代碼,這主要是基於如下兩點:

  • 若是一次解析和編譯全部的 JavaScript 代碼,過多的代碼會增長編譯時間,這會嚴重影響到首次執行 JavaScript 代碼的速度,讓用戶感受到卡頓。
  • 其次,解析完成的字節碼和編譯以後的機器代碼都會存放在內存中,若是一次性解析和編譯全部 JavaScript 代碼,那麼這些中間代碼和機器代碼將會一直佔用內存。

延遲解析是指解析器在解析的過程當中,若是遇到函數聲明,那麼會跳過函數內部的代碼,並不會爲其生成 AST 和字節碼。

三、隱藏類

咱們能夠結合一段代碼來分析下隱藏類是怎麼工做的:

let point = {x:100,y:200}
複製代碼

當 V8 執行到這段代碼時,會先爲 point 對象建立一個隱藏類,在 V8 中,把隱藏類又稱爲 map,每一個對象都有一個 map 屬性,其值指向內存中的隱藏類。隱藏類描述了對象的屬性佈局,它主要包括了屬性名稱和每一個屬性所對應的偏移量,好比 point 對象的隱藏類就包括了 x 和 y 屬性,x 的偏移量是 4,y 的偏移量是 8。 有了隱藏類以後,那麼當 V8 訪問某個對象中的某個屬性時,就會先去隱藏類中查找該屬性相對於它的對象的偏移量,有了偏移量和屬性類型,V8 就能夠直接去內存中取出對應的屬性值,而不須要經歷一系列的查找過程,那麼這就大大提高了 V8 查找對象的效率。

四、快屬性與慢屬性

當咱們在控制檯輸入以下代碼時:

function Foo() {

    this[100] = 'test-100'

    this[1] = 'test-1'

    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["A"] = 'bar-A'

    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:B  value:bar-B

index:A  value:bar-A

index:C  value:bar-C
複製代碼

之因此出現這樣的結果,是由於在 ECMAScript 規範中定義了數字屬性應該按照索引值大小升序排列,字符串屬性根據建立時的順序升序排列。

  • 數字屬性稱爲排序屬性,在 V8 中被稱爲 elements。
  • 字符串屬性就被稱爲常規屬性,在 V8 中被稱爲 properties。

下面咱們執行這樣一段代碼,看一看當對象中的屬性數目發生變化時,其在內存中結構是怎樣變化的。

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 標籤,而後點擊左側的小圓圈就能夠捕獲以上代碼的內存快照,最終截圖以下所示:將建立的對象屬性的個數調整到 20 個

var bar2 = new Foo(20,10)
複製代碼

總結:當對象中的屬性過多時,或者存在反覆添加或者刪除屬性的操做,那麼 V8 就會將線性的存儲模式(快屬性)降級爲非線性的字典存儲模式(慢屬性),這樣雖然下降了查找速度,可是卻提高了修改對象的屬性的速度。

五、內聯緩存

咱們再來看一段這樣的代碼。

function loadX(o) {

    o.y = 4

    return o.x

}

var o = { x: 1,y:3}

var o1 = { x: 3 ,y:6}

for (var i = 0; i < 90000; i++) {

    loadX(o)

    loadX(o1)

}
複製代碼

一般 V8 獲取 o.x 的流程是這樣的:查找對象 o 的隱藏類,再經過隱藏類查找 x 屬性偏移量,而後根據偏移量獲取屬性值,在這段代碼中 loadX 函數會被反覆執行,那麼獲取 o.x 流程也須要反覆被執行。爲了提高對象的查找效率。V8 執行的策略就是使用內聯緩存 (Inline Cache),簡稱爲 IC。IC 會爲每一個函數維護一個反饋向量 (FeedBack Vector),反饋向量記錄了函數在執行過程當中的一些關鍵的中間數據。而後將這些數據緩存起來,當下次再次執行該函數時,V8 就能夠直接利用這些中間數據,節省了再次獲取這些數據的過程。V8 會在反饋向量中爲每一個調用點分配一個插槽(Slot),好比 o.y = 4 和 return o.x 這兩段就是調用點 (CallSite),由於它們使用了對象和屬性。每一個插槽中包括了插槽的索引 (slot index)、插槽的類型 (type)、插槽的狀態 (state)、隱藏類 (map) 的地址、還有屬性的偏移量,好比上面這個函數中的兩個調用點都使用了對象 o,那麼反饋向量兩個插槽中的 map 屬性也都是指向同一個隱藏類的,所以這兩個插槽的 map 地址是同樣的。經過內聯緩存策略,就可以提高下次執行函數時的效率,可是這有一個前提,那就是屢次執行時,對象的形狀是固定的,若是對象的形狀不是固定的,這意味着 V8 爲它們建立的隱藏類也是不一樣的。面對這種狀況,V8 會選擇將新的隱藏類也記錄在反饋向量中,同時記錄屬性值的偏移量,這時,反饋向量中的一個槽裏就會出現包含了多個隱藏類和偏移量的狀況,若是超過 4 個,那麼 V8 會採起 hash 表的結構來存儲。講到這裏個人分享就結束了,若是有不足之處歡迎你們多多批評指正。

參考連接

www.cnblogs.com/nickchen121…

v8.dev/docs

juejin.cn/post/684490…

time.geekbang.org/column/arti…

www.jianshu.com/p/e4a75cb6f…

歡迎關注「 字節前端 ByteFE 」 簡歷投遞聯繫郵箱「tech@bytedance.com

相關文章
相關標籤/搜索