跳到主要内容

编译执行

2024年04月07日
柏拉文
越努力,越幸运

一、认识


V8 是一个由 Google 开发的开源 JavaScript 引擎,目前用在 Chrome 浏览器和 Node.js 中,其核心功能是执行易于人类理解的 JavaScript 代码。

1.1 JIT 即时编译


V8 采用即时编译JIT (Just In Time)的双轮驱动设计,这是一种权衡的策略,混合编译执行解释执行两种手段,给JavaScript的执行速度带来极大的提升。

1.2 解释执行

定义: 解释执行需要先将输入的源代码通过解析器编译成中间代码,之后直接使用解释器解释执行中间代码,然后输出结果。具体流程如图所示:

Preview

特点: 解释执行启动速度快,但是执行时的速度慢

1.3 编译执行

定义: 编译执行需要先将输入的源代码通过解析器编译成中间代码,之后使用编译器将中间代码编译成机器代码。通常编译成的机器代码是以二进制文本形式存储的,可以直接执行。具体流程如图所示:

Preview

特点: 编译执行启动速度慢,执行时的速度快

1.4 JIT 混合解释编译执行

图一所示:

Preview

图二所示:

Preview

图三所示:

Preview

如图所示: V8 执行一段JavaScript代码所经历的主要流程为:

  1. 词法分析
  2. 语法分析
  3. 根据 AST 和 作用域生成字节码
  4. 解释执行字节码
  5. 优化字节码执行速度
  6. 即时编译

1.5 基石-运行时环境

执行JavaScript代码之前,V8 就已经准备好了代码的运行时环境,这个环境包括堆空间栈空间全局执行上下文全局作用域内置的内建函数宿主环境提供的扩展函数和对象消息系统。准备好运行时环境之后,V8 才可以执行JavaScript 代码,这包括词法分析语法分析……等。

1.6 宿主环境

浏览器V8提供了基础的消息循环系统全局变量Web API堆、栈存储空间等,因此浏览器可以作为V8的宿主环境。Node.js也可以作为V8的另外的一个宿主环境,它提供了不同的宿主对象和宿主 API。

1.7 存储空间

Preview

堆空间: 堆空间 是一种树形存储结构,用来存储对象类型的离散数据

  • 特点:

    • 数据特点: 堆空间适合存储占用内存较大不需要存储在连续空间 的数据
  • 存储的数据: 对象类型,诸如函数、数组、windows对象等

栈空间: 栈空间 主要是用来管理JavaScript函数调用,是内存中连续的一块空间,同时栈结构先进后出的策略

  • 特点:

    • 数据特点: 栈空间适合存储占用内存较小需要存储在连续空间 的数据

    • 空间连续 所以栈中每个元素的地址都是固定的,因此栈空间的查询效率非常高

    • 空间大小限制 通常在内存中,很难分配到一块很大的连续空间,因此,V8 对栈空间的大小做了限制,如果函数调用层数过深,那么V8就有可能抛出栈溢出的错误

  • 存储的数据: 基础数据类型BooleanNumberStringUndefinedNull 、引用数据类型的地址、函数的执行状态

宿主在启动V8的过程中,会同时创建堆空间栈空间,再继续往下执行,产生的新数据都会放在这两个空间中。

1.8 执行上下文

V8执行上下文 来维护执行当前代码所需要的变量声明、this 指向等

分类

  • 全局执行上下文: V8 开始执行一段可执行代码时,会生成一个执行上下文。如果 V8 执行的全局代码中有 声明的函数 或者 定义的变量 , 那么函数对象和声明的变量都会被添加到全局执行上下文中。

    • 特点: 全局执行上下文V8 的生存周期内不会被销毁,它会一直保存在 中,这样在下次需要使用函数、或者全局变量时就不需要重新创建了。
  • 函数执行上下文:

    • 特点: 函数执行上下文 在函数执行结束之后,就会被销毁
  • eval 执行上下文

组成 如图所示

