Skip to content

V8 工作原理

v8 引擎是如何解析一段 js 代码的?

可以把 JavaScript 的编译看成两部分:

  • 第一部分从一段 JavaScript 代码编译字节码,然后解释器解释执行字节码

  • 第二部分深度编译,将活跃的字节码编译二进制,然后直接执行二进制

  • 无论哪个阶段都需要 编译

    详细流程:

    v8垃圾回收

1. 生成抽象语法树(AST)和执行上下文 AST

抽象语法树
  • 生成抽象语法树需要进行词法分析语法分析(即先分词再解析),如果这个过程中语法有误,则会抛出异常

  • 经过了语法分析和词法分析之后就生成 AST ,接下来 V8 就会生成该段代码的执行上下文

    词法分析图: 分词解析

2. 生成字节码

  • 解释器 Ignition 就登场了,它会根据 AST 生成字节码,并解释执行字节码。
  • 字节码就是介于 AST 和机器码之间的一种代码。但是与特定类型的机器码无关,字节码需要通过解释器将其转换为机器码后才能执行。 分词解析 早期的 v8 直接将 ast 编译成机器码,效率很,但是随着 chrome 在手机上的普及,运行内存是硬伤,机器码会消耗运行内存,早期的手机运行内存小一般只有 512M,所以 chrome 被迫进行重新架构

3. 执行代码

通常,如果有一段第一次执行的字节码,解释器 Ignition 会逐条解释执行。解释器 Ignition 除了负责生成字节码之外,它还有另外一个作用,就是解释执行字节码。在 Ignition 执行字节码的过程中,如果发现有热点代码(HotSpot),比如一段代码被重复执行多次,这种就称为热点代码,那么后台的编译器 TurboFan 就会把该段热点的字节码编译为高效的机器码,然后当再次执行这段被优化的代码时,只需要执行编译后的机器码就可以了,这样就大大提升了代码的执行效率。

字节码配合解释器和编译器是最近一段时间很火的技术,比如 Java 和 Python 的虚拟机也都是基于这种技术实现的,我们把这种技术称 JIT(即时编译)

疑问:🤔️

1. v8 引擎中的编译器编译的机器码存在哪里?什么时候销毁 ?

  • 机器码存储在内存中的一块特定区域,它可以被 V8 引擎快速访问和执行。一旦热点代码被编译成机器码并存储在代码缓存中,下次执行相同的代码时,V8 引擎会直接从代码缓存中读取机器码,并执行它,而无需再次进行解释和编译的过程。
  • 至于机器码的销毁,一般情况下,机器码会在不再需要时被销毁。V8 引擎会进行垃圾回收(Garbage Collection),其中包括对不再使用的机器码进行回收和释放内存。

2. 为什么一般情况下 js 执行时间越长,执行效率越高?

一般情况下,执行的时间越长, 就会有更多的代码成为了热点代码,并被 v8 的编译器 TurboFan 编译成了机器码,所以执行效率会越高。

为什么 js 要分栈和堆呢?全部存储在栈中不就好了吗?

不行,因为 v8 引擎需要栈来维护程序执行时的上下文的状态,如果数据都放在栈空间的话,会影响上下文切换的效率,进而影响到整个程序的运行效率

v8 引擎的函数预编译与闭包机制

javascript
function foo() {
  var myName = '不一样的少年';
  let test1 = 1;
  const test2 = 2;
  var innerBar = {
    setName: function (newName) {
      myName = newName;
    },
    getName: function () {
      console.log(test1);
      return myName;
    },
  };
  return innerBar;
}
var bar = foo();
bar.setName('少年');
bar.getName();
console.log(bar.getName());
  1. 当 v8 引擎执行到 foo 函数时,首先会编译,并创建一个空执行上下文。
  2. 在编译过程中,遇到内部函数 setName,v8 引擎还要对内部函数做一次快速的词法扫描,发现该内部函数引用了 foo 函数中的 myName 变量,由于是内部函数引用了外部函数的变量,所以 v8 引擎判断这是一个闭包,于是在堆空间创建换一个“closure(foo) v8 是无法访问的),用来保存 myName 变量。
  3. 接着继续扫描到 getName 方法时,发现该函数内部还引用变量 test1,于是 v8 引擎又将 test1 添加到“closure(foo)”对象中。这时候堆中的“closure(foo)”对象中就包含了 myName 和 test1 两个变量了。
  4. 由于 test2 并没有被内部函数引用,所以 test2 依然保存在调用栈中。

