JavaScript引擎是怎么工作的(V8)

130 阅读11分钟

Intro

现代浏览器是一个相当复杂的软件,其代码库包含数千万行代码。所以它被分成很多模块负责不同的逻辑。其中最重要的两个部分是 JavaScript 引擎和渲染引擎。

JavaScript引擎

每个主流浏览器都开发了自己的 JS 引擎:Google 的 Chrome 使用 V8,Safari 使用 JavaScriptCore,而 Firefox  使用 SpiderMonkey。

作为最强大的 JavaScript 引擎之一,V8 无处不在。在浏览器端,它支撑着 Chrome 以及众多 Chromium 内核的浏览器运行。在服务端,它是 Node.js 的执行环境。在桌面端领域,也同样有 V8 的一席之地。

对于大部分同学来说,JavaScript 引擎还只是一个黑盒,我们将一段代码丢给这个黑盒,它便会返回结果,并没有深入了解过它的工作原理。本文以V8为例,从编译原理切入,帮助大家理解 JavaScript 引擎的工作原理(本篇不会过多讲解每个执行流程的细节问题)。同样的,目前市面上 JavaScript 虚拟机都有着类似的架构。

JavaScript 引擎 / 虚拟机

你可以把 JavaScript 引擎看成是一个虚构出来的计算机,也称为虚拟机,虚拟机通过模拟实际计算机的各种功能来实现代码的执行,如模拟实际计算机的 CPU、堆栈、寄存器等,虚拟机还具有它自己的一套指令系统。

所以对于 JavaScript 代码来说,JavaScript 引擎就是它的整个世界,当 JavaScript 引擎执行 JavaScript 代码时,你并不需要担心现实中不同操作系统的差异,也不需要担心不同体系结构计算机的差异,你只需要按照虚拟机的规范写好代码就可以了。

一些前置知识:

高级语言是怎么被机器执行的?

编译 & 解释(动词)

计算机程序设计语言通常分为机器语言、汇编语言和高级语言三类。高级语言需要翻译成机器语言才能执行,而翻译的方式分为两种,一种是编译,另一种是解释。

  • 编译(Compile)

编译 的过程是把整个源程序代码翻译成另外一种代码,翻译后的代码等待被执行或者被优化等等,发生在运行之前,产物是另一份代码。

  • 解释(Interpret)

解释 的过程是把源程序代码一行一行的读懂,然后一行一行的执行,发生在运行时,产物是运行结果。

2.jpeg

  • 两者的特性比较

编译和解释的输入都是源程序代码(有可能是源码,中间代码等等),但是输出是不同的。

编译会把输入的源程序翻译生成为目标代码,并存下来(无论是存在内存中还是磁盘上),后续执行可以复用,翻译与执行是分开的;

解释则是把源程序中的指令逐条翻译并执行,不生成可存储的目标代码。

类型编译器解释器
工作机制编译和执行分离编译和执行同时运行
产物目标代码执行结果
启动速度相对较慢相对较快
运行速度相对较高相对较低

编译型语言 & 解释型语言(名词)

语言一般只会定义其抽象语义,而不会强制性要求采用某种实现方式。理论上,任何编程语言都可以是编译型或解释型的。但是,会根据其主流实现方式来把语言分为“编译型语言”和“解释型语言”。

对 C 语言或者其他编译型语言来说,编译生成了目标文件,而这个目标文件是针对特定的 CPU 体系的,为 ARM 生成的目标文件,不能被用于 x86 的 CPU。这段代码在编译过程中就已经被翻译成了目标 CPU 指令,所以,如果这个程序需要在另外一种 CPU 上面运行,这个代码就必须重新编译。这时可以理解为它的解释器是 CPU。

对于各种非编译型语言(例如python/java)来说,同样也可能存在某种编译过程,但他们编译生成的通常是一种『平台无关』的中间代码,这种代码一般不是针对特定的 CPU 平台,它们是在运行过程中才被翻译成目标 CPU 指令的,因而,在 ARM CPU 上能执行,换到 X86 也能执行,不需要重新对源代码进行编译。

至于为什么会有虚拟机的存在?因为那些非编译型语言生成的并不是目标平台的代码,而是某种中间代码。而能够运行这种中间代码的机器并不广泛存在,所以我们在每个不同的平台中用软件模拟出这个假想平台的虚拟机,这个虚拟机执行这种中间代码,而虚拟机负责把代码转换成最终的目标平台上的指令。

一个更简单的理解:

编译型语言是翻译成 machine code,machine去解释

解释型语言是翻译成 middle code,virtual machine去解释

V8的架构演进

早期架构

V8引擎的诞生带着使命而来,就是要在速度和内存回收上进行革命的。JavaScriptCore的架构是采用生成中间代码(字节码)的方式,然后解释执行字节码。Google觉得生成字节码会浪费时间,不如直接生成机器码快。

所以V8在前期的架构设计上是非常激进的,采用了直接编译成机器码的方式。可以看下V8的初期流程图:

早期的V8有基线编译器 Full-Codegen 和优化编译器 Crankshaft 两个编译器。代码被结构化为抽象语法树 (AST)后,V8 首先用 Full-Codegen 把所有的代码都编译一次,生成对应的机器码。JS在执行的过程中,V8筛选出热点函数并且记录参数的反馈类型,然后交给 Crankshaft 来进行优化。所以 Full-Codegen 本质上是生成的是未优化的机器码,而 Crankshaft 生成的是优化过的机器码。

