天天看點

詳解JavaScript執行過程

js代碼的執行,主要分為兩個個階段:編譯階段、執行階段!本文所有内容基于V8引擎。

1前言

v8引擎

v8引擎工作原理:

詳解JavaScript執行過程

V8由許多子子產品構成,其中這4個子產品是最重要的:

  • Parser:負責将JavaScript源碼轉換為Abstract Syntax Tree (AST);
  • 如果函數沒有被調用,那麼是不會被轉換成AST的
  • Ignition:interpreter,即解釋器,負責将AST轉換為Bytecode,解釋執行Bytecode;同時收集TurboFan優化編譯所需的資訊,比如函數參數的類型,有了類型才能進行真實的運算;
  • 如果函數隻調用一次,Ignition會執行解釋執行ByteCode
  • 解釋器也有解釋執行bytecode的能力
通常有兩種類型的解釋器,基于棧 (Stack-based)和基于寄存器 (Register-based),基于棧的解釋器使用棧來儲存函數參數、中間運算結果、變量等;基于寄存器的虛拟機則支援寄存器的指令操作,使用寄存器來儲存參數、中間計算結果。通常,基于棧的虛拟機也定義了少量的寄存器,基于寄存器的虛拟機也有堆棧,其差別展現在它們提供的指令集體系。大多數解釋器都是基于棧的,比如 ​

​Java 虛拟機​

​​,​

​.Net 虛拟機​

​​,還有​

​早期的 V8 虛拟機​

​​。基于堆棧的虛拟機在處理函數調用、解決遞歸問題和切換上下文時簡單明快。而​

​現在的 V8 虛拟機​

​則采用了基于寄存器的設計,它将一些中間資料儲存到寄存器中。基于寄存器的解釋器架構:
詳解JavaScript執行過程
  • TurboFan:compiler,即編譯器,利用Ignitio所收集的類型資訊,将Bytecode轉換為優化的彙編代碼;
  • 如果一個函數被多次調用,那麼就會被标記為熱點函數,那麼就會經過TurboFan轉換成優化的機器碼,提高代碼的執行性能;
  • 但是,機器碼實際上也會被還原為ByteCode,這是因為如果後續執行函數的過程中,類型發生了變化(比如sum函數原來執行的是number類型,後來執行變成了string類型),之前優化的機器碼并不能正确的處理運算,就會逆向的轉換成位元組碼;
  • Orinoco:garbage collector,垃圾回收子產品,負責将程式不再需要的記憶體空間回收;

提一嘴

棧 stack

棧的特點是"LIFO,即後進先出(Last in, first out)"。資料存儲時隻能從頂部逐個存入,取出時也需從頂部逐個取出。

詳解JavaScript執行過程

堆 heap

堆的特點是"無序"的key-value"鍵值對"存儲方式。堆的存取方式跟順序沒有關系,不局限出入口。

詳解JavaScript執行過程

隊列 queue

隊列的特點是是"FIFO,即先進先出(First in, first out)" 。資料存取時"從隊尾插入,從隊頭取出"。

"與棧的差別:棧的存入取出都在頂部一個出入口,而隊列分兩個,一個出口,一個入口"。

詳解JavaScript執行過程

2編譯階段

詞法分析 Scanner

将由字元組成的字元串分解成(對程式設計語言來說)有意義的代碼塊,這些代碼塊被稱為詞法單元(token)。

詳解JavaScript執行過程
[
    {
        "type": "Keyword",
        "value": "var"
    },
    {
        "type": "Identifier",
        "value": "name"
    },
    {
        "type": "Punctuator",
        "value": "="
    },
    {
        "type": "String",
        "value": "'finget'"
    },
    {
        "type": "Punctuator",
        "value": ";"
    }
]      

文法分析 Parser

這個過程是将詞法單元流(數組)轉換成一個由元素逐級嵌套所組成的代表了程式文法結構的樹。這個樹被稱為“抽象文法樹”(Abstract Syntax Tree,AST)。