结论:函数没有执行前会进行预编译,预编译阶段就会扫描函数内部或者对象内中的函数内部是否存在闭包,如果有则开辟对应的内存空间进行存储

v8 引擎的垃圾回收策略

  • 垃圾回收策略一般分为手动回收和自动回收,java python JavaScript 等高级语言为了减轻程序员负担和出错概率采用了自动回收策略。
  • JavaScript 的原始类型数据和引用数据是分别存储在栈和椎中的,由于栈和堆分配空间大小差异,垃圾回收方式也不一样。
  • 中分配空间通过 ESP 的向下移动销毁保存在栈中数据;
  • 中垃圾回收主要通过副垃圾回收器(新生代)主垃圾回收器(老生代)负责的,副垃圾回收器采用 scavenge 算法将区域分为对象区域和空闲区域,通过两个区域的反转让新生代区域无限使用下去。主垃圾回收器采用 Mark-Sweep(Mark-Compact Incremental Marking 解决不同场景下问题的算法改进)标记清除算法进行空间回收的。

结论: 无论是主副垃圾回收器的策略都是标记-清除-整理三个大的步骤。另外还有新生代的晋升策略(两次未清除的),大对象直接分配在老生代。

详解:

v8垃圾回收

新生区负责存生命周期比较短的对象(副垃圾回收器)老生区负责存生命周期比较长的对象(主垃圾回收器)

  • 副垃圾回收器: Scavenge 算法来处理。所谓 Scavenge 算法,是把新生代空间对半划分为两个区域,一半是对象区域,一半是空闲区域,当对象区域的内存快满的时候就会执行一次垃圾回收,对对象区域的垃圾进行标记,然后将存活的对象搬到空闲区域,这个复制的过程也完成了内存整理,所以不存在内存碎片,最后对象区域和空闲区互相调换位置,这样就完成了一次垃圾回收。(js 引擎采用了对象晋升策略,也就是经过两次副垃圾回收之后依然存活的对象就会被移到老生区中 )
  • 主垃圾回收器:因为老生区比较大,不像新生区那么小,所以不能采用 Scavenge 算法,不然会导致很大的性能开销和执行效率,同时还会占用掉一半的空间(空闲区域),因此主垃圾回收器采用的是标记 - 清除(Mark-Sweep)的算法进行垃圾回收的,首先是标记过程阶段。标记阶段就是从一组根元素开始,递归遍历这组根元素,在这个遍历过程中,能到达的元素称为活动对象,没有到达的元素就可以判断为垃圾数据。

接下来就是垃圾的清除过程。它和副垃圾回收器的垃圾清除过程完全不同,你可以理解这个过程是清除掉红色标记数据的过程,可参考下图大致理解下其清除过程: v8垃圾回收 上面的标记过程和清除过程就是标记 - 清除算法,不过对一块内存多次执行标记 - 清除算法后,会产生大量不连续的内存碎片。而碎片过多会导致大对象无法分配到足够的连续内存,于是又产生了另外一种算法——标记 - 整理(Mark-Compact),这个标记过程仍然与标记 - 清除算法里的是一样的,但后续步骤不是直接对可回收对象进行清理,而是让所有存活的对象都向一端移动,然后直接清理掉端边界以外的内存。可以参考下图: 标记整理 所以无论是新生代还是老生代进行垃圾回收都需要经过 标记-清除-整理三个阶段

v8 有没有使用引用计数的机制?

  • 没有,因为引用计数算法有问题,即循环引用的情况下会导致内存泄漏。
  • 循环引用指的是一组对象互相引用,形成一个环状结构,而且这些对象之间没有被外部引用,在这种情况下,即使这些对象不再被程序使用,他们的引用计数仍然不为 0,无法被回收,这就导致了内存泄漏,所以现在流行的垃圾回收器都没有采用引用计数的方式!

全停顿

因为垃圾回收是运行在渲染进程中的主线程上的,也就是说在进行垃圾回收机制的时候,正在执行的 js 脚本将会被暂停,待垃圾回收机制完毕后再恢复执行 js 脚本,这个过程就叫全停顿 全停顿

解决全停顿问题:

增量标记

全停顿对老生代的影响比较大,因为老生代的存储比较大,新生代的区域比较小,所以影响不大 为了降低老生代垃圾回收带来的卡顿,v8 引擎使用增量标记算法,可以把一个完整的垃圾回收任务拆分为很多小的任务,这些小的任务执行时间比较短,可以穿插在其他的 JavaScript 任务中间执行,这样就不会让用户因为垃圾回收任务而感受到页面的卡顿了。 增量标记