浅析V8

501 阅读8分钟

  目前编程语言主要分为两种,解释型语言和编译型语言。编译型语言如C/C++,先将编程语言编译为机器码,然后在计算机上运行。而解释型语言则是一边编译一边运行。在运行性能上,当然是编译型语言更胜一筹,在运行时省去了编译的过程。而浏览器这种特殊的运行环境,使得javascript有了用武之地。

javascript引擎就是执行 JavaScript 代码的程序或者说是解释器。当前较火的JavaScript引擎有:

名称
介绍
V8引擎由Google开发,C++编写的开源引擎
SpiderMonkey第一个JavaScript引擎(Firefox正在使用),C语言编写
JavaScriptCore苹果公司为safari而开发
ChakraIE引擎
Rhino由Mozilla,java编写的开源引擎
KJSKDE引擎

今天我们主要来说一说Google的V8引擎,以及V8引擎简单的工作原理,了解了引擎背后的工作,对大家日常开发和深入认识JavaScript都有一定的帮助。

V8最初的设计初衷就是为了提高JavaScript的执行性能。为了得到更快得速度,V8在编译时直接将抽象语法树解析为机器码,而不像其它引擎,需要生成字节码或中间代码。

下面是V8引擎与其它引擎执行过程对比:

V8:源代码→抽象语法树→JIT→本地代码

other:源代码→抽象语法树→字节码→JIT→本地代码

但在V8 V5.9版本开始,V8重新捡起了字节码,引入了lgnition字节码解释器,并默认开启。那么既然直接转换为机器码运行速度更快,Google为什么还要适用字节码解释器呢?



这是因为Google发现,当一个项目足够大时,机器码所占用的内存空间会变得很大,V8无法将项目下的所有JS代码编译后缓存下来,加大了内存压力,并且在退出重进Chrome时,序列化/反序列化的时间将会变得很长,而且由于机器码占用空间过大,V8无法提前编译所有的代码,这无疑是令人难受的。


抽象语法树(AST):

抽象语法树是源代码的抽象语法结构的树状标识。

例如这段代码解析后的抽象语法树为:

var a = 1;
function fn(){
}
{
"type": "Program",
"start": 0,
"end": 28,
"body": [
{
"type": "VariableDeclaration",
"start": 0,
"end": 10,
"declarations": [
{
"type": "VariableDeclarator",
"start": 4,
"end": 9,
"id": {
"type": "Identifier",
"start": 4,
"end": 5,
"name": "a"
},
"init": {
"type": "Literal",
"start": 8,
"end": 9,
"value": 1,
"raw": "1"
}
}
],
"kind": "var"
},
{
"type": "FunctionDeclaration",
"start": 11,
"end": 27,
"id": {
"type": "Identifier",
"start": 20,
"end": 22,
"name": "fn"
},
"expression": false,
"generator": false,
"async": false,
"params": [],
"body": {
"type": "BlockStatement",
"start": 24,
"end": 27,
"body": []
}
}
],
"sourceType": "module"
}
key
表示
type描述该语句的类型
kind变量声明的关键字
declaration声明的内容
declaration.id描述变量名称的对象
declaration.id.type定义
declaration.id.name变量的名称
declaration.init初始化变量值得对象
declaration.init.type类型
declaration.init.value
generatorgenerator函数
asyncasync函数
params函数的参数

JIT:

JIT全称:just-in-time(即时编译),通过lgnition基线编译器快速生成字节码进行执行

上面说到了编译型语言和解释型语言,为了解决浏览器解释器低效的问题,后来浏览器把编译器也引入了进来,形成了混合的模式。

在JavaScript引擎中加入一个监视器,监控着代码的运行情况。

基线编译器:

起初,监视器监视着所有通过解释器的代码,如果一段代码运行了多次,那么这个代码段就被标记为warm,如果运行了很多次,则被标记为hot。

如果一段代码被标记为warm,那么JIT就把它送到编译器去编译,并将编译结果存储起来。代码段的每一行都会被编译为一个“桩”,同时给这个桩分配一个行号+类型的索引,如果监视器监视到了相同类型和同样的代码,那么就直接将这个已编译的版本push给浏览器运行。

优化编译器:

如果一段代码变得“very hot”,监视器会把它发送到优化编译器中。由于JS是弱类型语言,ES标准中有大量多义性和类型判断,因此通过基线编译器生成的代码执行效率低下。但是这也不意味着没有优化的空间,对于固定的程序逻辑,其接收的参数类型往往是固定的。正因如此,V8引入了类型反馈机制,在进行计算时,V8使用类型反馈对参数进行动态检查。

对于重复执行的代码,如果多次执行后,传入的参数类型是相同的,那么V8会假设之后的每一次调用,传入参数都是这种类型,并进行优化处理。如果这段代码在进入优化编译器后,某一次执行时参数类型变化,那么V8就会撤销这次优化,这个过程被称为“去优化”。

去优化的开销是很大的,在实际编写函数的时候要注意尽量避免去优化

垃圾回收机制:

相比于java/Go来说,V8对于内存的使用是有限制的。在64位系统下,V8只能分配1.4G的内存,而32位系统只能分配到0.7G。虽然在浏览器端,可能用不到这么多内存,但是对于nodeJS,可能这个限制在某些情况下就会不够用。

V8把堆内存分为两个部分——新生代内存和老生代内存。顾名思义,新生代内存就是临时分配的内存,存活时间短,回收频繁。老生代内存就是常驻内存,存活时间长。而V8的堆内存就是这两种之和。

新生代内存:

V8对新生代内存的限制在32位和64位下分别是16MB和32MB。为什么会这么小呢?其实很好理解,新生代内存中的变量存活时间短,回收频繁,不会产生较大的内存负担。

新生代内存回收机制:首先将内存空间一分为二,分为from和to。其中 from表示正在使用的内存,to表示当前闲置的内存。


在进行垃圾回收时,V8先将from中的对象检查一遍,如果是存活的对象,那么就复制到TO中,并按顺序从头放置。如果是非存活对象,那么直接进行回收。

当所有的from里的对象全部复制到TO中以后,将from和to角色互换,依次循环。

那么为什么不直接将非存活对象回收,而是要重复进行这么一步操作呢?这是因为,堆内存是连续分配的,内存在开始时,空间是零散的,存在很多零散空间(内存碎片),这会导致可能一些大的对象无法进行空间分配。如图所示:


V8重复已上步骤,将变量按顺序排列,解决了内存碎片的问题,且在进行垃圾回收时,效率更高。这种回收机制算法也叫做scavenge算法


老生代内存:

新生代内存中的变量如果经历了多次scabenge算法回收后,还依然存在的话,就会被放入到老生代内存中,这个现象叫做晋升。

但是不仅是这一种情况会发生晋升,当空间To空间内存占用超过25%时也会发生晋升。

老生代垃圾回收机制的第一步就是进行标记-清除,标记-清除分为两个阶段,即标记阶段和清除阶段。首先会遍历堆内存中的变量,对他们进行标记,然后对代码中使用中的变量和强引用的变量取消标记,剩下的就是要清除的变量了,在清除阶段对其进行回收。

当然,这也会引发内存碎片的问题,参考上图。因为老生代内存空间很大,不适用scavenge算法,所以在老生代内存中,V8在进行清除后,直接将所有变量向一端靠拢。由于是移动对象,所以这个过程也是最耗时间的部分。

由于JS单线程的机制,v8在进行老生代内存回收时,会不可避免的阻塞业务逻辑的执行,如果老生代的回收任务很重的话,那么会非常耗时。为了解决这个问题,V8采用了增量标记的方案。就是将大的回收任务分为多个小任务,即每昨晚一部分,就停一下,等JS应用执行一会儿,再进行下一个任务,直到所有任务执行完毕,再进入清除阶段。

代码缓存:

在chrome浏览器中,缓存功能极大的加快了页面响应速度,在用户多次访问同一个页面时,如果页面脚本没有更改,缓存技术会使js加载变得很快。

代码缓存被分为cold、warm、hot三个等级

当用户首次加载某脚本时,chrom将此文件下载并交于V8编译,并将文件缓存磁盘中

当用户第二次请求这个JS文件,Chrome将文件从缓存中取出,再次交给V8编译,在编译完成后,编译的代码会被反序列化,作为元数据附加到缓存的脚本文件中

当用户第三次请求这个JS文件时,Chrome从磁盘中获取文件和元数据,一并交给V8。V8将跳过编译阶段,直接反序列化元数据。