{
  "type": "Program",
  "body": [
    {
      "type": "VariableDeclaration",
      "declarations": [
        {
          "type": "VariableDeclarator",
          "id": {
            "type": "Identifier",
            "name": "name"
          },
          "init": {
            "type": "Literal",
            "value": "finget",
            "raw": "'finget'"
          }
        }
      ],
      "kind": "var"
    }
  ],
  "sourceType": "script"
}      
在此過程中,如果源代碼不符合文法規則,則會終止,并抛出“文法錯誤”。
詳解JavaScript執行過程

這裡有個工具,可以實時生成文法樹,可以試試esprima。

位元組碼生成

可以用​

​node --print-bytecode​

​檢視位元組碼:

// test.js
function getMyname() {
 var myname = 'finget';
 console.log(myname);
}
getMyname();
node --print-bytecode test.js 

...
[generated bytecode for function: getMyname (0x10ca700104e9 <SharedFunctionInfo getMyname>)]
Parameter count 1
Register count 3
Frame size 24
   18 E> 0x10ca70010e86 @    0 : a7                StackCheck 
   37 S> 0x10ca70010e87 @    1 : 12 00             LdaConstant [0]
         0x10ca70010e89 @    3 : 26 fb             Star r0
   48 S> 0x10ca70010e8b @    5 : 13 01 00          LdaGlobal [1], [0]
         0x10ca70010e8e @    8 : 26 f9             Star r2
   56 E> 0x10ca70010e90 @   10 : 28 f9 02 02       LdaNamedProperty r2, [2], [2]
         0x10ca70010e94 @   14 : 26 fa             Star r1
   56 E> 0x10ca70010e96 @   16 : 59 fa f9 fb 04    CallProperty1 r1, r2, r0, [4]
         0x10ca70010e9b @   21 : 0d                LdaUndefined 
   69 S> 0x10ca70010e9c @   22 : ab                Return 
Constant pool (size = 3)
Handler Table (size = 0)
...      

這裡涉及到一個很重要的概念:JIT(Just-in-time)一邊解釋,一邊執行。

它是如何工作的呢(結合第一張流程圖來看):

1.在 JavaScript 引擎中增加一個螢幕(也叫分析器)。螢幕監控着代碼的運作情況,記錄代碼一共運作了多少次、如何運作的等資訊,如果同一行代碼運作了幾次,這個代碼段就被标記成了 “warm”,如果運作了很多次,則被标記成 “hot”;

2.(基線編譯器)如果一段代碼變成了 “warm”,那麼 JIT 就把它送到基線編譯器去編譯,并且把編譯結果存儲起來。比如,螢幕監視到了,某行、某個變量執行同樣的代碼、使用了同樣的變量類型,那麼就會把編譯後的版本,替換這一行代碼的執行,并且存儲;

3.(優化編譯器)如果一個代碼段變得 “hot”,螢幕會把它發送到優化編譯器中。生成一個更快速和高效的代碼版本出來,并且存儲。例如:循環加一個對象屬性時,假設它是 INT 類型,優先做 INT 類型的判斷;

4.(反優化 Deoptimization)可是對于 JavaScript 從來就沒有确定這麼一說,前 99 個對象屬性保持着 INT 類型,可能第 100 個就沒有這個屬性了,那麼這時候 JIT 會認為做了一個錯誤的假設,并且把優化代碼丢掉,執行過程将會回到解釋器或者基線編譯器,這一過程叫做反優化。

作用域

作用域是一套規則,用來管理引擎如何查找變量。在es5之前,js隻有全局作用域及函數作用域。es6引入了塊級作用域。但是這個塊級别作用域需要注意的是不是​

​{}​

​​的作用域,而是​

​let​

​​,​

​const​

​關鍵字的塊級作用域。

var name = 'FinGet';

function fn() {
  var age = 18;
  console.log(name);
}      

在解析時就會确定作用域:

詳解JavaScript執行過程
簡單的來說,作用域就是個盒子,規定了變量和函數的可通路範圍以及他們的生命周期。

詞法作用域

