JavaScript深入浅出第4课V8引擎是如何工作的

2次阅读

共计 7591 个字符,预计需要花费 19 分钟才能阅读完成。

摘要: 性能彪悍的 V8 引擎。

《JavaScript 深入浅出》系列

  • JavaScript 深入浅出第 1 课:箭头函数中的 this 究竟是什么鬼?
  • JavaScript 深入浅出第 2 课:函数是一等公民是什么意思呢?
  • JavaScript 深入浅出第 3 课:什么是垃圾回收算法?
  • JavaScript 深入浅出第 4 课:V8 是如何工作的?

最近,JavaScript 生态系统又多了 2 个非常硬核的项目。

大神 Fabrice Bellard 发布了一个新的 JS 引擎 QuickJS,可以将 JavaScript 源码转换为 C 语言代码,然后再使用系统编译器 (gcc 或者 clang) 生成可执行文件。

Facebook 为 React Native 开发了新的 JS 引擎 Hermes,用于优化安卓端的性能。它可以在构建 APP 的时候将 JavaScript 源码编译为 Bytecode,从而减少 APK 大小、减少内存使用,提高 APP 启动速度。

作为 JavaScript 程序员,只有极少数人有机会和能力去实现一个 JS 引擎,但是理解 JS 引擎还是很有必要的。本文将介绍一下 V8 引擎的原理,希望可以给大家一些帮助。

JavaScript 引擎

我们写的 JavaScript 代码直接交给浏览器或者 Node 执行时,底层的 CPU 是不认识的,也没法执行。CPU 只认识自己的指令集,指令集对应的是汇编代码。写汇编代码是一件很痛苦的事情,比如,我们要计算 N 阶乘的话,只需要 7 行的递归函数:

function factorial(N) {if (N === 1) {return 1;} else {return N * factorial(N - 1);
    }
}

代码逻辑也非常清晰,与阶乘数的学定义完美吻合,哪怕不会写代码的人也能看懂。

但是,如果使用汇编语言来写 N 阶乘的话,要 300+ 行代码 n -factorial.s:

这个 N 阶乘的汇编代码是我大学时期写的,已经是 N 年前的事情了,它需要处理 10 进制与 2 进制的转换,需要使用多个字节保存大整数,最多可以计算大概 500 左右的 N 阶乘。

还有一点,不同类型的 CPU 的指令集是不一样的,那就意味着得给每一种 CPU 重写汇编代码,这就很崩溃了。。。

还好,JavaScirpt 引擎可以将 JS 代码编译为不同 CPU(Intel, ARM 以及 MIPS 等)对应的汇编代码,这样我们才不要去翻阅每个 CPU 的指令集手册。当然,JavaScript 引擎的工作也不只是编译代码,它还要负责执行代码、分配内存以及垃圾回收。

虽然浏览器非常多,但是主流的 JavaScirpt 引擎其实很少,毕竟开发一个 JavaScript 引擎是一件非常复杂的事情。比较出名的 JS 引擎有这些:

  • V8 (Google)
  • SpiderMonkey (Mozilla)
  • JavaScriptCore (Apple)
  • Chakra (Microsoft)
  • IOT:duktape、JerryScript

还有,最近发布 QuickJS 与 Hermes 也是 JS 引擎,它们都超越了浏览器范畴,Atwood’s Law 再次得到了证明:

Any application that can be written in JavaScript, will eventually be written in JavaScript.

V8:强大的 JavaScript 引擎

在为数不多 JavaScript 引擎中,V8 无疑是最流行的,Chrome 与 Node.js 都使用了 V8 引擎,Chrome 的市场占有率高达 60%,而 Node.js 是 JS 后端编程的事实标准。国内的众多浏览器,其实都是基于 Chromium 浏览器开发,而 Chromium 相当于开源版本的 Chrome,自然也是基于 V8 引擎的。神奇的是,就连浏览器界的独树一帜的 Microsoft 也投靠了 Chromium 阵营。另外,Electron 是基于 Node.js 与 Chromium 开发桌面应用,也是基于 V8 的。

V8 引擎是 2008 年发布的,它的命名灵感来自超级性能车的 V8 引擎,敢于这样命名确实需要一些实力,它性能确实一直在稳步提高,下面是使用 Speedometer benchmark 的测试结果:

V8 在工业界已经非常成功了,同时它还获得了学术界的肯定,拿到了 ACM SIGPLAN 的 Programming Languages Software Award:

V8’s success is in large part due to the efficient machine code it generates.
Because JavaScript is a highly dynamic object-oriented language, many experts believed that this level of performance could not be achieved.
V8’s performance breakthrough has had a major impact on the adoption of JavaScript, which is nowadays used on the browser, the server, and probably tomorrow on the small devices of the internet-of-things.

JavaScript 是一门动态类型语言,这会给编译器增加很大难度,因此专家们觉得它的性能很难提高,但是 V8 居然做到了,生成了非常高效的 machine code(其实是汇编代码),这使得 JS 可以应用在各个领域,比如 Web、APP、桌面端、服务端以及 IOT。

严格来讲,V8 所生成的代码是汇编代码而非机器代码,但是 V8 相关的文档、博客以及其他资料都把 V8 生成的代码称作 machine code。汇编代码与机器代码很多是一一对应的,也很容易互相转换,这也是反编译的原理,因此他们把 V8 生成的代码称为 Machine Code 也未尝不可,但是并不严谨。

V8 引擎的内部结构

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

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

  • Parser:负责将 JavaScript 源码转换为 Abstract Syntax Tree (AST)
  • Ignition:interpreter,即解释器,负责将 AST 转换为 Bytecode,解释执行 Bytecode;同时收集 TurboFan 优化编译所需的信息,比如函数参数的类型;
  • TurboFan:compiler,即编译器,利用 Ignitio 所收集的类型信息,将 Bytecode 转换为优化的汇编代码;
  • Orinoco:garbage collector,垃圾回收模块,负责将程序不再需要的内存空间回收;

其中,Parser,Ignition 以及 TurboFan 可以将 JS 源码编译为汇编代码,其流程图如下:

简单地说,Parser 将 JS 源码转换为 AST,然后 Ignition 将 AST 转换为 Bytecode,最后 TurboFan 将 Bytecode 转换为经过优化的 Machine Code(实际上是汇编代码)。

  • 如果函数没有被调用,则 V8 不会去编译它。
  • 如果函数只被调用 1 次,则 Ignition 将其编译 Bytecode 就直接解释执行了。TurboFan 不会进行优化编译,因为它需要 Ignition 收集函数执行时的类型信息。这就要求函数至少需要执行 1 次,TurboFan 才有可能进行优化编译。
  • 如果函数被调用多次,则它有可能会被识别为热点函数,且 Ignition 收集的类型信息证明可以进行优化编译的话,这时 TurboFan 则会将 Bytecode 编译为 Optimized Machine Code,以提高代码的执行性能。

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

function add(x, y) {return x + y;}

add(1, 2);
add("1", "2");

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

Ignition:解释器

Node.js 是基于 V8 引擎实现的,因此 node 命令提供了很多 V8 引擎的选项,使用 node 的 --print-bytecode 选项,可以打印出 Ignition 生成的 Bytecode。

factorial.js 如下,由于 V8 不会编译没有被调用的函数,因此需要在最后一行调用 factorial 函数。

function factorial(N) {if (N === 1) {return 1;} else {return N * factorial(N - 1);
    }
}

factorial(10); // V8 不会编译没有被调用的函数,因此这一行不能省略

使用 node 命令 (node 版本为 12.6.0) 的--print-bytecode选项,打印出 Ignition 生成的 Bytecode:

node --print-bytecode factorial.js

控制台输出的内容非常多,最后一部分是 factorial 函数的 Bytecode:

[generated bytecode for function: factorial]
Parameter count 2
Register count 3
Frame size 24
   18 E> 0x3541c2da112e @    0 : a5                StackCheck
   28 S> 0x3541c2da112f @    1 : 0c 01             LdaSmi [1]
   34 E> 0x3541c2da1131 @    3 : 68 02 00          TestEqualStrict a0, [0]
         0x3541c2da1134 @    6 : 99 05             JumpIfFalse [5] (0x3541c2da1139 @ 11)
   51 S> 0x3541c2da1136 @    8 : 0c 01             LdaSmi [1]
   60 S> 0x3541c2da1138 @   10 : a9                Return
   82 S> 0x3541c2da1139 @   11 : 1b 04             LdaImmutableCurrentContextSlot [4]
         0x3541c2da113b @   13 : 26 fa             Star r1
         0x3541c2da113d @   15 : 25 02             Ldar a0
  105 E> 0x3541c2da113f @   17 : 41 01 02          SubSmi [1], [2]
         0x3541c2da1142 @   20 : 26 f9             Star r2
   93 E> 0x3541c2da1144 @   22 : 5d fa f9 03       CallUndefinedReceiver1 r1, r2, [3]
   91 E> 0x3541c2da1148 @   26 : 36 02 01          Mul a0, [1]
  110 S> 0x3541c2da114b @   29 : a9                Return
Constant pool (size = 0)
Handler Table (size = 0)

生成的 Bytecode 其实挺简单的:

  • 使用 LdaSmi 命令将整数 1 保存到寄存器;
  • 使用 TestEqualStrict 命令比较参数 a0 与 1 的大小;
  • 如果 a0 与 1 相等,则 JumpIfFalse 命令不会跳转,继续执行下一行代码;
  • 如果 a0 与 1 不相等,则 JumpIfFalse 命令会跳转到内存地址 0x3541c2da1139

不难发现,Bytecode 某种程度上就是汇编语言,只是它没有对应特定的 CPU,或者说它对应的是虚拟的 CPU。这样的话,生成 Bytecode 时简单很多,无需为不同的 CPU 生产不同的代码。要知道,V8 支持 9 种不同的 CPU,引入一个中间层 Bytecode,可以简化 V8 的编译流程,提高可扩展性。

如果我们在不同硬件上去生成 Bytecode,会发现生成代码的指令是一样的:

TurboFan:编译器

使用 node 命令的 --print-code 以及 --print-opt-code 选项,打印出 TurboFan 生成的汇编代码:

node --print-code --print-opt-code factorial.js

我是在 Mac 上运行的,结果如下图所示:

比起 Bytecode,正真的汇编代码可读性差很多。而且,机器的 CPU 类型不一样的话,生成的汇编代码也不一样。

这些汇编代码就不用去管它了,因为最重要的是理解 TurboFan 是如何优化所生成的汇编代码的。我们可以通过 add 函数来梳理整个优化过程。

function add(x, y) {return x + y;}

add(1, 2);
add(3, 4);
add(5, 6);
add("7", "8");

由于 JS 的变量是没有类型的,所以 add 函数的参数可以是任意类型:Number、String、Boolean 等,这就意味着 add 函数可能是数字相加(V8 还会区分整数和浮点数),可能是字符串拼接,也可能是其他更复杂的操作。如果直接编译的话,生成的代码比如会有很多 if…else 分支,伪代码如下:

