JS 引擎初探

概述


JavaScript语言

JavaScript 本质上是一种解释性语言(目前其实是不太准确的)。函数是一等公民。JavaScript的另一个特点(也算是一个缺点)是动态类型,在编译的时候不能够确定每个变量类型,只有根据运行时的环境去判断其具体的类型,这就导致了性能问题。
下面通过一个例子来解释动态类型所带来的性能损失:

// javascript
function add(a, b) {
	return a.x * b.y + b.x * b.y;
}
// C++
int add(Class1 a, Class1 b) {
	return a.x * b.y + b.x * b.y;
}
class Class1 {
	int x;
	int y;
}

当运行C++的代码的时候,根据 Class1 的定义,我们可以知道对象a,b的属性(x, y)的类型,只要平台确定,那确定的类型就会有确定的长度。这样在生成可执行代码的时候就可以直接根据内存地址和相应的偏移量来获得相应的值。所以字符“x”和“y”在执行的时候根本不需要,也就不需要额外的查找地址的工作。

再看JavaScript的执行,传统的JavaScript解释器一切都是解释执行的,所以效率不会高,即使现在更为高效的JIT(just-in-time)技术,也同样受类型问题困扰。
我们将JavaScript的处理分为两个阶段,编译阶段(不同于传统编译)和执行阶段。由于不能实现判断类型,JavaScript引擎通常采用下图的做法来存储每一个对象。因为属性(相当于C++中的成员变量)没有类型,所以没办法确定其大小,而且还需要把属性名都保存下来,因为之后访问属性值都需要通过属性名匹配才能访问到。而且对象b也同样需要保存相同额属性,因为JavaScript的每个对象要自己保存这些信息,这不仅降低了性能还会带来冗余。
在这里插入图片描述
从获取对象属性值的具体位置的方式来看,JavaScript和C++存在以下几点差异:

  • 编译确定位置:C++在编译阶段就可以计算出对象和属性的偏移信息,方便执行阶段高效读取。而JavaScript只能在执行阶段才能确定,而且JavaScript语言能够在执行时修改对象的属性(不是修改值,而是添加或者删除属性本身)。
  • 偏移信息共享:因为C++有类型的定义,所有对象都是按照该类型来确定,并且在执行的时候不能够动态修改。所以这些对象都是共享偏移信息的,即使访问不同对象也只需要按照编译时确定的偏移量即可。而JavaScript每个对象都是自描述的,属性和位置偏移信息都包含在自身的结构中。
  • 偏移信息查找:C++利用编译时确定好的位置偏移量,可以很方便高效的查找到成员变量。JavaScript则需要利用属性名进行匹配才能查找到对应的值。

推动JavaScript运行速度提高的另一大利器就是JIT技术,其作用是解决解释性语言的性能问题,主要思想是当解释器将源代码解释成内部表示的时候(类似于java字节码),JavaScript的执行环境不仅是解释这些内部表示,而且将其中一些字节码(使用率高的部分)转成本地代码(汇编代码),这样就可以被CPU直接执行,而不是解释执行,从而提高性能。
下面介绍两个带来编程上便易性和模块化的概念:作用域链和闭包。
闭包是一个拥有许多变量和绑定了这些变量的环境的表达式(通常是一个函数),因为这些变量也是该表达式的一部分。JavaScript使用作用域链来实现闭包,作用域链由执行环境维护,JavaScript中所有的标识符都是通过作用域链来查找值的。

JavaScript 引擎

TL;DR
JavaScript引擎就是能够将JavaScript代码处理并执行的运行环境。
先来看一下主流的几种语言从代码到执行的过程:

  • 首先是C/C++。该类语言就是使用编译器直接将他们编译成本地代码,用户使用的只是编译好的本地代码,能够被系统的加载器加载执行,由操作系统直接调度CPU直接执行,无须额外处理。
    在这里插入图片描述
  • Python语言。运行脚本语言通常是开发者将写好的代码直接交给用户,用户使用脚本的解释器将脚本文件加载然后解释执行。现在python也可以支持将脚本编译生成中间表示。
    在这里插入图片描述
  • Java语言。java的执行分为两个阶段,首先是像C++语言一样的编译阶段,但是java编译生成的是字节码而不是本地代码,字节码是跨平台的一种中间表示。在运行字节码阶段,Java虚拟机加载字节码,使用解释器去执行这些字节码。为了提高性能,Java虚拟机引入了JIT技术,也就是将部分字节码转变为本地代码。
    在这里插入图片描述
  • JavaScript。JavaScript现在也借鉴了java虚拟机设计的理念。他们的区别如下:
    • 类型。JavaScript无类型,所以相比Java有更大的性能损失,不过现在有一些新的技术,可以构建隐式的类型信息。
    • 对于Java来说将源代码编译成字节码和之后的执行是分开的,也就是说从源代码到抽象语法树再到字节码(相当于编译)这段时间长短对之后的执行没有影响。对于JavaScript来说,JavaScript文件下载后到执行阶段是在网页的加载和渲染过程中一块完成的,所以对于JavaScript源代码到字节码的阶段有着很高的时效性。所以对于JavaScript来说,每个阶段的时间越短越好。