Preview
  • 变量环境

    • 访问特点: 变量环境: var 声明的变量编译阶段放到 变量环境 , 如果在声明之前访问该变量,那么是 undefined, 这个过程叫变量提升
  • 词法环境

    • 访问特点: 词法环境: letconst 声明的变量编译阶段放到 词法环境 , 会为变量添加一个没有绑定值标识符。JavaScript拒绝访问还没有绑定值的let/const标识符。因此不可以在声明之前访问该变量, 否则报错,这个过程叫暂时性死区 理解如下:

      var bar = "bar";
      function foo() {
      console.log(bar); // 报错-暂时性死区 Cannot access 'bar' before initialization
      let bar = "bar-foo";
      }
      foo();
      var bar = "bar";
      {
      console.log(bar); // 报错-暂时性死区 Cannot access 'bar' before initialization
      let bar = "bar-foo";
      }
      foo();
  • 外部环境

    • 特点*: 其实在每个执行上下文的变量环境中,都包含了一个外部引用,用来指向外部的执行上下文,我们把这个外部引用称为 outer
  • this 关键字

1.9 调用栈

调用栈: 每个函数在执行过程中,都有自己的生命周期和作用域,当函数执行结束时,其作用域也会被销毁,因此,我们会使用栈这种数据结构来管理函数的调用过程,我们也把管理函数调用过程的栈结构称之为调用栈

为什么使用栈结构来管理函数调用?

答: 函数有两个主要的特性: 函数可以被调用; 函数具有作用域机制。观察函数的生命周期汗函数的资源分配情况,它们符合先进后出的策略。而结构正好满足先进后出的需求,所以需要通过来管理函数的调用.

既然有了,为什么还要用?

答: 因为栈空间是有限的,这就导致我们在编写程序的时候,经常一不小心就会导致栈溢出,比如函数循环嵌套层次太多,或者在栈上分配的数据过大,都会导致栈溢出,基于栈不方便存放大的数据,因此我们使用了另外一种数据结构用来保存一些大数据,这就是

1.10 作用域

执行上下文 中存在多个 作用域作用域 是指在程序中定义变量的区域,该位置决定了变量的生命周期。通俗地理解,作用域就是变量函数的可访问范围,即作用域控制着变量函数可见性生命周期。分类如下:

  • 全局作用域:

    • 特点: 全局作用域中的对象在代码中的任何地方都能访问,其生命周期伴随着页面的生命周期; 全局作用域是在 V8 启动过程中就创建了,且一直保存在内存中不会被销毁的,直至 V8 退出
  • 函数作用域:

    • 特点: 函数作用域就是在函数内部定义的变量或者函数,并且定义的变量或者函数只能在函数内部被访问;函数执行结束之后,函数内部定义的变量会被销毁

    • 顺序: 在执行阶段,在执行一个函数时,当该函数需要使用某个变量或者调用了某个函数时,便会优先在该函数作用域中查找相关内容

  • 块级作用域:

    • 特点: 代码块内部定义的变量在代码块外部访问不到,等该代码块中的代码执行完成之后,代码块中定义的变量会被销毁

    • 问题: JavaScript 是如何支持块级作用域的? JavaScript 又是如何同时支持变量提升块级作用域 呢?

      答: 块级作用域是通过词法环境结构来实现,变量提升 是通过变量环境来实现,通过两者的结合,JavaScript 可以同时支持变量提升块级作用域,具体过程如下:

      • 1. JavaScript 执行一段代码时,首先进入编译阶段,根据声明的变量类型进行放入不同的环境

        • var 声明的变量, 全部被存到变量环境
        • letcont 声明的变量, 全部存放到词法环境
      • 2. 词法环境内部,维护了一个小型结构,进入一个作用域块之后,就会把该作用域块内部的letconst 变量压到栈顶,作用域执行完成之后,该作用域的信息就会从栈顶弹出

      • 3. 访问一个变量的顺序为: 首先沿着词法环境的栈顶向下查询,如果有找到,直接返回给 JavaScript ;如果没有找到, 那么继续在变量环境中找

1.11 词法作用域

词法作用域 就是指作用域是由代码中函数声明的位置来决定的,和函数是怎么调用的没有关系。所以词法作用域静态的作用域,通过它就能够预测代码在执行过程中如何查找标识符。理解如下所示:

Preview

