站在‘上帝 ’角度,透视v8 执行 js 的过程

1,522 阅读13分钟

站在‘上帝’角度,透视v8 执行 js 的过程

​大部分同学知道JavaScript 是如何工作的,但是不太清楚 JavaScript 的底层工作机制,很多时候,只有了解了底层原理,才能帮助你更好地理解和应用一门语言,比如 JavaScript。 今天这篇文章我们就“向下”分析,站在 JavaScript 引擎 V8 的视角,来分析JavaScript 代码是如何被执行的。

前端工具和框架的自身更新速度非常块,而且还不断有新的出现。要想追赶上前端工具和框架的更新速度,你就需要抓住那些本质的知识,然后才能更加轻松地理解这些上层应用。比如我们接下来要介绍的 V8 执行机制,能帮助你从底层了解 JavaScript,也能帮助你深入理解语言转换器 Babel、语法检查工具 ESLint、前端框架 Vue 和 React 的一些底层实现机制。因此,了解 V8 的编译流程能让你对语言以及相关工具有更加充分的认识。(说实话,这些其实我也不清楚咋实现的,毕竟我也是个菜鸡,但 菜鸡也有变成雄鹰的一天,继续加油!)

要深入理解 V8 的工作原理,你需要搞清楚一些概念和原理,比如接下来我们要详细讲解的编译器(Compiler)、解释器(Interpreter)、抽象语法树(AST)、字节码(Bytecode)、即时编译器(JIT)等概念,都是你需要重点关注的。(这些名词需要会,如果之前不会,那就耐心往后看)

1、图解V8 执行 Javascript 过程

先来个V8 执行一段代码流程图,总览一下全局:

img

img

这个流程图现在看不懂没关系,先有个大概印象就行,请继续往下看,客官。

2、什么是编译器和解释器

编译器和解释器 之所以存在编译器和解释器,是因为机器不能直接理解我们所写的代码,所以在执行程序之前,需要将我们所写的代码“翻译”成机器能读懂的机器语言。按语言的执行流程,可以把语言划分为编译型语言解释型语言

编译型语言在程序执行之前,需要经过编译器的编译过程,并且编译之后会直接保留机器能读懂的二进制文件,这样每次运行程序时,都可以直接运行该二进制文件,而不需要再次重新编译了。比如 C/C++、GO 等都是编译型语言。

而由解释型语言编写的程序,在每次运行时都需要通过解释器对程序进行动态解释和执行。 比如 Python、JavaScript 等都属于解释型语言。

译器和解释器“翻译”代码流程:

在这里插入图片描述

从图中你可以看出这二者的执行流程,大致可阐述为如下:

  1. 在编译型语言的编译过程中,编译器首先会依次对源代码进行词法分析、语法分析,生成抽象语法树(AST),然后是优化代码,最后再生成处理器能够理解的机器码。如果 编译成功,将会生成一个可执行的文件。但如果编译过程发生了语法或者其他的错误,那么编译器就会抛出异常,最后的二进制文件也不会生成成功。
  2. 在解释型语言的解释过程中,同样解释器也会对源代码进行词法分析、语法分析,并生成抽象语法树(AST),不过它会再基于抽象语法树生成字节码,最后再根据字节码来 执行程序、输出结果。

提前说一下,现在的v8引擎是解释器和编译器配合使用的,这种技术称为即时编译(JIT)。具体后续再说。

3、具体阐述v8执行javascript流程

3.1. 由解析器(parser)解析(parse),生成抽象语法树(AST)和执行上下文

从最开始的流程图也可以得出结论,这一步最重要的是将源代码转换为抽象语法树,并生成执行上下文,而执行上下文我们在前面的文章中已经介绍过很多了,主要是代码在执行过程中的环境信息。

估计有同学会问,AST是啥玩意?说实话,我也不清楚,我最开始还以为这个就是DOM树结构呢,然并不是。

现在大掌柜就从AST是啥,以及为啥需要AST这玩意,还有这玩意是咋生成的,这几方面给大家稍作解释一下。

1、AST是啥玩意

