关于javascript:JS执行过程详解

13次阅读

共计 9834 个字符,预计需要花费 25 分钟才能阅读完成。

博客原文地址:https://finget.github.io/view…

js 代码的执行,次要分为两个个阶段:编译阶段、执行阶段!
本文所有内容基于 V8 引擎。

前言

v8 引擎

v8 引擎工作原理:

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 虚拟机 则采纳了基于寄存器的设计,它将一些两头数据保留到寄存器中。
基于寄存器的解释器架构:

  • TurboFan:compiler,即编译器,利用 Ignitio 所收集的类型信息,将 Bytecode 转换为优化的汇编代码;

    • 如果一个函数被屡次调用,那么就会被标记为热点函数,那么就会通过 TurboFan 转换成优化的机器码,进步代码的执行性能;
    • 然而,机器码实际上也会被还原为 ByteCode,这是因为如果后续执行函数的过程中,类型产生了变动(比方 sum 函数原来执行的是 number 类型,起初执行变成了 string 类型),之前优化的机器码并不能正确的解决运算,就会逆向的转换成字节码;
  • Orinoco:garbage collector,垃圾回收模块,负责将程序不再须要的内存空间回收;

提一嘴

栈 stack

栈的特点是 ”LIFO,即后进先出(Last in, first out)”。数据存储时只能从顶部一一存入,取出时也需从顶部一一取出。

堆 heap

堆的特点是 ” 无序 ” 的 key-value” 键值对 ” 存储形式。堆的存取形式跟程序没有关系,不局限出入口。

队列 queue

队列的特点是是 ”FIFO,即先进先出(First in, first out)”。
数据存取时 ” 从队尾插入,从队头取出 ”。

“ 与栈的区别:栈的存入取出都在顶部一个出入口,而队列分两个,一个进口,一个入口 ”。

编译阶段

词法剖析 Scanner

将由字符组成的字符串分解成(对编程语言来说)有意义的代码块,这些代码块被称为词法单元(token)。