从图中可以看出,词法作用域就是根据代码的位置来决定的,其中 main 函数包含了 bar 函数,bar 函数中包含了 foo 函数,因为 JavaScript 作用域链是由词法作用域决定的,所以整个*词法作用域链的顺序是: foo 函数作用域—>bar 函数作用域—>main 函数作用域—> 全局作用域

1.12 动态作用域

动态作用域静态作用域相对的是动态作用域, 动态作用域并不关心函数和作用域是如何声明以及在何处声明的,只关心它们从何处调用。换句话说,作用域链是基于调用栈的,而不是基于函数定义的位置的。

1.13 作用域链

作用域链 当访问一个变量时,编译器在执行这段代码时,会首先从当前的作用域中查找是否有这个标识符,如果没有找到,就会去父作用域查找,如果父作用域还没找到继续向上查找,直到全局作用域为止,而作用域链,就是有当前作用域上层作用域的一系列变量对象组成,它保证了当前执行的作用域对符合访问权限的变量和函数的有序访问

1.14 事件循环系统

V8 执行JavaScript 代码时,宿主环境 需要构造事件循环系统,用来处理任务队列任务的调度

详细了解事件循环系统

二、词法分析 Lexer


词法分析 扫描源码,分析源码中的字符,输出 Token

三、语法分析 Parser


3.1 AST

AST 基于词法分析出来的Token,生成AST

四、语义分析 Semantic


语义分析阶段 编译器开始对 AST 进行一次或多次的遍历,检查程序的语义规则。主要包括声明检查类型检查

4.1 作用域

语法分析阶段, 在遍历 AST 的过程中:

  • 遇到函数声明: 将这个函数声明转换为内存中的函数对象,并将其放到作用域中, 同名覆盖

  • 遇到变量声明: 将其放到作用域中,但是会将其值设置为 undefined,表示该变量还未被使用

    • var声明的变量放到 变量环境 ,同名覆盖

    • let声明的变量放到 词法环境, 同名报错(按照 词法环境 -> 语法环境 的顺序寻找)

因此, 在编译阶段(语义分析)过程中,声明的变量都会被添加到当前作用域中,所以在执行阶段,V8 就可以获取到所有定义的变量了。 把这种在编译阶段,将所有的变量提升到作用域的过程称为变量提升

变量提升的经典题型

  1. 经典题型一
var bar = "bar";
function foo() {
console.log(bar); // undefined
if (true) {
var bar = "bar-foo";
}
}
foo();

解析: foo 函数在执行之前,先经过编译,遇到var bar = "bar-foo" 声明语句会将var bar = undefined 放到foo 函数的开头(变量提升),所以,foo() 函数在执行阶段遇到console.log(bar) 时,bar = undefined

变量提升所带来的问题

  1. 变量容易在不被察觉的情况下被覆盖掉, 场景如下:
var myname = "极客时间"function showName(){ console.log(myname); if(0){ var myname = "极客邦" } console.log(myname);}showName()
  1. 本应销毁的变量没有被销毁,造成变量污染, 场景如下:
function foo(){ for (var i = 0; i < 7; i++) { } console.log(i); }foo()

ES6 是如何解决变量提升带来的缺陷

  1. ES6 引入了 letconst 关键字,从而使 JavaScript 也能像其他语言一样拥有了块级作用域

4.2 惰性编译

惰性解析 是指解析器在解析的过程中,如果遇到函数声明,那么会跳过函数内部的代码,并不会为其生成AST字节码,而仅仅生成顶层代码的AST字节码。等到函数被调用的时候,才开始编译为AST字节码,然后再解释执行。利用惰性解析可以加速 JavaScript 代码的启动速度,如果要将所有的代码一次性解析编译完成,那么会大大增加用户的等待时间。

4.3 预解析器

预解析器 是指当解析顶层代码的时候,遇到了一个函数,那么预解析器并不会直接跳过该函数,而是对该函数做一次快速的预解析,其主要目的有两个:

  1. 是判断当前函数是不是存在一些语法上的错误,在预解析过程中,预解析器发现了语法错误,那么就会向 V8 抛出语法错误
  2. 检查函数内部是否引用了外部变量,如果引用了外部的变量,预解析器会将中的变量复制到中,在下次执行到该函数的时候,直接使用中的引用这个就是外层函数执行上下文销毁,为什么闭包可以访问该变量的原因