詞法作用域就是指作用域是由代碼中函數聲明的位置來決定的,是以詞法作用域是靜态的作用域,通過它就能夠預測代碼在執行過程中如何查找辨別符。

function fn() {
    console.log(myName)
}
function fn1() {
    var myName = " FinGet "
    fn()
}
var myName = " global_finget "
fn1()      

上面代碼列印的結果是:​

​global_finget​

​​,這就是因為在編譯階段就已經确定了作用域,​

​fn​

​​是定義在全局作用域中的,它在自己内部找不到​

​myName​

​​就會去全局作用域中找,不會在​

​fn1​

​中查找。

3執行階段

執行上下文

遇到函數執行的時候,就會建立一個執行上下文。執行上下文是目前 JavaScript 代碼被解析和執行時所在環境的抽象概念。

JavaScript 中有三種執行上下文類型:

  • 全局執行上下文 (隻有一個)
  • 函數執行上下文
  • eval

執行上下文的建立分為兩個階段建立:1.建立階段 2.執行階段

建立階段

在任意的 JavaScript 代碼被執行時,執行上下文處于建立階段。在建立階段中總共發生了三件事情:

  • 确定 this 的值,也被稱為 This Binding
  • LexicalEnvironment(詞法環境) 元件被建立。
  • VariableEnvironment(變量環境) 元件被建立。
