摘要:极客时间《图解 Google V8》V8 编译流水线笔记,运行时环境、机器代码、堆和栈、延迟解析、字节码、隐藏类、内联缓存
运行时环境
V8 在执行 JavaScript 代码之前就已经准备好了代码的运行时环境,环境包括了堆空间和栈空间、全局执行上下文、全局作用域、内置的内建函数、宿主环境提供的扩展函数和对象,还有消息循环系统。
宿主环境
浏览器和 Node.js 都可以可以作为 V8 的宿主环境。V8 的核心是实现了 ECMAScript 标准,提供了 ECMAScript 定义的一些对象和一些核心的函数(Object、Function、String)。此外,还提供了垃圾回收器、协程等基础内容,但这些需要宿主环境的配合才能工作。宿主则提供了很多 V8 执行 JavaScript 时所需的基础功能部件:
1、构造数据存储空间:堆空间和栈空间
宿主在启动 V8 的过程中,会同时创建堆空间和栈空间,再继续往下执行,产生的新数据都会存放在这两个空间中。
栈空间主要是用来管理 JavaScript 函数调用的,栈是内存中连续的一块空间,所以在栈中每个元素的地址都是固定的,也因此栈空间的查找效率非常高,同时栈结构是“先进后出”的策略。在函数调用过程中,涉及到上下文相关的内容都会存放在栈上,比如原生类型、引用到的对象的地址、函数的执行状态、this 值等都会存在在栈上。函数执行完时,该函数的执行上下文就会被销毁。
堆空间是一种树形的存储结构,用来存储对象类型的离散的数据。堆中的数据不是线性存储的,所以堆空间可以存放很多数据。JavaScript 中除了原生类型的数据,其他的都是对象类型,比如函数、数组、window 对象、document 对象,它们都保存在堆空间中。查看《浏览器工作原理与实践》笔记(三)
2、全局执行上下文和全局作用域
有了基础的存储空间后,就要初始化全局执行上下文和全局作用域,它们是 V8 执行后续流程的基础。
V8 执行代码时会生成一个执行上下文,执行上下文中包含了变量环境、词法环境和 this 关键字这三部分。作用域就是变量与函数的可访问范围,规定了变量的生命周期。全局作用域中的对象在代码中的任何地方都能访问,其生命周期伴随着页面的生命周期。查看《浏览器工作原理与实践》笔记(二)
3、构造事件循环系统
宿主环境还需要构造事件循环系统,事件循环系统主要用来处理任务的排队和任务的调度。V8 没有自己的主线程和消息队列,只能由宿主环境提供,这样 V8 会和页面共用主线程,共用消息队列。如果 V8 执行函数过久,那么也会影响到页面交互性能。查看《浏览器工作原理与实践》笔记(四)
机器代码
准备好环境后,V8 需要将 JavaScript 编译成字节码或者二进制代码(查看《浏览器工作原理与实践》笔记(四)),然后再执行。编译后的程序是由一堆二进制代码组成的,而二进制代码是由一条条指令构成的。程序的执行,本质上就是 CPU 按照顺序执行一大堆以二进制存储的指令的过程。汇编代码采用助记符(memonic)来编写程序,代码中可以使用单词来表示二进制的指令,且汇编语言和机器语言是一一对应的。汇编语言转换为机器语言的过程称为“汇编”,机器语言转化为汇编语言的过程称为“反汇编”。
CPU 执行程序
内存中的每个存储空间都有其对应的独一无二的地址,CPU 通过指定内存地址,从内存中读写数据。编译后的程序由一堆二进制代码组成,二进制代码被装进内存中后,CPU 便可以从内存中取出一条指令,然后分析该指令,最后执行该指令。取出指令、分析指令、执行指令这三个过程称为一个 CPU 时钟周期。
CPU 中有一个 PC 寄存器,它保存了将要执行的指令地址。当程序存入内存中后,程序第一条指令的内存地址会被存入 PC 寄存器中。在下一个时钟周期时,CPU 就会根据 PC 寄存器中的地址,从内存中取出指令。
系统取出指令之后有以下操作:
1、读取指令,并将下一个指令的地址写入到 PC 寄存器中。
2、分析指令,并识别出不同的类型的指令,以及各种获取操作数的方法。寄存器的基础指令有:加载指令、存储指令、更新指令、跳转指令和 IO 读/写指令等。
3、执行指令,在执行指令的过程中,CPU 需要对数据执行读写操作。内存容量大,但读写速度慢。为了提高读写性能,CPU 引入了容量小但读写速度快的寄存器,将一些中间数据存放在寄存器中,这样就能加速 CPU 的执行速度。
4、指令完成后,通过 pc 寄存器取出下一条指令地址,重复以上步骤。
堆和栈
通常函数有两个主要的特性:
1、函数可以被调用,在一个函数中调用另外一个函数,当函数调用发生时,执行代码的控制权将从父函数转移到子函数,子函数执行结束之后,又会将代码执行控制权返还给父函数;
2、函数具有作用域机制,函数在执行的时候会将定义在函数内部的临时变量和外部环境隔离,临时变量只能在该函数中被访问,外部函数通常无权访问,当函数执行结束之后,存放在内存中的临时变量也随之被销毁。
通过函数的特性可以看出函数调用者的生命周期总是长于被调用者(后进),并且被调用者的生命周期总是先于调用者的生命周期结束 (先出),符合后进先出 (LIFO) 的策略。而栈结构十分符合这中需求,因此大部分高级语言都采用栈这种结构来管理函数调用。
函数在执行过程中,其内部的临时变量会按照执行顺序被压入到栈中,查看《浏览器工作原理与实践》笔记(二)。函数执行完成之后,需要“恢复现场”——将栈的状态恢复到函数执行之前的状态,查看《浏览器工作原理与实践》笔记(三)。在寄存器中保存一个永远指向当前栈顶的指针,这个指针通常存放在 esp 寄存器中,使用栈顶指针来控制添加新元素或者恢复执行现场。此外,还有一个用来保存添加新元素之前栈顶指针的 ebp 寄存器,这样恢复现场时,只需要将 ebp 寄存器中的栈顶指针取出并写入 esp 寄存器中即可。
在调用函数时,V8 会为该函数创建栈帧,栈帧中保存了该函数的返回地址和局部变量。每个栈帧对应着一个未运行完的函数,函数执行结束之后,V8 就会销毁对应的栈帧。esp 寄存器和 ebp 寄存器就是保存了调用者和被调用者两个函数的栈帧指针。因为栈空间是连续的,所以在栈上分配资源和销毁资源的速度非常快。但是在内存中分配一块连续的大空间是非常难的,这导致栈结构的容量不可能太大,如果重复嵌套执行一个函数,就会导致栈会栈溢出。如果需要保存大数据,就需要使用不是连续分配的堆空间,而在堆空间中保存数据的地址会被存放在栈中。
延迟解析
一次解析和编译所有的 JavaScript 代码,会严重影响到首次执行 JavaScript 代码的速度。而且解析完成的字节码和编译之后的机器代码都会存放在内存中,但内存资源十分宝贵,能省则省。因此所有主流的 JavaScript 虚拟机都实现了惰性解析,即在解析的过程中,如果遇到函数声明,那么会跳过函数内部的代码,并不会为其生成 AST 和字节码,而仅仅生成顶层代码的 AST 和字节码。但是由于 JavaScript 天生支持闭包,使得 V8 在实现惰性解析的过程中还需要支持闭包。
JavaScript 与闭包相关的三个特性:
1、可以在 JavaScript 函数内部定义新的函数;
2、内部函数中访问父函数中定义的变量;
3、因为 JavaScript 中的函数是一等公民,所以函数可以作为另外一个函数的返回值。
想要支持闭包,就需要面对两个问题:
1、父函数执行完成时,还存活的子函数中引用的父函数的变量要不要销毁
2、V8 使用了惰性解析,当解析到父函数时,V8 只解析父函数,而父函数内部定义的子函数会被跳过,那么也就无法知道子函数中是否引用了父函数中的变量
V8 引入预解析器来支持闭包,当解析顶层代码时,如果遇到了函数声明,预解析器会对该函数做一次快速的预解析。这样做有两个目的:
1、判断当前函数有无语法上的错误。如果有语法错误,就向 V8 抛出语法错误。
2、检查函数内部是否引用了外部变量。如果引用了外部的变量,预解析器会将这个变量的存储位置由栈存储改为堆存储,在下次执行到该函数的时候,直接使用堆中的引用。即便父函数执行结束,该变量也不会被释放,这样就解决了闭包所带来的问题。
字节码
JavaScript 是一门动态语言,动态语言的执行效率要低于静态语言,V8 为了提高执行效率,借鉴了很多静态语言的特性:
1、实现 JIT 机制
2、引入隐藏类以提升对象的属性访问速度
3、使用内联缓存以加速运算
字节码的优势
之前的 V8 为了提升代码的执行速度,会直接将 JavaScript 编译成未经优化的二进制机器代码,然后再执行这些未优化的二进制代码。如果某一段二进制代码执行频率过高,那么 V8 会将其标记为热点代码,热点代码会被优化编译器优化,优化后的机器代码执行效率更高。虽然二进制代码体积更大,但执行性能非常高效,而且 Chrome 还采用空间换时间的策略来来提升 JavaScript 代码的执行速度:
1、将运行时将二进制机器代码缓存在内存中;
2、当浏览器退出时,缓存编译之后二进制代码到磁盘上。
但这么做有两个弊端:
1、时间问题:编译时间过久,影响代码启动速度;
2、空间问题:缓存编译后的二进制代码占用更多的内存。
在 PC 端这些弊端还不明显,但在内存小的移动端则会非常影响体验。V8 团队权衡利弊,重构了架构,并引入字节码、解释器和新的优化编译器。这样做的优势:
1、解决启动问题:生成字节码的时间很短;
2、解决空间问题:字节码占用内存不多,缓存字节码会大大降低内存的使用;
3、代码架构清晰:不同架构的机器码是不一样的,而字节码的执行过程和 CPU 执行二进制代码的过程类似,相似的工作流程使得字节码转换为不同架构的二进制代码的工作量也大大降低,简化了程序的复杂度。
字节码就是指编译过程中的中间代码。它有两个作用:
1、解释器可以直接解释执行字节码;
2、优化编译器可以将字节码编译为二进制代码,然后再执行二进制机器代码
解释器
解释器有两种:
1、基于栈 (Stack-based) 的解释器,它使用栈来保存函数参数、中间运算结果、变量等,但同时也定义了少量的寄存器。基于堆栈的虚拟机在处理函数调用、解决递归问题和切换上下文时简单明快。大多数解释器都是基于栈的,比如 Java 虚拟机和早期的 V8.
2、基于寄存器 (Register-based) 的解释器,它支持寄存器的指令操作,使用寄存器来保存参数、中间计算结果,但同时也有堆栈的使用。现在的 V8 虚拟机就是基于寄存器设计的。
两种解释器的区别体现在它们提供的指令集体系上。
执行字节码
V8 执行一段 JavaScript 代码时,会先对 JavaScript 代码进行解析 (Parser),并生成为 AST 和作用域信息,之后 AST 和作用域信息被输入到 Ignition 解释器中,并将其转化为字节码,之后字节码再由 Ignition 解释器来解释执行。
具体过程:
1、函数字面量被解析为 AST 树的形态,可以在astexplorer.net查看解析后的 AST 结构。在生成 AST 的同时,还会生成作用域,函数体中声明的变量和函数参数都被放进作用域中,如果是普通变量,那么默认值是 undefined,如果是函数声明,那么将指向实际的函数对象。等到了执行阶段,作用域中的变量会在赋值后指向堆和栈中相应的数据。
2、有了 AST 和作用域后,Ignition 解释器中的字节码生成器 (BytecodeGenerator) 会根据 AST 生成以函数为单位的字节码,字节码也是一条一条的指令。
3、最后由 Ignition 解释器解释执行生成的字节码。解释器执行时主要有四个模块,内存中的字节码、寄存器、栈、堆。
执行过程与 CPU 执行二进制机器代码的模式相似:
1、使用内存中的一块区域来存放字节码;
2、使用通用寄存器来存放一些中间数据;
3、PC 寄存器用来指向下一条要执行的字节码;
4、栈顶寄存器用来指向当前的栈顶的位置。
累加器是一个非常特殊的寄存器,它主要用来保存中间的结果,现今的 CPU 通常有很多寄存器,所有或多数都可以被用来当作累加器。
常见的字节码指令:
1、StackCheck 字节码指令就是检查栈是否达到了溢出的上限,如果栈增长超过某个阈值,解释器将中止该函数的执行并抛出一个 RangeError,表示栈已溢出。
2、Ldar 表示将某个寄存器中的值加载到累加器中。
3、Star 表示把累加器中的值保存到某个寄存器中。
4、Add 表示把某个寄存器的值与累加器中的值相加,并将结果再次放入累加器。上图中Add a0, [0]
的 [0] 是反馈向量。
5、LdaSmi 将某个小整数加载到累加器中。
6、Return 结束当前函数的执行,并将控制权传回给调用方,累加器中的值作为返回值。
隐藏类
静态语言中,声明一个对象之前需要定义该对象的结构(形状),在编译的时候每个对象的形状都是固定的,那么属性相对于该对象地址的偏移值也是固定的,在执行访问属性的时候可以直接根据偏移值获取属性。而在 JavaScript 中,对象的属性可以任意增删改,要访问对象某个属性时就要按照具体规则一步一步来查询,效率自然没有静态语言高。
V8 借鉴静态语言而引入了隐藏类,先将 JavaScript 对象静态化:假设对象都是静态的,创建好的对象既不会添加新属性也不会删除属性。然后再为静态对象创建一个隐藏类,隐藏类中包含了该对象一些基础的布局信息,主要记录了对象中所有的属性和每个属性相对于对象的偏移量。V8 的隐藏类又称 map,每个对象的第一个属性的指针都指向其 map 地址。隐藏类描述了对象的属性布局,主要包括属性名称和每个属性对应的偏移量。V8 在查询对象属性时,会先查询 map 中该属性相对于对象的偏移量,之后就可以根据对象的起始值加上偏移量得到对象属性的值在内存中的位置。
如果两个对象的形状是相同的,即属性的个数、名称、顺序和值的类型都相同,那么 V8 就会为其复用同一个隐藏类。一旦对象的形状发生了改变,V8 会为对象重建新的隐藏类。为了避免一些不必要的性能问题,程序中尽量不要随意改变对象的形状,具体需要注意以下几点:
1、使用字面量初始化对象时,要保证属性的顺序是一致的。
2、尽量使用字面量一次性初始化完整对象属性。
3、尽量避免使用 delete 方法。
内联缓存
V8 执行函数的过程中,会观察函数中一些调用点 (CallSite) 上的关键的中间数据,然后将这些数据缓存起来,当下次再次执行该函数的时候,V8 就可以直接利用这些中间数据,节省了再次获取这些数据的过程,有效的提升了执行效率。这个提升函数执行效率的策略就是内联缓存 (Inline Cache),简称为 IC。
IC 会为每个函数维护一个反馈向量 (FeedBack Vector),它是一个表结构,每一项被称为一个插槽 (Slot),V8 会依次将执行函数的中间数据写入到反馈向量的插槽中(比如上一张图中Add a0, [0]
的 [0] 就是反馈向量)。每个插槽中包括了插槽的索引 (slot index)、插槽的类型 (type)、插槽的状态 (state)、隐藏类 (map) 的地址、还有属性的偏移量,其中插槽的类型有加载 (LOAD)类型、存储 (STORE) 类型和函数调用 (CALL) 类型。有了反馈向量,当下次需要执行相同的操作,V8 会从对应的插槽中找到对应的偏移量,之后就可以直接去内存中获取对应的值。
如果对象的形状不是固定的,那么 V8 会将新的隐藏类也记录在反馈向量中,这样一个反馈向量的一个插槽中就包含了多个隐藏类的信息,即一个插槽中可以有不止一个的隐藏类 map。如果一个插槽中只包含 1 个隐藏类,这种状态为单态 (monomorphic);如果一个插槽中包含了 2~4 个隐藏类,这种状态为多态 (polymorphic);如果一个插槽中超过 4 个隐藏类,这种状态为超态 (magamorphic)。如果 V8 发现一个插槽中不止一个 map,就会取出对象的隐藏类来一一对比,对比次数越多,则效率越低,因而要尽量保持单态。