学习v8的一些笔记

看了几篇v8整体介绍的文章,做一下笔记。关于v8还有很多需要详细分析的地方,后续再慢慢分析整理吧~

Source to Binary Jounrney of V8 javascript engine

http://eternalsakura13.com/2018/06/16/nodefest_v8/

这篇文章从整体上介绍了JS代码在V8中的执行过程,包括从源码到字节码,最后通过Turbofan生成高度优化的代码。

解析 Parsing

js代码首先会被解析成抽象语法树(AST),由于直接解析所有的代码会导致效率上的问题,因为有些代码在解析后可能不会执行到,所以引入了延迟解析。

预解析

会由v8::internal::PreParser事先解析所有的函数布局,能够实现

  • 初步的语法错误检查
  • 函数的范围生成

延迟解析

当函数在被调用的时候V8才会对其进行解析,详细的分析等我看完相关文章后再来补充

AST

这部分内容我没有太理解,大概就是V8有一个自己的AST解析器,该解析器对js代码中的一些构造函数、for循环语句、Spread运算符进行相应的处理。例如会把for语句封闭在一个块中,用来声明一个变量

Ignition

Ignition是一个基于寄存器的解释器,负责对字节码进行解析。

首先会把AST转换成相应的1~4字节的字节码,由v8::internal::AstVisitor实现。生成的字节码存储在BytecodeArray中,BytecodeArray则是基于函数而存在。

在开始执行字节码之前,会从InterpreterEntryTrampoline的Builtin代码执行,InterpreterEntryTrampoline被编译成Assembly,并且被当成普通的C函数调用。

字节码都有着对应的功能实现函数,字节码执行的时候只需要依次调用每个字节码对应的处理函数就行,执行完一条字节码后会自动的调用下一个字节码。

CodeStubAssembler

我目前的理解是,V8要在各个架构下工作,每个架构的汇编代码是不一样的,为了不需要每次都编写汇编代码,就引入了CodeStubAssembler。

它用到了DSL,全称是DomainSpecificLanguage,利用它来生成asm,即使不熟悉特定架构的汇编语言,也可以编写DSL程序来添加新功能代码,而且只需要将CodeStubAssembler得到的结果添加至Dispatch table索引中,即可添加新的字节码。

MacroAssembler负责在代码生成阶段输出实际的汇编代码,它内部调用的Assembler会输出特定于每个体系结构的代码。

Builtins和Runtime

Builtins

  • Builtins是在v8启动时被编译好的asm code fragment
  • Call Builtin就像call一个函数
  • 也被称为Stub
  • 没有进行runtime优化

Runtime

  • Runtime是可以从Builtins和其他汇编代码中调用的c++代码
  • 连接javascript和c++
  • 也没有runtime优化

Inline-Caching

负责对之前对对象属性的访问进行缓存,来加速之后对相同形状的对象属性的访问。

第一次访问将通过最通用的方法(从HashMap或者FixedArray加载属性)来查找属性的位置,然后在后续的访问中直接依据缓存的信息直接获取属性的值,而不需要进行耗时的查找。

在实际中当然会遇到当前要访问的对象与之前缓存过的对象形状不一致的情况。Cache一共有四种状态:

  • PreMonomorphic
  • Monomorphic(单态)
  • Polymorphic(多态)
  • Megamorphic

Monomorphic是访问最快的情况,表示当前仅仅遇到了一种形状的对象,因此所有的属性访问操作只需要一步即可完成。

Polymorphic则是Cache中有着多个形状,将对缓存的Map信息进行循环搜索(Map存储在FixedArray中,从多个Map中搜索并访问对应的属性)

Megamorphic是由于Miss次数太多,停止对Map进行记录的状态。
利用Stub的GetProperty来从hash表中进行搜索,是属性访问最慢的状态。

优化 Optimization

V8会对变热的或者比较小的代码进行优化,较小的代码指字节码长度小于90,热代码指的是代码较长且被调用了2次。

V8引入了优化预算(Optimization Budget)来控制优化,在字节码执行期间会被分配给每个函数,当他的值小于0的时候会成为优化候选代码。

对循环的优化

再循环里面会输出JumpLoop的字节码,通过JumpLoop,返回终点地址的值的偏移量被加权,会从之前的Budget中扣除一个值,当他变成0的时候触发对loop的优化(OptimizeAndOSR)。

OSR是指代码被编译以及优化成机器码后,jump的终点将会被改变,也就是将跳转到优化好的机器码处执行。

对函数的优化

函数执行完后会调用Return Bytecode,通过计算BytecodeHandler中Return Bytecode的调用次数,如果超过阈值,就会触发中断(中断处会对budget进行检查),对bytecode进行编译,然后使用编译好的机器码替换函数体。

Turbofan

Turbofan是V8用来编译字节码的组件,编译开始时会基于字节码生成IR,然后在IR的基础上生成图(Graph)并进行优化。