在这里插入图片描述

JavaScript引擎通常包含以下几个部分:

  • 编译器:将源代码编译成抽象语法树,在某些引擎中还包含将抽象语法树转换成字节码。
  • 解释器:解释器主要是接收字节码,解释执行字节码,同时也依赖垃圾回收机制。
  • JIT工具:将字节码或者抽象语法树转换成本地代码
  • 垃圾回收器和分析工具:负责垃圾回收和收集引擎中的信息,帮助改善引擎的性能和功效。

JavaScript引擎和渲染引擎

网页的工作过程需要两个引擎,渲染引擎和JavaScript引擎。JavaScript引擎负责执行JavaScript代码,渲染引擎负责渲染网页。JavaScript引擎提供调用接口给渲染引擎,以便让渲染引擎使用JavaScript引擎来处理JavaScript代码并获取结果。JavaScript引擎需要能够访问渲染引擎构建的DOM树,所以JavaScript引擎通常需要提供桥接的接口,渲染引擎根据桥接接口来提供让JavaScript访问DOM的能力。
渲染引擎和JavaScript引擎之间的调用关系:
在这里插入图片描述

两种引擎通过桥接接口来DOM结构,造成了性能的损失。所以目前为止使用JavaScript操作DOM还是一个非常低效率的事。目前主流的解决方案是使用虚拟DOM的方式。

V8引擎


基础

V8的目的是提高JavaScript运行的速度。V8支持多系统、多平台。NodeJS就是基于V8打造。

代码结构
V8
└─── include 		v8 接口
│   	|
|		|——v8.h   				包含V8的接口 
|		|——v8-debug.h   	    调试相关接口  
|		|——v8-profile.h     	信息收集的接口 
|		|——v8-testing.h   		测试相关接口
│
└─── src			v8内部实现
│   │   arm		arm后端,抽象语法树转成arm指令的相关代码
│   │   ia32	ia32后端,抽象语法树转成ia32指令的相关代码
│   │   x64		x64后端,抽象语法树转成x64指令的相关代码
│   │   ast.h/cc		抽象语法树的实现
│   │   d8.h/cc		一个调试程序
│   │   full-codegen.h/cc		抽象语法树生成本地代码
│   │   heap.h/cc		堆实现
│   │  extensions		扩展机制
└─── benchmarks			性能测试用例
└─── build			编译v8项目相关脚本
应用程序编程接口(API)

在代码目录 include/V8.h 中,主要有以下这些类:

  • 各种各样的基础类:对象引用类,基本数据类型类。真正的实现在 src/objects.h/cc。
  • value:所有JavaScript数据和对象的基类
  • V8数据的句柄类:以上数据类型的对象在V8中有不同的生命周期,需要使用句柄来描述他们的生命周期,以及垃圾回收器如何使用句柄来管理这些数据,句柄类包括Local、Persistant和Handle。
  • Isolate:这个类表示一个V8引擎实例包括相关状态信息,堆等。这是一个能狗执行JavaScript代码的类。
  • Context:执行上下文,包含内置的对象和方法。
  • Extension:V8的扩展类。
  • Handle:句柄类,主要用于管理基础数据和对象,以便被垃圾回收器操作。
  • Script:用于表示被编译过的JavaScript代码。
  • HandleScope:包含一组handle的容器类,帮助一次性删除这些handle,避免重复调用。
  • FunctionTemplete:绑定C++函数到JavaScript。
  • ObjectTemplete:绑定C++对象到JavaScript。

工作原理

数据表示

V8中,数据的表示分为两部分,第一部分是数据的实际内容(边长的),第二部分是数据的句柄,句柄的大小是固定的,句柄中包含指向数据的指针。这样设计的主要目的是V8需要进行垃圾回收,并需要移动这些数据内容,如果直接使用指针的话就会出问题或者需要比较大的开销,使用句柄就可以避免这些问题,只需要将句柄中的指针修改即可,使用者使用的还是句柄,它本事并没有发生变化。
除了极少数的数据结构(整形),其他内容都是从堆中申请内存来存储他们的,因为Handle 本身就可以存储整形,同时也为了快速访问。
JavaScript对象的实现在V8中包含3个成员,第一个是隐藏类的指针,是V8为JavaScript对象创建的隐藏类,第二个指向这个对象包含的属性值,第三个指向这个对象包含的元素。
在这里插入图片描述

