目前编程语言主要分为两种,解释型语言和编译型语言。编译型语言如C/C++,先将编程语言编译为机器码,然后在计算机上运行。而解释型语言则是一边编译一边运行。在运行性能上,当然是编译型语言更胜一筹,在运行时省去了编译的过程。而浏览器这种特殊的运行环境,使得javascript有了用武之地。
javascript引擎就是执行 JavaScript 代码的程序或者说是解释器。当前较火的JavaScript引擎有:
名称 | 介绍 |
|---|---|
| V8引擎 | 由Google开发,C++编写的开源引擎 |
| SpiderMonkey | 第一个JavaScript引擎(Firefox正在使用),C语言编写 |
| JavaScriptCore | 苹果公司为safari而开发 |
| Chakra | IE引擎 |
| Rhino | 由Mozilla,java编写的开源引擎 |
| KJS | KDE引擎 |
今天我们主要来说一说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 | 值 |
| generator | generator函数 |
| async | async函数 |
| 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将跳过编译阶段,直接反序列化元数据。