Turbofan对图的优化包括:

  • inline 内联函数调用
  • trimming 未到达节点删除
  • type 类型推断
  • typed-lowering 根据类型将表达式和指令替换为更简单的处理
  • loop-peeling 取出循环内的处理
  • loop-exit-elimination 删除Loop Exit
  • load-elimination 删除不必要的读取和检查
  • simplified-lowering 用更具体的值来进行指令的简单转换
  • generic-lowering 将JS前缀指令转换为更简单的调用和stub调用
  • dead-code-elimination 删除无法访问的代码

最终,InstructionSelector分配寄存器,依据Graph,CodeGenerator将生成机器码,并将汇编代码写入ProgramCounter

解优化 Deoptimization

当有预期之外的值传递给优化后的代码时将触发Deoptimization,因为投机优化的原则就是依据之前看到的值信息来假定将来遇到的也是相同类型的值,所以出现非预期的值后假设不成立,当然就需要解优化,代码将返回到未优化前的字节码开始执行。

An Introduction to Speculative Optimization in V8

https://ponyfoo.com/articles/an-introduction-to-speculative-optimization-in-v8

这篇文章主要是讲TurboFan是如何工作的。先大致介绍了V8是如何工作的:

  • 用户编写的JS代码会首先被V8解析成抽象语法树(AST)
  • 抽象语法树会被传递给V8的Ignition Interpreter,并将AST转换成一系列的字节码,字节码将会被Ignition解释执行。
  • 在执行期间Ignition会分析某些操作输入的性能分析信息或反馈,其中的一些信息将被Ignition自己使用,例如访问对象属性的操作,要查找对象属性的偏移是比较费时的操作,通过将获取对象属性所需要的信息缓存起来,加速下次访问的速度,这被称为内联缓存(Inline Cache)。
  • 收集的反馈信息更重要的是被TurboFan所使用,以生成高度优化的机器代码。优化中使用了一种叫做投机优化(Speculative Optimization)的技术,优化编译器依据到目前为止见到的值的信息,并认为在将来还会遇到相同类型的值。

文章中举了一个很好懂的例子,一个简单的执行a+b功能的函数,JS中的+比较特殊,它不仅能进行两个数之间的加法运算,还能进行字符串之间的连接操作。而且JS中的数有很多种,有Smi(Small Integer),更广范围的Number以及BigInt。因此在执行加法运算符之前需要先判断两边操作数的类型,这里面会进行一系列的函数调用,如果每次计算都需要这样判断,那么效率是很低的。

Ignition Interpreter会收集执行期间的值的信息,如果到目前为止进行的还是两个Smi之间的加法运算,那么会把反馈信息保存到Feedback Vector中,在触发优化之后,如果下次在调用add函数,那么执行的将会是优化后的二进制代码,相比于未优化之前的代码,会有更高的效率。

生成的优化代码中会有类型检测,如果参数不是Smi,那么程序会进行Deoptimize操作,而不会进行状态的回退,否则程序将有可能陷入到解优化循环中。

V8工作原理

https://juejin.im/post/5d74bd5be51d4561c02a25af

这篇文章对v8的内存模型以及解析执行js代码的过程进行了介绍。解析js代码的过程前面有说过,这里重点关注v8的内存模型。

栈空间与堆空间

JS引擎主要依据栈来维护程序执行期间的上下文状态,并且普通类型的变量是保存在执行上下文中。引用类型的变量保存在堆中,在栈中保存对它的引用。

垃圾回收机制

栈中的垃圾回收比较好理解,和普通程序的栈操作一样,改变ESP指针的值就可以实现栈空间的分配和回收。

堆中的垃圾回收比较麻烦一点,V8里面对内存进行了分代(新生代和老生代),新生代存放生存时间很短的对象,容量只有 1~8M;老生代中存放生存时间久的对象,容量很大。

对应的有两个垃圾回收器(分代回收):

  • 主垃圾回收器–>老生代内存
  • 副垃圾回收器–>新生代内存

回收主要是:先标记内存空间中的活动对象和非活动对象;标记完成后清理内存中被标记为可清理的对象;最后对内存碎片进行整理。

副垃圾回收器将内存分为了对象区域和空闲区域,当对象区域被写满时执行回收操作,把活动对象拷贝到空闲区域,然后将空闲区域和对象区域反转即可。对象晋升:若经两次GC回收后对象仍然在就会将其移到老生代中

主垃圾回收器也是采用标记回收的策略,从根元素开始遍历,能访问到的作为活动对象,其他的作为垃圾数据。除了标记-清除算法外还有标记-整理算法,主要是整理碎片内存。

垃圾回收在执行的时候也有策略,若是一次性执行完所有的垃圾回收,那么用户体验会很不好,浏览器会直接卡住。所以这里采用了类似于操作系统里面的分时操作,将标记和整理的过程与js的执行过程穿插进行,每次标记一小部分,也就是增量标记。