深入理解JS:初识V8引擎

前言

​ 最近在学习各种前端框架之余,也在花更多时间对原生JS的知识做系统梳理。前端相对来说是一个比较新兴的领域,技术的更新换代非常频繁,光是想要赶上这些新兴技术的更新速度就已经非常难了,这对个人的学习能力是一个非常大的考验。如果只是不断学习新技术而不去思考背后的原理,在这个过程中很难产生自己的技术积淀。比起一味地追求新技术带来的新鲜感和功利地为了业务需求而提升熟练度,理解技术本质对于今后的学习来说是一个更加行之有效的方式。学习那些本质的知识,抓住上层应用中不变的底层机制,这样我们便能轻松理解上层的框架而不仅仅是被动地使用,甚至能够在适当的场景下自己造出轮子,以满足开发效率的需求。

JavaScript与JavaScript 引擎

​ 我们写的 JavaScript 代码直接交给浏览器或者 Node 执行时,底层的 CPU 无法识别,也没法执行。CPU 只认识自己的指令集,指令集对应的是汇编代码。写汇编代码是一件很痛苦的事情。并且不同类型的 CPU 的指令集是不一样的,那就意味着需要给每一种 CPU 重写汇编代码。JavaScript引擎可以将JS代码编译为不同 CPU(Intel, ARM 以及 MIPS 等)对应的汇编代码,这样我们就不需要去翻阅每个 CPU 的指令集手册来编写汇编代码了。当然,JavaScript 引擎的工作也不只是编译代码,它还要负责执行代码、分配内存以及垃圾回收

JavaScript引擎与浏览器

​ 语言和环境是两个不同的概念。提及JavaScript,大多数人可能会想到浏览器,脱离浏览器JavaScript是不可能运行的,这与其他系统级的语言有着很大的不同。例如 C 语言可以开发系统和制造环境,而 JavaScript 只能寄生在某个具体的环境中才能够工作。JavaScript 运行环境一般都有宿主环境和执行期环境。如下图所示:

image-20210708173753615

宿主环境是由外壳程序生成的,比如浏览器就是一个外壳环境(但是浏览器并不是唯一,很多服务器、桌面应用系统都能也能够提供 JavaScript 引擎运行的环境)宿主环境可以是浏览器中的渲染进程,可以是 Node.js 进程, 也可以是其他的定制开发的环境,而这些宿主则提供了很多 V8 执行 JavaScript 时所需的基础功能部件。执行期环境则由嵌入到外壳程序中的 JavaScript 引擎生成,在这个执行期环境,首先需要创建一个代码解析的初始环境,初始化的内容包含:

  1. 一套与宿主环境相关联系的规则

  2. JavaScript 引擎内核(基本语法规则、逻辑、命令和算法)

  3. 一组内置对象和 API

  4. 其他约定

不同的 JavaScript 引擎定义初始化环境是不同的,这就形成了所谓的浏览器兼容性问题,因为不同的浏览器使用不同 JavaScript 引擎。

由于 JavaScript 大多数都是运行在浏览器上,不同浏览器的使用的引擎也各不相同,以下是目前主流浏览器引擎:

image-20210708173806258

V8:强大的JavaScript引擎