ExecutionContext = {  
  ThisBinding = <this value>,     // 确定this 
  LexicalEnvironment = { ... },   // 詞法環境
  VariableEnvironment = { ... },  // 變量環境      
This Binding

在全局執行上下文中,​

​this​

​​ 的值指向全局對象,在浏覽器中,​

​this​

​​ 的值指向 ​

​window​

​​ 對象。在函數執行上下文中,​

​this​

​​ 的值取決于函數的調用方式。如果它被一個對象引用調用,那麼 ​

​this​

​​ 的值被設定為該對象,否則 ​

​this​

​​ 的值被設定為全局對象或 ​

​undefined​

​(嚴格模式下)。

詞法環境(Lexical Environment)

詞法環境是一個包含辨別符變量映射的結構。(這裡的辨別符表示變量/函數的名稱,變量是對實際對象【包括函數類型對象】或原始值的引用)。在詞法環境中,有兩個組成部分:(1)環境記錄(environment record) (2)對外部環境的引用

  • 環境記錄是​

    ​存儲變量​

    ​​和​

    ​函數聲明​

    ​的實際位置。
  • 對外部環境的引用意味着它​

    ​可以通路其外部詞法環境​

    ​。(實作作用域鍊的重要部分)

詞法環境有兩種類型:

  • 全局環境(在全局執行上下文中)是一個沒有外部環境的詞法環境。全局環境的外部環境引用為 null。它擁有一個全局對象(window 對象)及其關聯的方法和屬性(例如數組方法)以及任何使用者自定義的全局變量,this 的值指向這個全局對象。
  • 函數環境,使用者在函數中定義的變量被存儲在環境記錄中。對外部環境的引用可以是全局環境,也可以是包含内部函數的外部函數環境。
注意:對于函數環境而言,環境記錄 還包含了一個 ​

​arguments​

​ 對象,該對象包含了索引和傳遞給函數的參數之間的映射以及傳遞給函數的參數的長度(數量)。
變量環境 Variable Environment

它也是一個詞法環境,其 ​

​EnvironmentRecord​

​​ 包含了由 ​

​VariableStatements​

​ 在此執行上下文建立的綁定。

如上所述,變量環境也是一個詞法環境,是以它具有上面定義的詞法環境的所有屬性。

示例代碼:

let a = 20;  
const b = 30;  
var c;

function multiply(e, f) {  
 var g = 20;  
 return e * f * g;  
}

c = multiply(20, 30);      

執行上下文:

GlobalExectionContext = {

  ThisBinding: <Global Object>,

  LexicalEnvironment: {  
    EnvironmentRecord: {  
      Type: "Object",  
      // 辨別符綁定在這裡  
      a: < uninitialized >,  
      b: < uninitialized >,  
      multiply: < func >  
    }  
    outer: <null>  
  },

  VariableEnvironment: {  
    EnvironmentRecord: {  
      Type: "Object",  
      // 辨別符綁定在這裡  
      c: undefined,  
    }  
    outer: <null>  
  }  
}

FunctionExectionContext = {  
   
  ThisBinding: <Global Object>,

  LexicalEnvironment: {  
    EnvironmentRecord: {  
      Type: "Declarative",  
      // 辨別符綁定在這裡  
      Arguments: {0: 20, 1: 30, length: 2},  
    },  
    outer: <GlobalLexicalEnvironment>  // 指定全局環境
  },

  VariableEnvironment: {  
    EnvironmentRecord: {  
      Type: "Declarative",  
      // 辨別符綁定在這裡  
      g: undefined  
    },  
    outer: <GlobalLexicalEnvironment>  
  }  
}      
仔細看上面的:​

​a: < uninitialized >​

​​,​

​c: undefined​

​​。是以你在​

​let a​

​​定義前​

​console.log(a)​

​​的時候會得到​

​Uncaught ReferenceError: Cannot access 'a' before initialization​

​。
為什麼要有兩個詞法環境

變量環境元件(VariableEnvironment) 是用來登記​

​var​

​​ ​

​function​

​變量聲明,詞法環境元件(LexicalEnvironment)是用來登記​

​let​

​​ ​

​const​

​​ ​

​class​

​等變量聲明。

在ES6之前都沒有塊級作用域,ES6之後我們可以用​

​let​

​​ ​

​const​

​​來聲明塊級作用域,有這兩個詞法環境是為了實作塊級作用域的同時不影響​

​var​

​變量聲明和函數聲明,具體如下:

  1. 首先在一個正在運作的執行上下文内,詞法環境由​

    ​LexicalEnvironment​

    ​​和​

    ​VariableEnvironment​

    ​構成,用來登記所有的變量聲明。
  2. 當執行到塊級代碼時候,會先​

    ​LexicalEnvironment​

    ​​記錄下來,記錄為​

    ​oldEnv​

    ​。
  3. 建立一個新的​

    ​LexicalEnvironment​

    ​​(outer指向oldEnv),記錄為​

    ​newEnv​

    ​​,并将​

    ​newEnv​

    ​​設定為正在執行上下文的​

    ​LexicalEnvironment​

    ​。
  4. 塊級代碼内的​

    ​let​

    ​​

    ​const​

    ​​會登記在​

    ​newEnv​

    ​​裡面,但是​

    ​var​

    ​​聲明和函數聲明還是登記在原來的​

    ​VariableEnvironment​

    ​裡。
  5. 塊級代碼執行結束後,将​

    ​oldEnv​

    ​​還原為正在執行上下文的​

    ​LexicalEnvironment​

    ​。
function foo(){
    var a = 1
    let b = 2
    {
      let b = 3
      var c = 4
      let d = 5
      console.log(a)
      console.log(b)
    }
    console.log(b) 
    console.log(c)
    console.log(d)
}   
foo()      
詳解JavaScript執行過程

從圖中可以看出,當進入函數的作用域塊時,作用域塊中通過let聲明的變量,會被存放在詞法環境的一個單獨的區域中,這個區域中的變量并不影響作用域塊外面的變量,比如在作用域外面聲明了變量b,在該作用域塊内部也聲明了變量b,當執行到作用域内部時,它們都是獨立的存在。

其實,在詞法環境内部,維護了一個小型棧結構,棧底是函數最外層的變量,進入一個作用域塊後,就會把該作用域塊内部的變量壓到棧頂;當作用域執行完成之後,該作用域的資訊就會從棧頂彈出,這就是詞法環境的結構。需要注意下,我這裡所講的變量是指通過let或者const聲明的變量。

再接下來,當執行到作用域塊中的​

​console.log(a)​

​這行代碼時,就需要在詞法環境和變量環境中查找變量a的值了,具體查找方式是:沿着詞法環境的棧頂向下查詢,如果在詞法環境中的某個塊中查找到了,就直接傳回給JavaScript引擎,如果沒有查找到,那麼繼續在變量環境中查找。

執行棧 Execution Context Stack

每個函數都會有自己的執行上下文,多個執行上下文就會以棧(調用棧)的方式來管理。

function a () {
  console.log('In fn a')
  function b () {
    console.log('In fn b')
    function c () {
      console.log('In fn c')
    }
    c()
  }
  b()
}
a()      
詳解JavaScript執行過程
詳解JavaScript執行過程

可以用這個工具試一下,更直覺的觀察進棧和出棧javascript visualizer 工具。

看這個圖就可以看出作用域鍊了吧,很直覺。作用域鍊就是在執行上下文建立階段确定的。有了執行的環境,才能确定它應該和誰構成作用域鍊。

4V8垃圾回收

記憶體配置設定

棧是臨時存儲空間,主要存儲局部變量和函數調用,内小且存儲連續,操作起來簡單友善,一般由系統自動配置設定,自動回收,是以文章内所說的垃圾回收,都是基于堆記憶體。

基本類型資料(Number, Boolean, String, Null, Undefined, Symbol, BigInt)儲存在在棧記憶體中。引用類型資料儲存在堆記憶體中,引用資料類型的變量是一個指向堆記憶體中實際對象的引用,存在棧中。

為什麼基本資料類型存儲在棧中,引用資料類型存儲在堆中?

JavaScript引擎需要用棧來維護程式執行期間的上下文的狀态,如果棧空間大了的話,所有資料都存放在棧空間裡面,會影響到上下文切換的效率,進而影響整個程式的執行效率。

詳解JavaScript執行過程

這裡用來存儲對象和動态資料,這是記憶體中最大的區域,并且是GC(Garbage collection 垃圾回收)工作的地方。不過,并不是所有的堆記憶體都可以進行GC,隻有新生代和老生代被GC管理。堆可以進一步細分為下面這樣:

  • 新生代空間:是最新産生的資料存活的地方,這些資料往往都是短暫的。這個空間被一分為二,然後被Scavenger(Minor GC)所管理。稍後會介紹。可以通過V8标志如 --max_semi_space_size 或 --min_semi_space_size 來控制新生代空間大小
  • 老生代空間:是從新生代空間經過至少兩輪Minor GC仍然存活下來的資料,該空間被Major GC(Mark-Sweep & Mark-Compact)管理,稍後會介紹。可以通過 --initial_old_space_size 或 --max_old_space_size控制空間大小。

Old pointer space:存活下來的包含指向其他對象指針的對象

Old data space:存活下來的隻包含資料的對象。

  • 大對象空間:這是比空間大小還要大的對象,大對象不會被gc處理。
  • 代碼空間:這裡是JIT所編譯的代碼。這是除了在大對象空間中配置設定代碼并執行之外的唯一可執行的空間。
  • map空間:存放 Cell 和 Map,每個區域都是存放相同大小的元素,結構簡單。

代際假說

代際假說有以下兩個特點:

  • 第一個是大部分對象在記憶體中存在的時間很短,簡單來說,就是很多對象一經配置設定記憶體,很快就變得不可通路;
  • 第二個是不死的對象,會活得更久。

在 V8 中會把堆分為新生代和老生代兩個區域,新生代中存放的是生存時間短的對象,老生代中存放的生存時間久的對象。

新生區通常隻支援 1~8M 的容量,而老生區支援的容量就大很多了。對于這兩塊區域,V8 分别使用兩個不同的垃圾回收器,以便更高效地實施垃圾回收。

  • 副垃圾回收器,主要負責新生代的垃圾回收。
  • 主垃圾回收器,主要負責老生代的垃圾回收。

新生代中用​

​Scavenge​

​​算法來處理。所謂 ​

​Scavenge​

​ 算法,是把新生代空間對半劃分為兩個區域,一半是對象區域,一半是空閑區域。

詳解JavaScript執行過程

新生代回收

新加入的對象都會存放到對象區域,當對象區域快被寫滿時,就需要執行一次垃圾清理操作。

  1. 先标記需要回收的對象,然後把對象區激活對象複制到空閑區,并排序;

   

詳解JavaScript執行過程

2. 完成複制後,對象區域與空閑區域進行角色翻轉,也就是原來的對象區域變成空閑區域,原來的空閑區域變成了對象區域。

詳解JavaScript執行過程

由于新生代中采用的 Scavenge 算法,是以每次執行清理操作時,都需要将存活的對象從對象區域複制到空閑區域。但複制操作需要時間成本,如果新生區空間設定得太大了,那麼每次清理的時間就會過久,是以為了執行效率,一般新生區的空間會被設定得比較小。

也正是因為新生區的空間不大,是以很容易被存活的對象裝滿整個區域。為了解決這個問題,JavaScript 引擎采用了對象晉升政策,也就是經過兩次垃圾回收依然還存活的對象,會被移動到老生區中。

老生代回收

Mark-Sweep

Mark-Sweep處理時分為兩階段,标記階段和清理階段,看起來與Scavenge類似,不同的是,Scavenge算法是複制活動對象,而由于在老生代中活動對象占大多數,是以Mark-Sweep在标記了活動對象和非活動對象之後,直接把非活動對象清除。

  • 标記階段:對老生代進行第一次掃描,标記活動對象
  • 清理階段:對老生代進行第二次掃描,清除未被标記的對象,即清理非活動對象
詳解JavaScript執行過程

Mark-Compact

由于Mark-Sweep完成之後,老生代的記憶體中産生了很多記憶體碎片,若不清理這些記憶體碎片,如果出現需要配置設定一個大對象的時候,這時所有的碎片空間都完全無法完成配置設定,就會提前觸發垃圾回收,而這次回收其實不是必要的。

為了解決記憶體碎片問題,Mark-Compact被提出,它是在是在 Mark-Sweep的基礎上演進而來的,相比Mark-Sweep,Mark-Compact添加了活動對象整理階段,将所有的活動對象往一端移動,移動完成後,直接清理掉邊界外的記憶體。

詳解JavaScript執行過程

全停頓 Stop-The-World

垃圾回收如果耗費時間,那麼主線程的JS操作就要停下來等待垃圾回收完成繼續執行,我們把這種行為叫做全停頓(Stop-The-World)。

詳解JavaScript執行過程

增量标記

為了降低老生代的垃圾回收而造成的卡頓,V8 将标記過程分為一個個的子标記過程,同時讓垃圾回收标記和 JavaScript 應用邏輯交替進行,直到标記階段完成,我們把這個算法稱為增量标記(Incremental Marking)算法。如下圖所示:

詳解JavaScript執行過程

惰性清理

增量标記隻是對活動對象和非活動對象進行标記,惰性清理用來真正的清理釋放記憶體。當增量标記完成後,假如目前的可用記憶體足以讓我們快速的執行代碼,其實我們是沒必要立即清理記憶體的,可以将清理的過程延遲一下,讓JavaScript邏輯代碼先執行,也無需一次性清理完所有非活動對象記憶體,垃圾回收器會按需逐一進行清理,直到所有的頁都清理完畢。

并發回收

并發式GC允許在在垃圾回收的同時不需要将主線程挂起,兩者可以同時進行,隻有在個别時候需要短暫停下來讓垃圾回收器做一些特殊的操作。但是這種方式也要面對增量回收的問題,就是在垃圾回收過程中,由于JavaScript代碼在執行,堆中的對象的引用關系随時可能會變化,是以也要進行寫屏障操作。

詳解JavaScript執行過程

并行回收

并行式GC允許主線程和輔助線程同時執行同樣的GC工作,這樣可以讓輔助線程來分擔主線程的GC工作,使得垃圾回收所耗費的時間等于總時間除以參與的線程數量(加上一些同步開銷)。

詳解JavaScript執行過程