五、生成字节码


生成了AST作用域 之后, AST作用域 输入到 Ignition 解释器中, 并将其转化为 字节码,之后 字节码 再由 Ignition 解释器解释执行。 字节码 是介于 AST机器代码中间代码

字节码的作用

  1. 解释器可以直接解释执行字节码
  2. 优化编译器可以将字节码编译为二进制代码,然后再执行二进制机器代码

Ignition 解释器的作用:

  1. **根据AST作用域 生成字节码
  2. 解释执行字节码

为什么要引入字节码?

  • 早期的V8: 为了提升代码的执行速度,直接将 JavaScript 源代码编译成了没有优化的二进制的机器代码,如果某一段二进制代码执行频率过高,那么 V8 会将其标记为热点代码,热点代码会被优化编译器优化,优化后的机器代码执行效率更高,不过随着移动设备的普及,V8 团队逐渐发现将 JavaScript 源码直接编译成二进制代码存在两个致命的问题:

    • 时间问题: 编译时间过久,影响代码启动速度 , 所以需要引入二进制代码缓存,省去了再次编译的时间

    • 空间问题: 缓存编译后的二进制代码占用更多的内存。 早期V8 的缓存策略为:

      • 1. 将运行时将二进制机器代码缓存在内存中

      • 2. 当浏览器退出时,缓存编译之后二进制代码到磁盘上

      采用缓存是一种典型的以空间换时间的策略,以牺牲存储空间来换取执行速度。但是二进制代码所占用的内存空间JavaScript 代码的几千倍,通常一个页面的 JavaScriptM 大小,转换为二进制代码就变成几十 M 了,导致过度占用内存。所以为了避免过多的占用内存,早期的V8只是缓存了顶层代码二进制代码,惰性编译之后的代码不做缓存。

      二进制代码占空空间庞大,所以通过只缓存顶层代码,不缓存惰性编译之后的代码的方式也不是很完美,一些关键的代码无法被缓存,比如闭包模块中的代码将无法被缓存。

    • 3. 代码复杂度: 基线编译器优化编译器要针对不同的体系的 CPU 编写不同的代码,这会大大增加代码量

      基于以上原因,V8 进行了非常大的重构,重新引入了字节码

  • 引入字节码V8:

    • 时间优势: 编译时间比机器码小很多,启动速度变快
    • 空间优势: 字节码虽然占用的空间比原始的 JavaScript 多,但是相较于机器代码,字节码还是小了太多。由于字节码占用的空间远小于二进制代码,所以浏览器就可以实现缓存所有的字节码,而不是仅仅缓存顶层的字节码
    • 执行问题: 虽然采用字节码在执行速度上稍慢于机器代码
    • 降低了代码复杂度: 引入了字节码,就可以统一将字节码转换为不同平台的二进制代码,因为字节码的执行过程和 CPU 执行二进制代码的过程类似,相似的执行流程,那么将字节码转换为不同架构的二进制代码的工作量也会大大降低,这就降低了转换底层代码的工作量。使得 V8 移植到不同的 CPU 架构平台更加容易

    整体上权衡利弊,采用字节码也许是最优解。之所以说是最优解,是因为采用字节码除了降低内存之外,还提升了代码的启动速度,并降低了代码的复杂度,而牺牲的仅仅是一点执行效率

六、优化字节码


6.1 解释执行

生成了 字节码 之后, Ignition 解释器 逐条解释执行 字节码,并输出执行结果。

Ignition 解释器的架构设计:

Preview

如图所示,Ignition 解释器是基于寄存器的架构,内存中各个部分的含义:

  • StackCheck字节码指令就是检查栈是否达到了溢出的上限。
  • Ldar表示将寄存器中的值加载到累加器中。
  • Add表示寄存器加载值并将其与累加器中的值相加,然后将结果再次放入累加器。
  • Star表示把累加器中的值保存到某个寄存器中。
  • Return结束当前函数的执行,并将控制权传回给调用方。返回的值是累加器中的值。

Ignition 解释执行的过程:

  1. 使用内存中的一块区域来存放字节码
  2. 使通用寄存器用来存放一些中间数据
  3. PC 寄存器用来指向下一条要执行的字节码
  4. 栈顶寄存器用来指向当前的栈顶的位置。