V8工作过程

V8的工作过程大致分为两个阶段,第一个是编译,第二个是运行。
在这里插入图片描述
从上图源代码到本地代码的过程,我们可以看出首先将源代码转变为抽象语法树,之后V8引擎通过JIT编译器的券代码生成器从抽象语法树直接生成本地代码,所以就没有像Java一样的虚拟机或者字节码解释器。这一点不同于JavaScriptCore引擎。这样做的原因主要是因为减少抽象语法树到字节码的转化时间,这一切都在网页加载时完成,虽然可以提高优化的可能,但是分析过程可能带来巨大的时间浪费。缺点主要包括:1. 在某些JavaScript使用场景其实使用解释器更为合适,因为没必要生成本地代码; 2. 没有中间表示会减少优化的机会,因为缺少了一个中间表示层。
V8引擎编译JavaScript生成本地代码的过程中主要使用了以下类和过程:

  • script:表示JavaScript代码,既包含源代码,又包含生成之后的本地代码。
  • compiler:编译器类,辅助script类来编译生成代码,主要起一个协调者的作用。
  • parser:将源代码解释并构建成抽象语法树。
  • AstNode:抽象语法树节点类,是其他所有节点的基类,它包含了非常多的子类,后面会针对不同的子类生成不同的本地代码。
  • AstVistor:抽象语法树的访问者类,主要用来遍历异构的抽象语法树。
  • FullCodeGenerator:AstVistor 的子类,通过遍历抽象语法树来为JavaScript生成本地可执行的代码。

在这里插入图片描述
编译JavaScript代码的过程大致如下:
Script类调用Compiler类的Compile函数为其生成本地代码。在该函数中,第一,它使用Parser类来生成抽象语法树;第二使用FullCodeGenerator类来生成本地代码。根据延迟编译思想(到运行时被调用才会编译),事实上,JavaScript中的很多函数是没有被编译生成本地代码的。因为JavaScript代码编译之前需要构建一个运行环境,所以在编译之前,V8会构建很多全局对象并加载一些内置的库(math等)。
V8在生成本地代码之后,为了性能考虑,会通过数据分析器(Profiler)去采集一些信息,以帮助决策哪些本地代码可以进行更好的优化,这是一个逐步改进的过程。同时,如果V8发现优化后的代码性能比优化前有所降低的话,V8能够回退到之前的代码(运行阶段)。

运行阶段主要用到的类包括:

  • script:运行代码的入口
  • Execution:运行代码的辅助类包含一些重要的函数。
  • JSFunction:需要执行的JavaScript函数表示类
  • Runtime:运行这些本地代码的辅助类,它的功能主要是提供运行时各种各样的辅助函数,包括但是不限于属性访问、类型转换、编译、算术、位操作、比较、正则表达式。
  • Heap:云顶本地代码需要使用内存堆。
  • MarkCompactCollector:垃圾回收机制的主要实现类,用来标记(Mark)、清除(Sweep)和整理(Compact)等基本的垃圾回收过程。
  • SweeperThread:负责垃圾回收的线程。

在这里插入图片描述

延迟编译在V8中大致思想就是在某个JavaScript函数被调用时,V8查找该函数是否有对应的本地代码,如果已经生成则直接调用,否则就会编译生成本地代码。目的是节约处理那些使用不到的代码的时间。
V8没有中间表示层,所以很多时候代码没有很好的进行优化,为了解决这个问题,V8引入了Crankshaft 编译器,可以针对热点函数进行优化。该编译器基于源代码分析,对其中的热点函数进行优化。

优化回滚

前面说到的Crankshaft编译器,其实是对代码进行了比较乐观的猜测,其认为这些代码比较稳定,变量类型不会发生变化,但是JavaScript动态类型的特点使得一些变量的类型往往会发生变化。在发现变量类型发生变化了之后V8就需要进行优化回滚。
优化回滚是一个很费时的操作,V8只有在不得不回滚的情况下才会进行回滚。所以我们平时编码过程中一定要注意不要随便改变变量的类型,或者使用flow等工具来进行JavaScript的类型检查。

隐藏类和内嵌缓存

