编译模式
几种编译模式

关于编译模式,目前编程语言大多以下几方式运行:

  • 机器码 AOT 编译

在程序运行之前,进行 AST 生成和代码编译,编译为机器码,在运行的时候无需编译,直接运行,比如 C 语言。

  • 中间产物 AOT 编译

在程序运行前进行 AST 生成并进行编译,但不是编译为机器码,而是编译为中间产物,之后在运行时将字节码解释为机器码再执行。比如 Java 编译为字节码,之后运行时 JVM 解释执行字节码。

  • 完全的解释执行

在程序运行前不进行任何编译,在运行时动态地根据源码生成 AST,再编译为字节码,最后解释执行字节码。比如没有开启 JIT 的 V8 引擎执行 JS 代码时的流程。

  • 混合的 JIT 编译

在通过解释执行字节码时(运行时动态生成或者 AOT 编译生成),对多次执行的热点代码进行进一步的优化编译,生成机器码,后续执行到这部分逻辑时,直接执行优化后的机器码。比如开启 JIT 的V8 引擎运行 JS 或者支持 JIT 的 JVM 运行 class 文件。

当然,以上仅考虑生产环境下的运行方式,不考虑部分语言在生产和开发阶段不同的运行方式。比如Dart 和 Swift,一般是开发阶段通过 JIT 实时编译快速启动,生产环境下为了性能通过 AOT 编译。

JS 的编译模式

在 V8 JIT 出现之前,所有的 JS 虚拟机所采用的都是采用的完全解释执行的方式,在运行时把源码生成 AST 语法树,之后生成字节码,然后将字节码解释为机器码执行,这是 JS 执行速度过慢的主要原因之一。

而这么做有以下两个方面的原因:

  1. JS 是动态语言,变量类型在运行时可能改变
  2. JS 主要用于 Web 应用,Web 应用如果提前编译为字节码将导致体积增大很多,对网络资源的消耗会更大
JS 变量类型在运行时可能改变

这张图描述了现在 V8 引擎的工作流程,目前 Chrome 和 Node 里的 JS 引擎都是这个:

从上面可以看到,V8 在拿到 JS 源码后,会先解析成 AST,之后经过 Ignition 解释器把语法树编译成字节码,然后再解释字节码执行。

于此同时还会收集热点代码,比如代码一共运行了多少次、如何运行的等信息,也就是上面的Feedback 的流程。

如果发现一段代码会被重复执行,则监视器会将此段代码标记为热点代码,交给 V8 的 Turbofan 编译器对这段字节码进行编译,编译为对应平台(Intel、ARM、MIPS等)的二进制机器码,并执行机器码,也就是图里的 Optimize 流程。

等后面 V8 再次执行这段代码,则会跳过解释器,直接运行优化后的机器码,从而提升这段代码的运行效率。

但是我们发现,图里面除了 Optimize 外,还有一个Deoptimize,反优化,也就是说被优化成机器码的代码逻辑,可能还会被反优化回字节码,这是为什么呢?

其实原因就是上面提到的“JS 变量类型在运行时可能改变”,我们来看一个例子:

比如一个 add 函数,因为 JS 没有类型信息,底层编译成字节码后伪代码逻辑大概如这张图所示。会判断 x 和 y 的各种类型,逻辑比较复杂。

在 Ignition 解释器执行 add(1, 2) 时,已经知道 add 函数的两个参数都是整数,那么 TurboFan 在进一步编译字节码时,就可以假定 add 函数的参数是整数,这样可以极大地简化生成的汇编代码,不再判断各种类型,伪代码如第三张图里所示。

接下来的 add(3, 4) 与 add(5, 6) 由于入参也是整数,可以直接执行之前编译的机器码,但是 add("7", "8") 时,发现并不是整数,这时候就只能将这段逻辑Deoptimize为字节码,然后解释执行字节码。

这就是所谓的 Deoptimize,反优化。可以看出,如果我们的 JS 代码中变量的类型变来变去,是会给V8 引擎增加不少麻烦,为了提高性能,我们可以尽量不要去改变变量的类型。

虽然说使用 TS 可以部分缓解这个问题,但是也只能约束开发时的类型,运行的时候 TS 的类型信息是会被丢弃的,V8 还是要做上面的一些假定类型的优化,无法一开始就编译为机器码。

可以说 TS 的类型信息被浪费了,没有给运行时代码特别大的好处。

JS 编译为字节码将导致体积增大

上面说到 JS 主要用于 Web 应用,Web 应用如果提前编译为字节码将导致体积增大很多,对网络资源的消耗会更大。

那么对于非 Web 应用,其实是可以做到提前编译为字节码的,比如 Hermes 引擎。

Hermes 作为 React Native 的运行时,是作为 App 预装到用户的设备上,除了热更新这种场景外,绝大部分情况下是不需要打开 App 时动态下载代码资源的,所以体积增大的问题影响不是很大,但是预编译带来的运行时效率提升的好处却比较明显。

所以相对于 V8,Hermes 去掉了JIT,支持了生成字节码,在构建 App 的时候,就把 JS 代码进行了预编译,预编译为了 Hermes 运行时可以直接处理的字节码,省去了在运行阶段解析 AST 语法树、编译为字节码的工作。

一句题外话,Hermes 去除了对 JIT 的支持,除了因为 JIT 会导致 JS 引擎启动时间变长、内存占用增大外,还有一部分可能的原因是,在 iOS上苹果为了安全考虑,不允许除了 Safari 和 WebView(只有 WKWebView 支持 JIT,UIWebView 不支持)之外的第三方应用里直接使用 JSC 的 JIT 能力,也不允许第三方 JS 运行时支持 JIT。甚至 V8 专门出了一个去掉 JIT 的 JIT-less V8 版本来在 iOS 上集成。

取长补短?

还记得上面说的“TS 的类型信息被浪费了”吗?TS 的类型信息只在开发时有用,在编译阶段就被丢弃了,而 ArkCompiler 就是利用了这一点,直接在 App 构建阶段,利用 TS 的类型信息直接预编译为字节码以及优化机器码。

即在 ArkCompiler 中,不存在 TS->JS 的这一步转译,而是直接从 TS 编译为了字节码和优化机器码(这里存疑,官网文档没有找到很明确的说法)。

同时由于鸿蒙应用也是一个 App 而不是 Web 应用,所以 ArkCompiler 和 Hermes 一样,也是在构建App 时就进行了预编译,而不是在运行阶段做这个事情。

简单总结下来,ArkCompiler 像 Hermes 一样支持生成字节码,同时又将 V8 引擎 JIT 生成机器码的工作也提前在预编译阶段做了。是比 Hermes 只生成字节码的 AOT 更进一步的 AOT(同时生成字节码和部分优化后的机器码)。