[
    {
        "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"
}

在此过程中,如果源代码不合乎语法规定,则会终止,并抛出“语法错误”。

这里有个工具,能够实时生成语法树,能够试试 esprima。

字节码生成

能够用 node 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 引入了块级作用域。然而这个块级别作用域须要留神的是不是{} 的作用域,而是 letconst 关键字的 块级作用域

var name = 'FinGet';

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

在解析时就会确定作用域:

简略的来说,作用域就是个盒子,规定了变量和函数的可拜访范畴以及他们的生命周期。

词法作用域

词法作用域就是指作用域是由代码中函数申明的地位来决定的,所以词法作用域是动态的作用域,通过它就可能预测代码在执行过程中如何查找标识符。

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

下面代码打印的后果是:global_finget, 这就是因为在编译阶段就曾经确定了作用域,fn是定义在全局作用域中的,它在本人外部找不到 myName 就会去全局作用域中找,不会在 fn1 中查找。

执行阶段

执行上下文

遇到函数执行的时候,就会创立一个执行上下文。执行上下文是以后 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. 首先在一个正在运行的执行上下文内,词法环境由 LexicalEnvironmentVariableEnvironment形成,用来注销所有的变量申明。
  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()

从图中能够看出,当进入函数的作用域块时,作用域块中通过 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 visualizer 工具。

看这个图就能够看出作用域链了吧,很直观。作用域链就是在 执行上下文创立阶段 确定的。有了执行的环境,能力确定它应该和谁形成作用域链。

V8 垃圾回收

内存调配

栈是长期存储空间,次要存储局部变量和函数调用,内小且存储间断,操作起来简略不便,个别由零碎 主动调配 主动回收,所以文章内所说的垃圾回收,都是基于堆内存。

根本类型数据(Number, Boolean, String, Null, Undefined, Symbol, BigInt)保留在在栈内存中。援用类型数据保留在堆内存中,援用数据类型的变量是一个指向堆内存中理论对象的援用,存在栈中。

为什么根本数据类型存储在栈中,援用数据类型存储在堆中?

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 算法,是把新生代空间对半划分为两个区域,一半是对象区域,一半是闲暇区域。

新生代回收

新退出的对象都会寄存到对象区域,当对象区域快被写满时,就须要执行一次垃圾清理操作。

  1. 先标记须要回收的对象,而后把对象区激活对象复制到闲暇区,并排序;

  1. 实现复制后,对象区域与闲暇区域进行角色翻转,也就是原来的对象区域变成闲暇区域,原来的闲暇区域变成了对象区域。

因为新生代中采纳的 Scavenge 算法,所以每次执行清理操作时,都须要将存活的对象从对象区域复制到闲暇区域。但复制操作须要工夫老本,如果新生区空间设置得太大了,那么每次清理的工夫就会过久,所以为了执行效率,个别新生区的空间会被设置得比拟小。

也正是因为新生区的空间不大,所以很容易被存活的对象装满整个区域。为了解决这个问题,JavaScript 引擎采纳了对象降职策略,也就是通过两次垃圾回收仍然还存活的对象,会被挪动到老生区中。

老生代回收

Mark-Sweep

Mark-Sweep 解决时分为两阶段,标记阶段和清理阶段,看起来与 Scavenge 相似,不同的是,Scavenge 算法是复制流动对象,而因为在老生代中流动对象占大多数,所以 Mark-Sweep 在标记了流动对象和非流动对象之后,间接把非流动对象革除。

  • 标记阶段:对老生代进行第一次扫描,标记流动对象
  • 清理阶段:对老生代进行第二次扫描,革除未被标记的对象,即清理非流动对象

Mark-Compact

因为 Mark-Sweep 实现之后,老生代的内存中产生了很多内存碎片,若不清理这些内存碎片,如果呈现须要调配一个大对象的时候,这时所有的碎片空间都齐全无奈实现调配,就会提前触发垃圾回收,而这次回收其实不是必要的。

为了解决内存碎片问题,Mark-Compact 被提出,它是在是在 Mark-Sweep 的根底演出进而来的,相比 Mark-Sweep,Mark-Compact 增加了流动对象整顿阶段,将所有的流动对象往一端挪动,挪动实现后,间接清理掉边界外的内存。

全进展 Stop-The-World

垃圾回收如果消耗工夫,那么主线程的 JS 操作就要停下来期待垃圾回收实现继续执行,咱们把这种行为叫做全进展(Stop-The-World)。

增量标记

为了升高老生代的垃圾回收而造成的卡顿,V8 将标记过程分为一个个的子标记过程,同时让垃圾回收标记和 JavaScript 应用逻辑交替进行,直到标记阶段实现,咱们把这个算法称为增量标记(Incremental Marking)算法。如下图所示:

惰性清理

增量标记只是对流动对象和非流动对象进行标记,惰性清理用来真正的清理开释内存。当增量标记实现后,如果以后的可用内存足以让咱们疾速的执行代码,其实咱们是没必要立刻清理内存的,能够将清理的过程提早一下,让 JavaScript 逻辑代码先执行,也无需一次性清理完所有非流动对象内存,垃圾回收器会按需逐个进行清理,直到所有的页都清理结束。

并发回收

并发式 GC 容许在在垃圾回收的同时不须要将主线程挂起,两者能够同时进行,只有在个别时候须要短暂停下来让垃圾回收器做一些非凡的操作。然而这种形式也要面对增量回收的问题,就是在垃圾回收过程中,因为 JavaScript 代码在执行,堆中的对象的援用关系随时可能会变动,所以也要进行写屏障操作。

并行回收

并行式 GC 容许主线程和辅助线程同时执行同样的 GC 工作,这样能够让辅助线程来分担主线程的 GC 工作,使得垃圾回收所消耗的工夫等于总工夫除以参加的线程数量(加上一些同步开销)。

站在伟人的肩膀上

在这里对前辈大佬示意敬意,查找了很多材料,如有脱漏,还请见谅。文中如果有误,还望及时指出,感激!
浏览器工作原理与实际
读李老课程引发的思考之 JS 执行机制 -| 超级 · 奥义 |
浏览器原理学习笔记 - 浏览器中 js 执行机制 (上)
js 引擎的执行过程
初步了解 JavaScript 底层原理
JavaScript 语言在引擎级别的执行过程
前端根底 | js 执行过程你理解多少?
【编译】代码是如何运行的之 JavaScript 执行过程
V8 是如何执行 JavaScript 代码的?
视线前端(二)V8 引擎是如何工作的
深刻理解 JavaScript 执行过程(JS 系列之一)
深入浅出解说 V8 引擎如何执行 JavaScript 代码
浏览器是如何工作的:Chrome V8 让你更懂 JavaScript
如何了解 js 的执行上下文与执行栈
js 执行可视化
【译】了解 Javascript 执行上下文和执行栈
【译】了解 Javascript 执行上下文和执行栈
JS 作用域链的详解
浏览器的垃圾回收详解(以谷歌浏览器的 V8 为例)
深刻了解谷歌最强 V8 垃圾回收机制
「译」Orinoco: V8 的垃圾回收器
V8 内存治理及垃圾回收机制
JS Memory Leak And V8 Garbage Collection

正文完
 0