一、JavaScript 引擎
JavaScript 引擎是一个专门处理 JavaScript 脚本的虚拟机,一般会附带在网页浏览器之中。
我们可以简单地把 JavaScript 虚拟机理解成是一个翻译程序,将人类能够理解的编程语言 JavaScript,翻译成机器能够理解的机器语言。
JavaScript 引擎有很多种:
- Rhino — 由 Mozilla 基金会管理,开源,完全用 Java 开发。
- SpiderMonkey — 第一款 JavaScript 引擎,早期用于 Netscape Navigator,现在用于 Mozilla Firefox。
- V8 — 开放源代码,由 Google 丹麦开发,是 Google Chrome 的一部分
- JavaScriptCore — 开发源代码,用于 Safari。
- KJS — KDE 的引擎,最初由 Harri Porten 为 KDE 项目中的 Konqueror 网页浏览器开发。
- Chakra (JScript 引擎) — 用于 Internet Explorer。
- Chakra (JavaScript 引擎) — 用于 Microsoft Edge。
- KJS — KDE 的 ECMAScript/JavaScript 引擎,最初由哈里·波顿开发,用于 KDE 项目的 Konqueror 网页浏览器中。
目前 V8 引擎是当下使用最广泛的 JavaScript 虚拟机,也是 Chrome 和 NodeJS使用的引擎。
二、V8 引擎内部原理
V8 工作流程
JavaScript 是高级语言,而计算机只能理解1和0。通常有两种方式来执行高级语言所编写的代码。
第一种是解释执行,需要先将输入的源代码通过解析器编译成中间代码,之后直接使用解释器解释执行中间代码,然后直接输出结果。这种方式启动速度快,但是执行速度慢。
第二种是编译执行,需要先将输入的源代码通过解析器编译成中间代码,之后使用编译器再将中间代码编译成机器代码,机器代码通常以二进制文件形式存储,然后直接执行二进制代码。这种方式启动速度慢,执行速度快。
V8 引擎并没有采用以上方式的单一技术,而是使用了 JIT 技术,其实就是混合了编译执行和解释执行两种方式。JIT 技术也是一种权衡策略,在启动过程中采用了解释执行的策略,但是如果某段代码的执行频率超过一个值,那么 V8 会采用优化编译器将其编译成执行效率更加高效的机器代码。
V8 引擎工作流程:
- V8 启动时初始化执行环境,比如堆栈、全局执行上下文、内置函数等。
- V8 接收到输入的源代码后,结构化这段源代码,然后生成抽象语法树,也就是 AST。
- 生成 AST 同时,V8 还会生成相关的作用域,然后生成字节码,字节码可以让解释器直接解释执行。
- 当解释器执行字节码时发现代码被多次执行,那么就会丢给编译器,编译器会将字节码编译成二进制代码去执行。
V8 如何查找变量
作用域链将一个个作用域串起来,从而实现变量查找的路径。当在函数内部使用一个变量的时候,V8 便会去作用域中查找,如果在当前函数作用域中没有查找到变量,那么 V8 会去全局作用域中查找。
全局作用域在 V8 启动过程中就创建了,且一直保存在内存中不会被销毁,直至 V8 退出。而函数作用域在执行该函数时创建,当函数执行结束之后,函数作用域就随之被销毁掉了。
需要注意的是对于 V8 内部对于函数的处理会增加 name 和 code 两个内部属性,因为函数是特殊的对象,可以被赋值,作为参数以及被调用。
V8 如何实现运算
在 JavaScript 中,数字和字符串相加会返回一个新的字符串,这是因为 JavaScript 认为字符串和数字相加是有意义的,V8 会将其中的数字转换为字符,然后执行两个字符串的相加 操作,最终得到的是一个新的字符串。
在 JavaScript 中,类型系统是依据 ECMAScript 标准来实现的,所以 V8 会严格根据 ECMAScript 标准来执行。在执行加法过程中,V8 会先通过 ToPrimitive 函数,将对象转换为原生的字符串或者是数字类型,在转换过程中,ToPrimitive 会先调用对象的 valueOf 方法,如果没有 valueOf 方法,则调用 toString 方法,如果 vauleOf 和 toString 两个方 法都不返回基本类型值,便会触发一个 TypeError 的错误。
V8 如何实现闭包
首先我们理解一下惰性解析。惰性解析是指解析器在解析的过程中,如果遇到函数声明,那么会跳过函数内部的代码,并不会为其生成 AST 和字节码,而仅仅生成顶层代码的 AST 和字节码。 利用惰性解析可以加速 JavaScript 代码的启动速度,如果要将所有的代码一次性解析编译完成,那么会大大增加用户的等待时间。
由于 JavaScript 是一门天生支持闭包的语言,闭包会引用当前函数作用域之外的变量,所以当 V8 解析一个函数的时候,还需要判断该函数的内部函数是否引用了当前函数内部声明的变量,如果引用了,那么需要将该变量存放到堆中,即便当前函数执行结束之后,也不会释放该变量。
三、V8 引擎垃圾回收机制
新生代 Scavenge(清除)算法
- 将堆内存一分为二,其中一个处于使用之中的称为 from 空间,另一个处于闲置称为 to 空间。
- 垃圾回收时,检查 from 空间内的存活对象,并将这些存活对象复制到 to 空间中,而非存活对象占用的空间被释放。这个阶段如果经历过清除回收或者 to 空间已经使用了25%,则会将对象晋升到老生代空间中。
- 将这些存活对象复制到 to 空间中。非存活对象占用的空间将会被释放。
- 完成复制后,from 空间与 to 空间发生对换。
scavenge 算法用空间换取时间,问题在于只能使用堆内存中的一半,但由于它只复制存活的对象,对于生命周期短的场景存活对象只占少部分,所以在时间效率上有着优异的表现。
Mark-Sweep 老生代标记清除
- 标记阶段遍历堆中的所有对象,并标记活着的对象。
- 清除阶段,只清除没有被标记的对象。
Mark-Sweep 算法的问题在于进行一次标记清楚后,内存会出现不连续的状态,这种会导致后续需要分配一个大对象的时候,无法完成分配,就会提前触发垃圾回收,而这次回收是不必要的。
Mark-Compact 老生代标记整理
- 基于Mark-Sweep 算法上进行演变。
- 在标记整理过程中,将活着的对象往一端移动。
- 移动完成后,直接清理掉边界外的内存。
Mark-Compact 算法的问题在于当对象存活率较高时就要进行较多的复制操作,效率将会变低。更关键的是,如果不想浪费50%的空间,就需要有额外的空间进行分配担保,以应对被使用的内存中所有对象都100%存活的极端情况。