其中,基线编译器更注重编译速度,而优化编译器更注重编译后代码的执行速度。综合使用基线编译器和优化编译器,使 JavaScript 代码拥有更快的冷启动速度,在优化后拥有更快的执行速度。

后期的实践证明Google的这套架构速度是有改善,但是随着版本的引进,网页的复杂化,V8也渐渐的暴露出了自己架构上的缺陷:

  • 直接编译生成机器码,导致内存占用大,编译时间长,启动速度慢
  • 优化编译器 只能优化 JavaScript 的一个子集
  • 编译管道中,层与层之间缺乏隔离,在某些情况下需要适配不同的CPU架构代码

现有架构

为了解决架构混乱和扩展困难的问题,V8 团队对早期的 V8 架构进行了非常大的重构,抛弃之前的基线编译器和优化编译器,引入了中间表示-字节码(Bytecode)、解释器 Ignition 和新的优化编译器 TurboFan,以及 JIT 编译技术。

JIT (Just In Time)编译是一种动态编译技术,相对于传统编译器而言,最大的区别在于编译时和运行时不分离,是一种在运行的过程中对代码进行动态编译的技术。

Ignition 将 AST 转换成字节码并解释执行。由于 Ignition 生成字节码 + 逐行解释执行的速度 比 Full-codegen 生成机器代码并执行的速度更快,因此新架构通常会改善脚本启动时间,从而改善网页加载。

在运行的过程中,还会使用类型反馈(TypeFeedback)技术并计算热点代码(重复被运行的代码,可以是方法也可以是循环体),最终交给 TurboFan 进行动态运行时的编译优化。

TurboFan 利用了 JIT 编译技术,主要作用是对 JavaScript 代码进行运行时编译优化为机器码。Ignition 生成的字节码可以直接用 TurboFan 生成优化的机器代码,而不必像 Crankshaft 那样从源代码重新编译。

在 Ignition 之前,V8的 Full-codegen 基线编译器生成的机器码通常占据 Chrome 整体 JavaScript 堆的近三分之一。优化后,也为 Web 应用程序留下了更多的内存空间。

不同架构下V8的内存对比

字节码的引入对V8优化起到了哪些作用?

早期的 V8 之所以抛弃字节码,直接将 JavaScript 代码编译成机器代码,是因为机器代码的执行性能非常高效,但是最新版本却朝着执行性能相反的方向进化,那么这是出于什么原因呢?

优化内存占用

早期, Chrome 为了优化启动速度,引入二进制代码缓存,通过把二进制代码保存在内存中来消除冗余的编译,这样就省去了再次编译的时间。很明显,采用缓存是一种典型的以空间换时间的策略。

然而,二进制代码所占用的内存空间是 JavaScript 代码的几千倍,在移动设备流行起来之后,V8 过度占用内存的问题就充分暴露出来了。因为通常一部手机的内存不会太大,如果过度占用内存,那么会导致 Web 应用的速度大大降低。

从图中可以看出,字节码虽然占用的空间比原始的 JavaScript 多,但是相较于机器代码,字节码还是小了太多。有了字节码,无论是解释器的解释执行,还是优化编译器的编译执行,都可以直接针对字节码来进行操作。由于字节码占用的空间远小于二进制代码,所以浏览器就可以实现缓存所有的字节码,而不是仅仅缓存顶层的字节码。

提升代码启动速度


从图中可以看出,生成机器代码比生成字节码需要花费更久的时间,但是直接执行机器代码却比解释执行字节码要更高效;

解释器可以快速生成字节码,但字节码执行通常效率不高。

所以在快速启动 JavaScript 代码 与花费更多时间获得运行性能更高的机器码之间,V8 需要找到一个平衡点。整体上权衡利弊,采用字节码也许是最优解。之所以说是最优解,是因为采用字节码在降低内存之外,也提升了代码的启动速度,而牺牲了一些执行速度

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

降低代码的复杂度

早期的 V8 代码,无论是基线编译器还是优化编译器,它们都是基于抽象语法树来将代码转换为机器码的,我们知道,不同架构的机器码是不一样的,而市面上存在不同架构的处理器又是非常之多,这意味着基线编译器和优化编译器要针对不同的体系的 CPU 编写不同的代码,这会大大增加代码量。

引入了字节码,就可以统一将字节码转换为不同平台的二进制代码。因为字节码的执行过程和 CPU 执行二进制代码的过程类似,相似的执行流程,那么将字节码转换为不同架构的二进制代码的工作量也会大大降低,这就降低了转换底层代码的工作量。

这里不得不提一下分层思想。计算机领域有句名言:计算机科学的任何问题都可以通过增加一个间接的中间层来解决。

在 V8 执行管道改进的过程中,通过引入中间表示层 IR(Intermediate representation),有效地提升了系统可扩展性,降低了关联模块的耦合度及系统的复杂度。

总结

我们今天了解了高级语言的执行,简单梳理了V8主流程上做的一些事情,V8的架构演进,以及字节码的引入对V8优化起到的作用。

事实上,V8 所做的事情,远远不止这些,如果细化到每个点,还有很多概念,比如内联缓存、隐藏类、快属性、慢属性、创建对象,垃圾回收等等,远不是一篇文章的体量,大家感兴趣可以查找资料深入了解。