聊聊V8引擎

首先要记住一个知识点,在浏览器中有那么一个部分叫js引擎,专门用来翻译js代码,把js代码翻译成机器能够执行的字节码或者机器码,以此提高执行效率。

js引擎有很多,不同浏览器使用的可能都不一样,这里主要讲的是V8引擎,讲它是因为chrome用的是它,而且Node.js用的也是它(说白了就是当今使用量比较大)

定义:V8 is Google’s open source high-performance JavaScript and WebAssembly engine, written in C++. It is used in Chrome and in Node.js, among others.(V8时gooogle开源的高效js和WebAssembly引擎,它用C++编写,在Chrome和Node.js都有使用)

V8的主要工作:编译和执行JS代码,处理调用栈,内存的分配,垃圾的回收,其中,全停顿(stop-the-world),分代式(generational)是实现精确的垃圾回收的关键点之一。

V8的编译和执行JS代码

js引擎的主要功能就是将js代码翻译出来并执行,这里会用到三种结构:

  • 解析器(parser):将JS源代码解析成抽象语法树AST(AST是啥下次再说)

  • 解释器(interpreter):将AST解释成字节码bytecode,也有直接解释执行bytecode的能力

  • 编译器(compiler):负责编译出运行更加高效的机器代码

早期V8引擎

没有解释器,但是有两个编译器

编译流程:JS由解析器解析之后生成AST抽象语法树,然后用full-codegen编译器直接使用AST编译出机器代码,不进行任何转换,full-codegen也称为基准编译器,编译出来的是没有被优化过的代码,这样的话好处就是第一次执行就是高效的机器代码,当js运行一段时间后,分析器线程收集到一定的数据,就会将数据传给crankshaft来做代码优化,需要优化的代码直接解析成AST,然后crankshaft将这个AST直接编译成优化后的机器代码,以此提升运行的效率

好处:减少了中间层转换,提高了运行效率,加快代码的构建

缺点:生成的机器码会占用大量内存,缺少中间层机器码,无法实现一些优化策略,无法很好的支持和优化JS的新语法特性

新的V8结构:

编译流程:JS由解析器解析之后生成AST抽象语法树,然后用Igniton基准解释器生成bytecode字节码,此时AST就被清除掉了,释放内存空间;

字节码直接被解释器执行,同时作为基准执行模型,字节码更加简洁 ,大小相当于等效的基准机器代码的25%-50%,解释器会不断收集优化JS代码,并把这些信息发送给新的编译器TurboFan,会根据这些信息和字节码,生成优化后的机器代码,引擎处于字节码和优化后的机器代码并行执行的阶段,

某些情况下优化后的机器代码会被反向编译回字节码(deoptimization),因为js是动态语言,可能会导致ignition收集了错误信息,比如变量未声明类型,一直被以为是整型,优化后的机器代码就会以为这个是整型突然传了一个字符串,编译器就不能识别这个字符串,就会deoptimization回到字节码,把这个函数给字节码执行,所以尽量不要把一个变量类型变来变去,否则会对这个引擎造成影响

优化策略:

  1. 函数只声明未被调用,不会被解析成AST
  2. 函数只被调用一次,bytecode直接被解释执行
  3. 函数被调用多次,可能会被标记为热点函数,可能会被编译成机器代码

优点:解释器解释字节码的速度比编译成机器码要快,所以网页初始化解析执行js的时间缩短了,网页onload时间变少了

在优化的时候不用回去源码,直接回退到字节码。

垃圾回收机制

在看V8垃圾回收机制之前,先来回顾一下其他文档常见的垃圾回收算法——引用计数和标记清除。

  • 引用计数:跟踪记录每个值被引用的次数,把对象是否不再需要这个概念简单理解为,对象有没有其他对象引用它,发现一次引用+1,解除一次引用-1,直到监听到引用为0时认为可以被回收
  • 标记清除:标记变量何时进入环境以及何时离开环境,把对象是否不再需要这个概念理解为对象是否可以获得

因为引用计数会存在循环引用的问题(对象与对象可能会互相引用,导致计数值一直都不会变为0,所以永远不会被回收),所以现在大部分的垃圾回收使用的都是标记清除算法。

前面也讲了V8垃圾回收的关键是全停顿和分代式,其中分代式是整个垃圾回收机制的核心。在V8的垃圾回收算法里,一般按照对象的存活时间将内存分代(新生代和老生代),新生代一般存放的是存活时间较短的对象,分配内存的时候只保存一个指向空间的指针,根据分配对象的大小分配指针就好,一旦发现空间快满了就进行一次垃圾回收。老生代一般存放的是存活时间较长,或者常驻内存的对象,因此老生代保存的数据量一般会比较大。新生代的对象在存活一段时间后就会被移动到老生代中,因此对比新生代而言,老生代的垃圾回收频率较低。

新生代的垃圾回收过程主要使用Scavenge算法(该算法的具体实现主要是Cheney算法),算法将新生代内存一分为二,每个部分的空间称为semispace,一个叫from空间,另一个叫to空间,这两个空间始终有一个是闲置状态的,核心思想如下:

  1. 被声明的对象会分配到from空间中
  2. 进行垃圾回收时,from空间中有存活的对象,就将对象复制到to空间中去
  3. 非存活的对象就会自动被回收(这个时候from和to空间实际上就转换了位置)
  4. 如果对象在经过多次复制之后依旧存活,它就会被认为是生命周期较长的对象,这时它就可以光荣的晋升到老生代中去

老生代因为管理着大量的存活对象,用Scavenge算法显然不合适(大量数据的移动是个力气活,而且会浪费一半的内存),因此使用标记清除(mark-sweep)和标记整理(mark-compact)进行管理

标记清除核心思想如下:

  1. 垃圾回收器会在内部构建一个根列表,以此为出发点寻找那些可以被访问到的变量
  2. 每次从根节点出发,遍历可以访问到的子节点,标记成活动的,根节点到不了的地方就会被认为是垃圾。
  3. 释放所有非活动的内存块

标记清除存在一个问题——经历过标记清除后,因为垃圾回收过程中被清理的对象不一定会是连续的,所以此时内存空间可能会出现内存碎片,导致内存可用性降低,如何将这些碎片进行整理,就是标记整理的职责所在:

  1. 非活动对象清除完毕后,存活下来的对象会全部往堆内存的一端移动(将存活对象整理之后放置到连续的内存空间内)
  2. 移动完成后清除掉边界外的全部内存

到此为止一次完整的垃圾回收过程就完成啦,那么全停顿又是什么呢?

全停顿顾名思义就是在垃圾回收的时候需要将逻辑暂停下来,等执行完回收再继续,这个其实也很好理解,首先,JS是单线程机制,垃圾回收和主线程不能一起执行,如果不暂停应用逻辑,很可能会出现JS应用逻辑和垃圾回收器看到不一致的情况。新生代默认配置较小,而且存活的对象较少,所以全停顿影响也不会很大,但是老生代对象比较多,垃圾回收的过程持续时间较长,全停顿就会较大,此时需要设法改善,因此,V8引擎引入了增量标记(Incremental Marking)的概念,将原本一次性遍历的操作拆分成许多次操作,在主线程空闲的时候进行标记,知道标记完整个堆内存(和fiber有点类似)。这样做的话可以尽可能少的减少影响主线程的任务,避免了应用的卡顿。同时后续V8也引入了延迟清理和增量式整理的概念
关于v8引擎的编译和执行主要参考了这个视频

猜你喜欢

转载自blog.csdn.net/weixin_43207208/article/details/117047249