​ 在为数不多的JavaScript引擎中,V8无疑是最流行的,也正是由于 V8 的出现,目前的前端才能大放光彩,百花齐放。Google V8 引擎是用 C ++编写的开源高性能 JavaScript 和 WebAssembly 引擎,可以运行在 Windows 7+,macOS 10.12+和使用 x64,IA-32,ARM 或 MIPS 处理器的 Linux 系统上。 V8 最早被开发用以嵌入到 Google 的开源浏览器 Chrome 中,第一个版本随着第一版[Chrome](https://baike.baidu.com/item/Google Chrome/5638378?fr=aladdin)于 2008 年 9 月 2 日发布。但是 V8 是一个可以独立运行的模块,完全可以嵌入到任何 C ++应用程序中。著名的 Node.js( 一个异步的服务器框架,可以在服务端使用 JavaScript 写出高效的网络服务器 ) 就是基于 V8 引擎的,Couchbase, MongoDB 也使用了 V8 引擎。

​ 和其他 JavaScript 引擎一样,V8 会编译 / 执行 JavaScript 代码,管理内存,负责垃圾回收,与宿主语言的交互等。通过暴露宿主对象 ( 变量,函数等 ) 到 JavaScript,JavaScript 可以访问宿主环境中的对象,并在脚本中完成对宿主对象的操作

image-20210708173819924

V8引擎的内部结构

V8是一个非常复杂的项目,使用cloc统计可知,它竟然有超过100万行C++代码

V8由许多子模块构成,其中这4个模块是最重要的:

  • Parser(解析/语法分析):负责将 JavaScript 源码转换为 Abstract Syntax Tree (AST),当程序出现语法错误的时候,V8在语法分析阶段抛出异常。

确切的说,在“Parser”将 JavaScript 源码转换为 AST前,还有一个叫”Scanner“的过程,具体流程如下:

image-20210708173833649

这个过程通常也被称为:分词/词法分析。所谓的分词,就好比我们将一句话,按照词语的最小单位进行分割。计算机在编译一段代码前,也会将源代码拆解成有意义的不可再分的代码块,这些代码块被称为词法单元(token)

例如,考虑程序 var a = 2。这段程序通常会被分解成为下面这些词法单元:vara=2;空格是否作为词法单位,取决于空格在这门语言中是否具有意义。

词法分析和语法分析不是完全独立的,而是交错进行的,也就是说,词法分析器不会在读取所有的词法记号后再使用语法分析器来处理。在通常情况下,每取得一个词法记号,就将其送入语法分析器进行分析。

语法树(AST):

一个由元素逐级嵌套所组成的代表了程序语法结构的树var a = 2;的抽象语法树中可能会有一个叫做VariableDeclaration的顶级节点,接下来是一个叫作Identifier(它的值是a)的子节点,以及一个叫做AssignmentExpression的子节点。AssignmentExpression节点有一个叫做NumericLiteral(它的值是2)的子节点。

image-20210708173846514

上面就是var a = 2生成抽象语法树的一个过程,可以借助在线工具查看

TIPS:

从词法分析到生成AST的这个过程称为编译。Babel就是一个JavaScript编译器,分了三个阶段:解析、转译、生成。将ES6源码解析成AST,再将ES6语法的AST转成ES5AST,最后利用它来生成ES5源代码,这就是Babel的基本实现原理。ESLint原理也大致相同,检测流程也是将源码转换成AST,利用AST来检测代码规范。Vue的编译也用到了AST

参考资料:20 | 原理解析:JS 代码是如何被浏览器引擎编译、执行的?

  • Ignition:interpreter,即解释器,负责将 AST 转换为 Bytecode(字节码),解释执行 Bytecode;同时收集 TurboFan 优化编译所需的信息,比如函数参数的类型;解释器执行时主要有四个模块,内存中的字节码、寄存器、栈、堆。

通常有两种类型的解释器,基于栈 (Stack-based)和基于寄存器 (Register-based),基于栈的解释器使用栈来保存函数参数、中间运算结果、变量等;基于寄存器的虚拟机则支持寄存器的指令操作,使用寄存器来保存参数、中间计算结果。通常,基于栈的虚拟机也定义了少量的寄存器,基于寄存器的虚拟机也有堆栈,其区别体现在它们提供的指令集体系大多数解释器都是基于栈的,比如 Java 虚拟机,.Net 虚拟机,还有早期的 V8 虚拟机。基于堆栈的虚拟机在处理函数调用、解决递归问题和切换上下文时简单明快。而现在的 V8 虚拟机则采用了基于寄存器的设计,它将一些中间数据保存到寄存器中。
基于寄存器的解释器架构

image-20210708173856583

字节码(Bytecode):

字节码是机器代码的抽象。如果字节码采用和物理 CPU 相同的计算模型进行设计,则将字节码编译为机器代码更容易。这就是为什么解释器(interpreter)常常是寄存器或堆栈。 Ignition 是具有累加器的寄存器。可以将 V8 的字节码看作是小型的构建块(bytecodes as small building blocks),这些构建块组合在一起构成任何 JavaScript 功能。V8 有数以百计的字节码。比如 Add 或 TypeOf 这样的操作符,或者像 LdaNamedProperty 这样的属性加载符,还有很多类似的字节码。 V8 还有一些非常特殊的字节码,如 CreateObjectLiteral 或 SuspendGenerator。头文件 bytecodes.h(github.com/v8/v8/blob/… 定义了 V8 字节码的完整列表。

资料参考:解释器是如何解释执行字节码的?

  • TurboFan:compiler,JIT 优化的编译器,TurboFan 的编译和Ignition的生成字节码不会在同一个线程上,这样就可以和 Ignition 解释器相互配合着使用,不受另一方的影响。它利用 Ignition 所收集的类型信息,将 Bytecode 转换为优化的汇编代码。

监视器(Monitor):
监视器监视着所有通过解释器的代码的运行情况,如果一段代码运行了几次(run a few times),那么这段代码会被标记为“warm”代码,如果一段代码运行很多次(run a lot),那么这段代码会被标记为“hot”代码。

基线编译器(Baseline compiler):
如果一段代码被标记成了“warm”,JIT 会把这段代码送到基线编译器编译,同时把结果缓存起来。
如果一个函数被基线编译器编译,那么这个函数的每一行都会被编译成一个表,这个表的索引是行号和变量类型,如果监视器监视到了执行同样的代码和同样的变量类型,那么就直接把这个已编译的版本 push 出来给浏览器。

优化编译器(Optimizing compiler):
如果一段标记为“warm”的代码还在不断被调用,那么它会被标记为“hot”,被标记为“hot”的代码可能会经常被调用,所以可能需要花更多的时间优化它的编译结果。监视器会把该段代码送到优化编译器中,编译成一个高效的版本并存储下来。

参考资料:WebAssembly 系列(二)JavaScript Just-in-time (JIT) 工作原理

  • Orinoco:garbage collector,垃圾回收模块,负责将程序不再需要的内存空间回收。

v8内存使用限制:

在Chrome中,v8被限制了内存的使用(64位约1.4G/1464MB , 32位约0.7G/732MB)。表层原因是,V8最初为浏览器而设计,不太可能遇到用大量内存的场景。深层原因是,V8的垃圾回收机制的限制(如果清理大量的内存垃圾很耗时间,这样会引起JavaScript线程暂停执行,性能和应用直线下降)。对于栈的内存空间,只保存简单数据类型的内存,由操作系统自动分配和自动释放,而堆空间中的内存,需要由JS引擎手动释放。

垃圾回收算法:

绝大多数的对象存活周期都很短,大部分在经过一次的垃圾回收之后,内存就会被释放掉,而少部分的对象存活周期将会很长,一直是活跃的对象,不需要被回收。为了提高回收效率,V8 把堆内存分成了两部分进行处理——新生代内存老生代内存。顾名思义,新生代就是临时分配的内存,存活时间短, 老生代是常驻内存,存活的时间长。V8 的堆内存,也就是两个内存之和。新生区通常只支持 1~8M 的容量,而老生区支持的容量就大很多了。对于这两块区域,V8 分别使用两个不同的垃圾回收器,以便更高效地实施垃圾回收。

新生代垃圾回收 (Scavenge):

任何对象的声明分配到的内存,将会先被放置在新生代中,而因为大部分对象在内存中存活的周期很短,所以需要一个效率非常高的算法。在新生代中,主要使用Scavenge算法进行垃圾回收,Scavenge算法是一个典型的牺牲空间换取时间的复制算法,在占用空间不大的场景上非常适用。新生代中的变量如果经过多次回收后依然存在,那么就会被放入到老生代内存中,这种现象就叫晋升

image-20210708173911781

老生代垃圾回收(Mark-Sweep & Mark-Compact):

在老生代空间中的对象都已经至少经历过一次或者多次的回收所以它们的存活概率会更大,如果这个时候再使用scavenge算法的话,会出现两个问题:

  • scavenge为复制算法,重复复制活动对象会使得效率低下

  • scavenge是牺牲空间来换取时间效率的算法,而老生代支持的容量较大,会出现空间资源浪费问题

所以在老生代空间中采用了 Mark-Sweep(标记清除) 和 Mark-Compact(标记整理) 算法。

Mark-Sweep处理时分为两阶段,标记阶段和清理阶段。看起来与Scavenge类似,不同的是,Scavenge算法是复制活动对象,而由于在老生代中活动对象占大多数,所以Mark-Sweep在标记了活动对象和非活动对象之后,直接把非活动对象清除。但是还遗留一个问题,被清除的对象遍布于各内存地址,产生很多内存碎片。

image-20210708173924721

由于Mark-Sweep完成之后,老生代的内存中产生了很多内存碎片,若不清理这些内存碎片,如果出现需要分配一个大对象的时候,这时所有的碎片空间都完全无法完成分配,就会提前触发垃圾回收,而这次回收其实不是必要的。

为了解决内存碎片问题,Mark-Compact被提出,它是在 Mark-Sweep的基础上演进而来的,相比Mark-Sweep,Mark-Compact添加了活动对象整理阶段,将所有的活动对象往一端移动,移动完成后,直接清理掉边界外的内存。

image-20210708173936917

全停顿( Stop-The-World):

由于垃圾回收是在JS引擎中进行的,而Mark-Compact算法在执行过程中需要移动对象,而当活动对象较多的时候,它的执行速度不可能很快,为了避免JavaScript应用逻辑和垃圾回收器的内存资源竞争导致的不一致性问题,垃圾回收器会将JavaScript应用暂停,这个过程,被称为全停顿(stop-the-world)。

在新生代中,由于空间小、存活对象较少、Scavenge算法执行效率较快,所以全停顿的影响并不大。而老生代中就不一样,如果老生代中的活动对象较多,垃圾回收器就会暂停主线程较长的时间,使得页面变得卡顿。

增量标记 (Incremental marking):

为了降低全堆垃圾回收的停顿时间,增量标记将原本的标记全堆对象拆分为一个一个任务,让其穿插在JavaScript应用逻辑之间执行,它允许堆的标记时的5~10ms的停顿。增量标记在堆的大小达到一定的阈值时启用,启用之后每当一定量的内存分配后,脚本的执行就会停顿并进行一次增量标记。

image-20210708173949356

懒性清理(Lazy sweeping):

增量标记只是对活动对象和非活动对象进行标记,惰性清理用来真正的清理释放内存。当增量标记完成后,假如当前的可用内存足以让我们快速的执行代码,其实我们是没必要立即清理内存的,可以将清理的过程延迟一下,让JavaScript逻辑代码先执行,也无需一次性清理完所有非活动对象内存,垃圾回收器会按需逐一进行清理,直到所有的页都清理完毕。

并发(Concurrent):

V8在老生代垃圾回收中,如果堆中的内存大小超过某个阈值之后,会启用并发(Concurrent)标记任务。并发式GC允许在在垃圾回收的同时不需要将主线程挂起,两者可以同时进行,只有在个别时候需要短暂停下来让垃圾回收器做一些特殊的操作。

image-20210708173959311

并行(Parallel):

V8在新生代垃圾回收中,使用并行(parallel)机制。并行式GC允许主线程和辅助线程同时执行同样的GC工作,这样可以让辅助线程来分担主线程的GC工作,使得垃圾回收所耗费的时间等于总时间除以参与的线程数量(加上一些同步开销)。

image-20210708174009434

参考资料:深入理解Chrome V8垃圾回收机制

V8 是怎么执行一段 JavaScript 代码的

初始化基础环境后,当 V8 编译 JavaScript 代码时,解析器(parser)将生成一个抽象语法树(AST)。生成 AST 后,解释器 Ignition 根据语法树(AST)生成字节码(Bytecode)。TurboFan 是 V8 的优化编译器,TurboFan 将字节码(Bytecode)生成优化的机器代码(Machine Code)。

image-20210708174021198

  • 如果函数没有被调用,则V8不会去编译它。

  • 如果函数只被调用1次,则Ignition将其编译为Bytecode就直接解释执行了。TurboFan不会进行优化编译,因为它需要Ignition收集函数执行时的类型信息。这就要求函数需要执行1次以上,TurboFan才有可能进行优化编译。

  • 如果函数被调用多次,则它有可能会被识别为hot函数,且Ignition收集的类型信息证明可以进行优化编译的话,这时TurboFan则会将Bytecode编译为Optimized Machine Code,以提高代码的执行性能。

  • 图片中的红线是逆向的,这的确有点奇怪,Optimized Machine Code会被还原为Bytecode,这个过程叫做Deoptimization。这是因为Ignition收集的信息可能是错误的,比如add函数的参数之前是整数,后来又变成了字符串。生成的Optimized Machine Code已经假定add函数的参数是整数,那当然是错误的,于是需要进行Deoptimization。

  • 在运行 C、C++以及 Java 等程序之前,需要进行编译,不能直接执行源码;但对于 JavaScript 来说,我们可以直接执行源码(比如:node test.js),它是在运行的时候先编译再执行,这种方式被称为**即时编译(Just-in-time compilation)**,简称为 JIT。

  • 总结:

    V8 执行一段 JavaScript 代码所经历的主要流程包括:

    • 准备好代码的运行时环境;
    • 解析源码生成 AST;
    • 依据 AST 生成字节码;
    • 解释执行字节码;
    • 监听热点代码;
    • 优化热点代码为二进制的机器代码;
    • 反优化生成的二进制机器代码。

资料参考:V8 引擎是如何工作的?