高级语言是开发者可以理解的语言,但是让编译器或者解释器来理解就非常困难了。对于编译器或者解释器来说,它们可以理解的就是 AST 了。所以无论你使用的是解释型语言还是编译型语言,在编译过程中,它们都会生成一个 AST。这和渲染引擎将 HTML 格式文件转换为计算机可以理解的 DOM 树的情况类似。

给大家看看一段js代码,转换成AST结构是什么样的:

resources.jointjs.com/demos/javas…

在这里插入图片描述

AST 是非常重要的一种数据结构,编译器或者解释器后续的工作都需要依赖于 AST,而不是源代码。

例如:

其中最著名的一个项目是 Babel。Babel 是一个被广泛使用的代码转码器,可以将 ES6 代码转为 ES5 代码,这意味着你可以现在就用 ES6 编写程序,而不用担心现有环境是否支持 ES6。Babel 的工作原理就是先将 ES6 源码转换为 AST,然后再将 ES6 语法的 AST 转换为 ES5 语法的 AST,最后利用 ES5 的 AST 生成 JavaScript 源代码。

2、生成 AST 需要经过两个阶段

在这里插入图片描述

1第一阶段是分词(tokenize),又称为词法分析,其作用是将一行行的源码拆解成一个个token。所谓token,指的是语法上不可能再分的、最小的单个字符或字符串。你可以参考下图来更好地理解什么 token。

在这里插入图片描述

2、第二阶段是解析(parse),又称为语法分析,其作用是将上一步生成的 token 数据,根据语法规则转为 AST。如果源码符合语法规则,这一步就会顺利完成。但如果源码存在语法错误,这一步就会终止,并抛出一个“语法错误”。

这就是 AST 的生成过程,先分词,再解析。
有了 AST 后,那接下来 V8 就会生成该段代码的执行上下文。至于执行上下文的具体内容,你可以参考前面几篇文章的讲解。

编译原理之词法分析、语法分析、语义分析参考文章:

1、blog.csdn.net/lzj_lzj2014…

2、juejin.cn/post/697158…

3、zhuanlan.zhihu.com/p/96502646

3.2、通过解释器 Ignition,他会根据AST生成字节码

在这里插入图片描述

1、认识一下字节码,解释器 Ignition生成字节码

​ 有了 AST 和执行上下文后,那接下来的第二步,解释器 Ignition 就登场了,它会根据 AST生成字节码,并解释执行字节码。

​ 在计算机学科里聊效率,都逃避不了时间和空间这两个概念,绝大部分的优化都是空间换时间和时间换空间,两者的平衡,效率如何达到最高,是一个很值得深入研究的问题。

​ 其实一开始 V8 并没有字节码,而是直接将 AST 转换为机器码,由于执行机器码的效率是非常高效的,所以这种方式在发布后的一段时间内运行效果是非常好的。但是随着Chrome 在手机上的广泛普及,特别是运行在 512M 内存的手机上,内存占用问题也暴露出来了,因为 V8 需要消耗大量的内存来存放转换后的机器码。为了解决内存占用问题,V8 团队大幅重构了引擎架构,引入字节码,并且抛弃了之前的编译器,最终花了将进四年的时间,实现了现在的这套架构。 ​ 那什么是字节码呢?为什么引入字节码就能解决内存占用问题呢? 字节码就是介于 AST 和机器码之间的一种代码。

但是与特定类型的机器码无关,字节码需要通过解释器将其转换为机器码后才能执行。

字节码其实是机器码的抽象,各种字节码的相互构成,可以实现 JS 所需的所有功能,当然首先一点,字节码比机器码占用的内存要小很多很多,基本是机器码所在内存的几十甚至几百分之一,这样一来字节码缓存下来所消耗的内存还是可以接受的。

字节码和机器码占用空间对比:

在这里插入图片描述

从图中可以看出,机器码所占用的空间远远超过了字节码,所以使用字节码可以减少系统的内存使用。

3.3、执行代码

在这里插入图片描述

生成字节码之后,接下来就要进入执行阶段了。 通常,如果有一段第一次执行的字节码,解释器 Ignition 会逐条解释执行。在执行字节码的过程中,如果发现有热点代(HotSpot),比如一段代码被重复执行多次,这种就称为热点代码,那么后台的编译器 TurboFan 就会把该段热点的字节码编译为高效的机器码,然后当再次执行这段被优化的代码时,只需要执行编译后的机器码就可以了,这样就大大提升了代码的执行效率。