七、生成机器码


八、优化机器码


8.1 监控器

监控器 监控 Ignition 解释器 的执行过程。在Ignition 解释执行字节码的过程中,收集代码信息,如果发现了某一段代码会被重复多次执行,那么监控机器人就会将这段代码标记为 热点代码(HotSpot)。当某段代码被标记为热点代码 后,V8 就会将这段字节码丢给TurboFan优化编译器

8.2 编译器

TurboFan 优化编译器 将热点的字节码编译为高效的机器码,然后当再次执行这段被优化的代码时,只需要执行编译后的机器码就可以了,这样就大大提升了代码的执行效率。

8.3 反优化

JavaScript 是一种非常灵活的动态语言,对象的结构属性是可以在运行时任意修改的,而经过优化编译器优化过的代码只能针对某种固定的结构,一旦在执行过程中,对象的结构被动态修改了,那么优化之后的代码势必会变成无效的代码。这时候优化编译器就需要执行反优化操作。经过反优化的代码,下次执行时就会回退到解释器解释执行。

反优化 对象的形状被动态修改了,隐藏类失效,触发发优化机制

九、汇总优化策略


9.1 重新引入字节码

上面有提到,且完整

9.2 字节码缓存

上面有提到,且完整

9.3 惰性编译

上面与提到,且完整

9.4 预解析

上面有提到,且完整

9.5 快属性与慢属性

JavaScript 中的对象由一组组属性组成的。所以最简单的方式是使用一个字典来保存属性,但是由于字典非线性结构,所以如果使用字典,那么查询效率会大大降低。所以为了提升查找效率, V8 内部存储对象的机制以及 V8 提升对象访问属性访问速度的策略 如下:

  1. V8 在对象中添加了两个隐藏属性: 排序属性常规属性; 数字属性 称为 排序属性, 在 V8 中称为 elements ; 字符串 属性被称为 常规属性 , 在 V8 中称为 properties;

  2. 数字属性 按照索引值大小升序排列, 字符串属性 根据创建时的顺序升序排列;

  3. 通过引入 elements 属性和 properties 属性, 加快了 V8 查找属性的速度

  4. 为了进一步提升查找效率, V8 实现了内置属性的策略:

    • properties 属性(常规属性) 少于一定数量时, V8 就会将这些常规属性直接写进对象中,这样节省了一个中间步骤

    • 当对象中的属性过多时,或者存在反复添加或者删除属性的操作,那么 V8 就会将线性的存储模式降级为非线性的字典存储模式,这样虽然降低了查询速度,但是却提升了修改对象的属性的速度。

      • 通常, 我们将保存在线性结构中的属性称之为快属性,因为线性结构中只需要通过索引即可访问到属性。虽然访问线性结构速度快,但是如果从线性结构中添加或者删除大量的属性时,执行效率非常低,因为过程中会产生大量时间和内存开销。

      • 将保存在非线性结构中的属性称之为慢属性

9.6 隐藏类(Hide Class)

基于以上块属性、慢属性,虽然在一定程度上加快了访问对象属性的速度,但是还是非常的慢且耗时,我们通过分析动态语言静态语言,来进一步提升访问对象属性的速度

静态语言执行高效的原因? 动态语言执行慢的原因

静态语言 编译时,对象的形状(结构)固定, 代码执行的过程中 , 对象的形状无法改变 , 因此访问对象属性的时候, 编译器会直接将该属性相对于该对象的偏移值写入指令。因此可以直接通过偏移量来查询对象属性值,中间没有任何查找环节。因此静态语言执行高效。而 JavaScript动态语言,V8 事先不知道类型的形状,更不知道属性相对于对象的偏移量,动态查找属性,执行前需要编译。因此, JavaScript 实现了 隐藏类

隐藏类JavaScript 中动态类型转换为静态类型的一种技术,可以消除动态类型的语言执行速度过慢的问题。