if (isInteger(x) && isInteger(y)) {// 整数相加} else if (isFloat(x) && isFloat(y)) {// 浮点数相加} else if (isString(x) && isString(y)) {// 字符串拼接} else {// 各种其他情况}

我只写了 4 个分支,实际上的分支其实更多,比如当参数类型不一致时还得进行类型转换,大家不妨看看 ECMASCript 对加法是如何定义的:12.8.3The Addition Operator (+)。

如果直接按照伪代码去生成汇编代码,那生成的代码必然非常冗长,这样会占用很多内存空间。

Ignition 在执行 add(1, 2) 时,已经知道 add 函数的两个参数都是整数,那么 TurboFan 在编译 Bytecode 时,就可以假定 add 函数的参数是整数,这样可以极大地简化生成的汇编代码,伪代码如下:

if (isInteger(x) && isInteger(y)) {// 整数相加} else {// Deoptimization}

当然这样做也是有风险的,因为如果 add 函数参数不是整数,那么生成的汇编代码也没法执行,只能 Deoptimize 为 Bytecode 来执行。

也就是说,如果 TurboFan 对 add 函数进行编译优化的话,则 add(3, 4)add(3, 4)可以执行优化的汇编代码,但是 add("7", "8") 只能 Deoptimize 为 Bytecode 来执行。

当然,TurboFan 所做的也不只是根据类型信息来简化代码执行流程,它还会进行其他优化,比如减少冗余代码等更复杂的事情。

由这个简单的例子可知,如果我们的 JS 代码中变量的类型变来变去,是会给 V8 引擎增加不少麻烦的,为了提高性能,我们可以尽量不要去改变变量的类型。

对于性能要求比较高的项目,使用 TypeScript 也是不错的选择,理论上,如果严格遵守类型化的编程方式,也是可以提高性能的,类型化的代码有利于 V8 引擎优化编译的汇编代码,当然这一点还需要测试数据来证明。

Orinoco:垃圾回收

强大的垃圾回收功能是 V8 实现提高性能的关键之一,因为它可以在避免影响 JS 代码执行的情况下,同时回收内存空间,提高内存利用效率。

关于垃圾回收,我在 JavaScript 深入浅出第 3 课:什么是垃圾回收算法?中有详细介绍,这里就不再赘述了。

JS 引擎的未来

V8 引擎确实很强大,但是它也不是无所不能的,简单地分析都可以发现一些可以优化的点。

我有一个新的想法,还没想好名字,不妨称作 Optimized TypeScript Engine:

  • 使用 TypeScript 编程,遵循严格的类型化编程规则,不要写成 AnyScript 了;
  • 构建的时候将 TypeScript 直接编译为 Bytecode,而不是生成 JS 文件,这样运行的时候就省去了 Parse 以及生成 Bytecode 的过程;
  • 运行的时候,需要先将 Bytecode 编译为对应 CPU 的汇编代码;
  • 由于采用了类型化的编程方式,有利于编译器优化所生成的汇编代码,省去了很多额外的操作;

这个想法其实可以基于 V8 引擎来实现,技术上应该是可行的:

  • 将 Parser 以及 Ignition 拆分出来,用于构建阶段;
  • 删掉 TurboFan 处理 JS 动态特性的相关代码;

这样做,可以将 JS 引擎简化很多,一方面不再需要 parse 以及生成 bytecode,另一方面编译器不再需要因为 JavaScript 动态特性做很多额外的工作。因此可以减少 CPU、内存以及电量的使用,优化性能,唯一的问题可能是必须使用严格的 TS 语法进行编程。

为啥要这样做呢?因为对于 IOT 硬件来说,CPU、内存、电量都是需要省着点用的,不是每一个智能家电都需要装一个骁龙 855,如果希望把 JS 应用到 IOT 领域,必然需要从 JS 引擎角度去进行优化,只是去做上层的框架是没有用的。

其实,Facebook 的 Hermes 差不多就是这么干的,只是它没有要求用 TS 编程。

这应该是 JS 引擎的未来,大家会看到越来越多这样的趋势。

关于 JS,我打算花 1 年时间写一个系列的博客《JavaScript 深入浅出》,大家还有啥不太清楚的地方?不妨留言一下,我可以研究一下,然后再与大家分享一下。欢迎添加我的个人微信(KiwenLau),我是 Fundebug 的技术负责人,一个对 JS 又爱又恨的程序员。

参考

  • Celebrating 10 years of V8
  • Launching Ignition and TurboFan
  • JavaScript engines – how do they even?
  • An Introduction to Speculative Optimization in V8
  • 2018 年,JavaScript 都经历了什么?
  • JavaScript 深入浅出第 3 课:什么是垃圾回收算法?
  • Fabrice Bellard 是个什么水平的程序员?
  • 如何评价 Fabrice Bellard 发布 QuickJS JS 引擎?

关于 Fundebug

Fundebug 专注于 JavaScript、微信小程序、微信小游戏、支付宝小程序、React Native、Node.js 和 Java 线上应用实时 BUG 监控。自从 2016 年双十一正式上线,Fundebug 累计处理了 10 亿 + 错误事件,付费客户有阳光保险、核桃编程、荔枝 FM、掌门 1 对 1、微脉、青团社等众多品牌企业。欢迎大家免费试用!

版权声明

转载时请注明作者 Fundebug以及本文地址:
https://blog.fundebug.com/2019/07/16/how-does-v8-work/

正文完
 0