这里会有一个疑问,既然 CPU 不能识别字节码,还需要将字节码转成机器码呢,那不是多此一举,耽误时间嘛?

解释器在将 AST 转为字节码之后,会在执行的时候将字节码转成机器码,这个执行过程肯定是比直接执行机器码要慢的,所以在执行方面,速度上会比较慢,但是 JS 源码通过解析器转 AST,然后再通过解释器转字节码,这个过程是比编译器直接将 JS 源码转机器码要快很多的,全流程看来,整个时间上是差不了多少的,但是却减小了大量的内存占用,何乐而不为。

4、相关名词解释

热代码

在代码中,常常会有同一部分代码,被多次调用,同一部分代码如果每次都需要解释器转二进制代码再去执行,效率上来说,会有些浪费,所以在 V8 模块中会有专门的监控模块,来监控同一代码是否多次被调用,如果被多次调用,那么就会被标记为热代码,这有什么作用呢?

优化编译器

TurboFan (优化编译器) 这个词相信关注手机界的同学并不陌生,华为、小米等这些品牌,在近几年产品发布会上都会出现这个词,主要的能力是通过软件计算能力来优化一系列的功能,使得效率更优。

接着热代码继续说,当存在热代码的时候,V8 会借着 TurboFan 将为热代码的字节码转为机器码并缓存下来,这样一来,当再次调用热代码时,就不在需要将字节码转机器码,当然热代码相对来说还是少部分的,所以缓存也并不会占用太大内存,并且提升了执行效率,同样此处也是牺牲空间换时间。

反优化

JS 语言是动态语言,非常之灵活,对象的结构和属性在运行时是可以发生改变的,设想一个问题,如果热代码在某次执行的时候,突然其中的某个属性被修改了,那么编译成机器码的热代码还能继续执行吗?答案是肯定不能。这个时候就要使用到优化编译器的反优化了,他会将热代码退回到 AST 这一步,这个时候解释器会重新解释执行被修改的代码,如果代码再次被标记为热代码,那么会重复执行优化编译器的这个步骤。

5、总结

从分析的过程来看,V8 对 JS 执行的过程,

首先通过解释器(parser)解析(parse)javsscript源代码,生成抽象语法树(AST)和执行上下文,第二步,通过解释器 Ignition,他会根据AST生成字节码,然后再通过解释器 Ignition去逐行解释执行字节码。

最后在执行字节码的过程中,如果发现有热点代(HotSpot),比如一段代码被重复执行多次,这种就称为热点代码,那么后台的编译器 TurboFan 就会把该段热点的字节码编译为高效的机器码,然后当再次执行这段被优化的代码时,只需要执行编译后的机器码就可以了,这样就大大提升了代码的执行效率。

​ 整个流程不仅使用到了解释器,还用到了优化编译器。这种两者结合去处理的方式,业界称为 JIT (Just-In-Time)。使用这种结合的方式来处理 JS,主要是利用了 AST 形成的文件较小,而通过优化编译器编译后的热代码执行效率高,两者结合,各自发挥各自的优势,将效率尽量提升到最大。

即时编译(JIT)技术:

在这里插入图片描述

文章中如有描述不清楚的地方,大家可以给我留言,后续再整理相关文章,进行补充。如有更好的观点或者想法,也欢迎大佬们多多指导哦,感激不尽,有机会可以一起在杭州聚聚,游游西湖,吃吃烧烤。

参考文章:

1、v8执行javascript的过程

juejin.cn/post/697158…

2、 编译器和解释器:V8是如何执行一段JavaScript代码的?

time.geekbang.org/column/arti…

3、万字干货!详解JavaScript执行过程

mp.weixin.qq.com/s/NkFJVY_HL…

课后思考:

1、V8执行越久,被编译成机器码的热点代码就越多,所以整体执行效率就越高。如果是这样的话,那么V8内存占用也会越来越多。那这跟全部机器码有啥区别呢?

2、V8解析后的字节码或热节点的机器码是存在哪的?