V8借助C++查找变量的方式,将通过字符串匹配来查找属性值的算法改进为通过偏移位置的机制来实现,这就是隐藏类。隐藏类将对象划分为不同的组,对于组内对象拥有相同的属性名和属性值的情况,将这些属性名和对应的偏移位置保存在一个隐藏类中,组内的对象共享这个信息。
访问对象属性的过程如下:首先获取隐藏类的地址,然后根据属性名查找偏移值,计算该属性的地址。通常可以使用内嵌缓存机制来优化上述过程,基本思想是将之前查找的隐藏类和偏移值保存下来,当下次查找的时候判断对象是否是之前的隐藏类,如果是的话,就可以直接使用缓存的偏移值,减少了查找表的时间。如果某个对象具有多个类型,会出现缓存失误的情况,一旦出现,V8会使用之前的方法。

内存管理

内存的划分:
Zone类:主要是管理一系类的小块内存。如果用户需要使用一些类小内存,并且这些小内存的生命周期类似,就可以使用一个Zone对象。Zone对象首先自己申请一块内存,然后分配一些小内存,当一块小内存被分配后,就不能够被Zone回收,只能一次性回收Zone分配的所有小块内存。
堆:V8使用堆来管理JavaScript使用的数据,生成的代码、哈希表等。为了方便回收,V8将堆分为三个部分,第一个是年轻分代,第二个是年老分代,第三个是为大对象保留的空间。
在这里插入图片描述
年轻分代主要是为新穿件的对象分配内存空间,因为年轻分代中的对象较容易被要求回收,为了方便垃圾回收,使用复制的方式将年轻代分为两半,一半用来分配,另一半在回收的时候负责将之前还需要保留的对象复制过来。
老年分代则主要根据需要将年老的指针、对象、代码等数据使用的内存较少的做垃圾回收。
大对象空间主要是用来为那些需要使用较多内存的大对象分配内存,当然同样可能包含数据和代码等分配的内存,需要注意的是每个页面只分配一个对象。

垃圾回收:
因为使用了分代和大数据的内存分配,V8需要使用精简整理的算法,用来标记哪些还被引用改的对象,然后消除那些没有被标记的对象,最后整理和压缩(compact)那些还需要保存的对象。垃圾回收算法还有并发标记、并发内存回收等。

快照

前面介绍到,V8引擎在开始启动的时候,需要加载很多内置的全局对象,同时还要建立内置的函数。为了使引擎本事更加整洁,加载对象与建立函数等任务都是使用JS文件来完成的,V8仅负责提供机制来支持,也就是在编译和执行用户自定义JavaScript代码之前,先加载他们。这些内置代码的编译和执行也是使用堆来保存执行过程中的对象、代码等,这些需要花费较多的时间,所以V8引入了快照机制。就是将这些内置的对象和函数加载之后的内存保存并序列化。序列化之后的结果很容易被反序列化,经过快照机制的启动时间,可以缩减几毫秒。
缺点就是创建快照之后的代买没办法被Crankshaft这样的优化编译器优化,所以也存在性能上的问题。

绑定和扩展

有时候,JavaScript引擎所提供的能力不能满足现实的需求,比如引擎本身没有HTML5的众多能力(如地理信息),这是就需要扩展引擎的能力。
V8提供两种扩展机制:

  • Extension机制:通过V8提供的基类Extension来达到扩展JavaScript能力的目的
  • 绑定:使用IDL文件或者接口文件来生成绑定文件,然后将这些文件同V8引擎的代码一起编译。

JavaScriptCore 引擎


。。。

高效的JavaScript代码实践


编程方式

以下是几个值得注意的方面:

  • 类型:JavaScript的动态类型,给引擎的性能带来了重大的问题。对于函数要使用特定类型的对象或者较少的类型,以此来减少缓存失误的几率从而提高性能。对于数组,尽量使用存放相同类型的数据,这样就可以通过偏移位置来访问它们。
  • 数据表示:一些简单类型的数据可以直接保存在句柄中,这就能够有效地减少寻址时间和内存的使用。但是使用了一部分位,所以整数表示的范围会缩小,如果使用较大的整数,就需要使用堆来保存。对于数值来说,能够使用整数的,尽量不要使用浮点类型。
  • 内存:有效使用内存可以显著地提高代码的性能。对于使用垃圾回收的代码来说,简单的做法就是对引用不再使用的对象的变量设置为空(置位null)。另一个方法是通过使用 delete 关键字,来删除对象。
  • 优化回滚:不要写出触发优化回滚的代码。也就是说在执行多次之后,不要出现修改对象类型的语句。
  • 新机制:使用JavaScript引擎或者渲染引擎提供的新机制和新接口,如 requestAnimationFrame等接口,可以有效减少JavaScript引擎的额外负担。另外可以使用web worker 来提升引擎并发处理能力。
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值