隐藏类过程

  1. 假设JavaScript是静态语言

    • 对象创建好了之后就不会添加新的属性

    • 对象创建好了之后也不会删除属性

  2. **然后V8会为每个对象创建一个隐藏类,对象的隐藏类中记录了该对象一些基础的布局信息**包括以下两点:

    • 对象中所包含的所有的属性

    • 每个属性相对于对象的偏移量

  3. 有了隐藏类之后,那么当 V8 访问某个对象中的某个属性时,就会先去隐藏类中查找该属性相对于它的对象的偏移量,有了偏移量和属性类型,V8 就可以直接去内存中取出对于的属性值,而不需要经历一系列的查找过程,那么这就大大提升了 V8 查找对象的效率。比如访问o.x的过程为: 查找对象 o 的隐藏类,再通过隐藏类查找 x 属性偏移量,然后根据偏移量获取属性值

  4. 一旦对象的形状发生了改变,这意味着 V8 需要为对象重建新的隐藏类,这就会带来效率问题。为了避免一些不必要的性能问题

隐藏类要点:

  • V8 中,每个对象都有一个隐藏类,隐藏类在 V8 中又被称为 map

  • V8 中,每个对象的第一个属性的指针都指向其 map 地址

  • map 描述了其对象的内存布局,比如对象都包括了哪些属性,这些数据对应于对象的偏移量是多少

  • 如果添加新的属性,那么需要重新构建隐藏类

  • 如果删除了对象中的某个属性,同样也需要构建隐藏类

开发时,我们应该怎样使用对象?

  1. 使用字面量初始化对象时,要保证属性的顺序是一致的
  2. 尽量使用字面量一次性初始化完整对象属性
  3. 尽量避免使用 delete 方法。delete 方法会破坏对象的形状,同样会导致 V8 为该对象重新生成新的隐藏类。

9.7 内联缓存

背景: 虽然隐藏类能够加速查找对象的速度,但是在 V8 查找对象属性值的过程中,依然有查找对象的隐藏类和根据隐藏类来查找对象属性值的过程。如果一个函数中利用了对象的属性,并且这个函数会被多次执行,那么 V8 就会考虑,怎么将这个查找过程再度简化,最好能将属性的查找过程能一步到位。

内联缓存(Inline Cache) 简称 IC , 就是在 V8 执行函数的过程中,会观察函数中一些调用点 (CallSite) 上的关键的中间数据,然后将这些数据**缓存&&起来,当下次再次执行该函数的时候,V8 就可以直接利用这些中间数据,节省了再次获取这些数据的过程,因此 V8 利用 IC,可以有效提升一些重复代码的执行效率。

内联缓存的过程:

IC 会监听每个函数的执行过程,并在一些关键的地方埋下监听点,这些包括了加载对象属性 (Load)给对象属性赋值 (Store)、还有函数调用 (Call)V8 会将监听到的数据写入一个称为反馈向量 (FeedBack Vector) 的结构中,同时 V8 会为每个执行的函数维护一个反馈向量。有了反馈向量缓存的临时数据,V8 就可以缩短对象属性的查找路径,从而提升执行效率。

  • 1. 反馈向量是一个结构,它由很多项组成的,每一项称为一个插槽(Slot)V8 会依次将执行函数的中间数据写入到反馈向量插槽

  • 2. 每个插槽中包括了插槽的索引 (slot index)、插槽的类型 (type)、插槽的状态 (state)、隐藏类 (map) 的地址、还有属性的偏移量

  • 3. 插槽的类型 包括:

    • LOAD 加载类型
    • STORE 缓存存储类型
    • CALL 函数调用类型
  • 4. V8 使用 反馈向量缓存数据 的过程: 当V8再次调用函数时,会在对应的插槽中查找相应的偏移量,之后 V8 就可以直接去内存获取相应值

但是针对函数中的同一段代码,如果对象的隐藏类是不同的,那么反馈向量也会记录这些不同的隐藏类,这就出现了多态超态的情况。我们在实际项目中,要尽量避免出现多态或者超态的情况。

  • 如果一个插槽中只包含 1 个隐藏类,那么我们称这种状态为单态 (monomorphic)
  • 如果一个插槽中包含了 2~4 个隐藏类,那我们称这种状态为多态 (polymorphic)
  • 如果一个插槽中超过 4 个隐藏类,那我们称这种状态为超态 (magamorphic)

参考资料


Js是怎样运行起来的?