一文让你搞懂各种虚拟机、解释器、JIT和AOT编译器

问题提出

java开发者都执行,我们用java编写的源码会被javac编译成字节码,然后jvm执行字节码就运行起来了。绝大多数初级java开发者对java程序的运行估计就理解到这个程度。其实,还有很多问题是要回答的?

  • 什么是字节码?为啥要有字节码的存在?不同VM的字节码一样吗?
  • VM对字节码是怎么执行的?
  • VM执行引擎中的解释器和编译器有什么不同?
  • JIT和AOT是什么含义呢?

什么是字节码?为啥要有字节码的存在?

字节码(byte code)是一种包含执行程序,由一系列OP代码(操作码)/数据对组成的二进制文件。字节码是一种中间码,它比机器码更抽象,需要借助虚拟机(VM)才行执行。通常情况下字节码是已经经过编译的(这里的编译指的是前端编译,后面会说说到),但与特定机器码无关。字节码通常不像源码一样可以让人阅读,而是编码后数值常量、引用、指令等构成的序列。
字节码主要为了实现特定软件运行和软件环境,与硬件环境无关。字节码的实现方式是通过编译器和虚拟机,编译器将源码编译成字节码,特定平台上的虚拟机通过执行引擎执行字节码,后面会说到执行引擎是怎么运作的。

不同VM的字节码一样吗?

JVM也有很多种,就单Oracle来说就有JRockit和Hotspot两款,JRockit号称是“世界上最快的java虚拟机”,还有就是IBM的J9 VM等。
Android早期,google专门为移动设备开发了一款虚拟机DalvikDalvik只能称做“虚拟机”,而不能称做“java虚拟机”,它没有遵循java虚拟机规范,不能直接执行java的.class文件,使用的是寄存器架构而不是JVM常见的栈架构。我们知道Dalvik执行的是dex文件,通过dx转换工具可以将JVM中的运行的字节码(.class格式)转换成Dalvik VM中运行的字节码(.dex格式),所以,Dalvik的字节码和JVM的字节码是不一样的。

VM对字节码是怎么执行的?

VM有java虚拟机也有Android虚拟机,它们对字节码的执行是不一样的,即便都是JVM,不同的java虚拟机对字节码的执行也是不一样的。
HotSpot VM采用解释器+自适应编译的执行引擎执行字节码,具体看HotSpot VM,JRockit VM采用JIT编译器+自适应编译器的执行引擎执行字节码,具体看JRockit VM

Dalvik和ART发展历程

2008年9月,Android发布,Dalvik VM的执行引擎是只有解释器的;
2010年5月,Android 2.2发布,Dalvik VM引入了JIT编译器,JIT的引入使得Dalvik的性能提升了3~6倍;
2013年10月,Android 4.4发布,Dalvik和ART并存;
2014年10月,Android 5.0发布,ART取代了Dalvik成为了VM,同时AOT也成为了唯一的编译模式;单纯的使用JIT和AOT都是有缺点的,具体看JIT编译和AOT编译比较
2016年8月,Android 7.0发布,JIT编译器回归,形成了AOT/JIT混合编译模式,吸取了两者的优点同时克服了缺点。

一些概念的解释

hot spot

“hot spot”这个拼写方式通常指比较宽泛的“热点”概念。在执行引擎的上下文中,“热点”指的是执行频率到的代码;至于“执行频率高的代码”是以什么为单位,是“方法/函数”级别还是“某条执行路径(trace)”级别,都可以;这是实现者可以选择的点。

HotSpot VM

HotSpot VM得名于它的混合模式执行引擎:这个执行引擎包含解释器和自适应编译器(adaptive compiler)。
默认配置下,一开始所有Java方法都是由解释器执行。解释器记录着每个方法的调用次数和循环次数,并以这两个数值为指标判断一个方法的“热度”,显然,HotSpot VM是以“方法”为单位来寻找热点代码的。等到一个方法足够“热”的时候,HotSpot VM就会启动对该方法的编译。

自适应编译(adaptive compilation)

在所有执行过的代码里只寻找一部分来编译的做法,叫做自适应编译。为了实现自适应编译,执行引擎通常需要有多层:至少要有一层能够处理初始阶段的执行,然后再自适应编译其中部分代码。

JIT编译

JIT编译,全称just-in-time compilation,按照其原始的、严格的定义,是每当一部分代码准备要第一次执行的时候,将这部分代码编译,然后跳进编译好的代码里执行。这样,所有执行过的代码都必然会被编译过。早期的JIT编译系统对同一块代码只会编译一次。JIT编译的单元也可以选择是方法/函数级别,或者别的,如trace。

动态编译(dynamic compilation)

JIT编译和自适应编译都属于动态编译,或者叫运行时编译的范畴,特点是在程序运行的时候进行编译,而不是在程序开始运行之前就完成了编译;后者叫做静态编译(static compilation)或AOT编译(ahead-of-time compilation)

严格说JIT编译与自适应编译相比:

  • 前者的编译时机比后者早:第一次执行之前 vs 已经被执行过若干次
  • 前者编译的代码比后者多:所有执行过的代码 vs 一部分代码

现在“JIT编译”这个名称已经被泛化为等价于“动态编译”,所以包含了严格的JIT编译和自适应编译。 也就是说,提到JIT编译,它其实说的是动态编译(运行时编译),它具体指的可能是自适应编译也可能是严格的JIT编译。按照绝大多少文章的上下文,JIT编译说的是根据热点进行编译的自适应编译。
比如,上面提到的HotSpot VM中的自适应编译在很多时候被叫做“JIT编译”;里面的Client Compiler(C1)和Server Compiler(C2)也常被称为“JIT编译器”。

JRockit VM

JRockit VM使用纯编译的执行引擎,没有解释器。但它有多层编译:第一次执行某个方法之前会用非常低的优化级别去JIT编译,然后等到某个方法足够热之后再用较高的优化级别重新编译它。这种系统既是严格意义上的JIT编译(第一次执行某个方法前编译它),又是自适应编译(找出热点再进行编译)。
所以说JIT编译与自适应编译可以共存。只不过HotSpot VM因为有解释器来承担第一层执行的任务,没使用JIT编译而已。

前端编译

我们运行javac命令的过程,其实就是javac编译器解析Java源代码并生成字节码文件的过程,这个过程就叫做前端编译。 说白了,其实就是使用javac编译器把Java语言规范转化为字节码语言规范。
javac 编译器的处理过程可以分为下面四个阶段:

  • 第一个阶段:词法、语法分析。在这个阶段,JVM 会对源代码的字符进行一次扫描,最终生成一个抽象的语法树。简单地说,在这个阶段 JVM 会搞懂我们的代码到底想要干嘛。就像我们分析一个句子一样,我们会对句子划分主谓宾,弄清楚这个句子要表达的意思一样。
  • 第二个阶段:填充符号表。我们知道类之间是会互相引用的,但在编译阶段,我们无法确定其具体的地址,所以我们会使用一个符号来替代。在这个阶段做的就是类似的事情,即对抽象的类或接口进行符号填充。等到类加载阶段,JVM会将符号替换成具体的内存地址。
  • 第三个阶段:注解处理。我们知道 Java 是支持注解的,因此在这个阶段会对注解进行分析,根据注解的作用将其还原成具体的指令集。
  • 第四个阶段:分析与字节码生成。到了这个阶段,JVM 便会根据上面几个阶段分析出来的结果,进行字节码的生成,最终输出为 class 文件。

JIT编译和AOT编译比较

我们以Android平台为例,这里说的JIT编译是泛化的概念,具体指的是自适应编译。
JIT和AOT的不同之处在于:JIT是在运行时进行编译,是动态编译,并且每次运行程序的时候都需要对odex重新进行编译;而AOT是静态编译,应用在安装的时候会启动dex2oat把dex预编译成ELF文件,每次运行程序的时候不用重新编译,是真正意义上的本地应用。

JIT编译模式的缺点:

  • 每次启动应用都需要重新编译;
  • 运行时比较耗电,造成电池额外的开销;

AOT编译模式的缺点:

  • 应用安装和系统升级之后的应用优化比较耗时;
  • 优化后文件会占用额外的存储空间;

AOT的缺点就是JIT的优点,JIT的缺点也就是AOT的优点,即每次启动应用和应用运行时不需要编译,很快,同时也省电了。

ART VM中AOT/JIT混合编译

应用在安装的时候dex不会被编译,在运行dex文件先通过解释器(Interpreter)解释执行(这一步骤跟Android2.2-Android4.4的行为是一致的),与此同时,热点函数(hot code)会被识别并被JIT编译后存储在jit code cache中并生成profile文件以记录热点函数的信息。手机进入IDLE(空闲)或者Charging(充电)状态的时候,系统会扫描App目录下的profile文件并执行AOT编译。
也就说,应用在安装的时候没有编译,所以安装会很快,首次启动和运行时还是采用解释器+JIT编译的模式,虽然也有慢和耗电问题,但是,被系统AOT编译后,以后启动和运行就很快了,也不耗电。

Dalvik VM

Dalvik虚拟机是Google等厂商合作开发的Android移动设备平台的核心组成部分之一,它可以支持已转换为.dex格式的java应用程序的运行,.dex格式是专为Dalvik设计的一种压缩格式,适合内存和处理器速度有限的系统
Dalvik经过优化,允许在有限的内存中同时运行多个虚拟机实例,并且每个Dalvik应用做为一个独立的Linux进程执行。独立的进程可以防止在虚拟机崩溃的时候所有程序都被关闭。
Dalvik早期是采用解释器执行dex字节码的,Android 2.2加入了JIT编译器,采用了解释器+JIT编译的方式,虽然性能提升了,可是每次启动应用都得动态编译,效率还是不是很高,Android 4.4引入了ART VM采用AOT编译模式(静态编译),5.0彻底抛弃了Dalvik,7.0采用了AOT+JIT混合编译模式。

DVM(Dalvik VM)与JVM的区别

DVM之所以不是一个JVM,主要原因是DVM并没有遵循JVM规范来实现,主要区别如下:

  • 基于的架构不同

JVM基于栈实现的,意味着需要去栈中读写数据,所需的指令会更多,这样会导致速度慢,对于性能有限的移动设备,显然不是很合适。
DVM是基于寄存器的,它没有基于栈的虚拟机在拷贝数据而使用的大量的出入栈指令,同时指令更紧凑更简洁;但是,由于显示指定了操作数,所以基于寄存器的指令会比基于栈的指令要大,但是由于指令数量的减少,总的代码数不会增加多少。

  • 执行的字节码不同

在Java SE中,Java类会被编译成一个或多个.class文件,打包成jar文件,而后JVM会通过相应的.class文件和jar文件获取字节码;执行顺序为:.java文件 -> .class文件 -> .jar文件。而DVM会用dx工具将所有的.class文件转换为一个.dex文件,然后DVM会从该.dex文件读取指令和数据;执行顺序为:.java文件 -> .class文件 -> .dex文件。
.jar文件里面包含多个.class文件,每个.class文件里面包含了该类的常量池、类信息、属性等等。当JVM加载该.jar文件的时候,会加载里面的所有的.class文件,JVM的这种加载方式很慢,对于内存有限的移动设备并不合适。 而在.apk文件中只包含了一个.dex文件,这个.dex文件里面将所有的.class里面所包含的信息全部整合在一起了,这样再加载就提高了速度。.class文件存在很多的冗余信息,dex工具会去除冗余信息,并把所有的.class文件整合到.dex文件中,减少了I/O操作,提高了类的查找速度。

通过上面的解释,我们知道DVM为了移动设备做了很多优化,这是因为移动设备有内存和处理器速度有限的特点。首先,架构变成了基于寄存器的,相应的指令集也进行了变化,指令个数变少了,而且对内存的读取变少了;然后,对由指令和数据组成的执行程序,也就是字节码,进行了编码和优化。

  • DVM允许在有限的内存中同时运行多个进程

DVM经过优化,允许在有限的内存中同时运行多个进程。在Android中的每一个应用都运行在一个DVM实例中,每一个DVM实例都运行在一个独立的进程空间。独立的进程可以防止在虚拟机崩溃的时候所有程序都被关闭。

难道JVM是一个进程运行多个应用的吗?

  • DVM由Zygote创建和初始化

Zygote可以称它为孵化器,它是一个DVM进程,同时它也用来创建和初始化DVM实例。每当系统需要创建一个应用程序时,Zygote就会fork自身,快速地创建和初始化一个DVM实例,用于应用程序的运行。

  • DVM架构

DVM的源码位于dalvik/目录下,其中dalvik/vm目录下的内容是DVM的具体实现部分,它会被编译成 libdvm.so;dalvik/libdex会被编译成libdex.a静态库,作为dex工具使用;dalvik/dexdump是.dex文件的反编译工具;DVM的可执行程序位于dalvik/dalvikvm中,将会被编译成dalvikvm可执行程序。

  • DVM的运行时堆

DVM的运行时堆主要由两个Space以及多个辅助数据结构组成,两个Space分别是Zygote Space(Zygote Heap)Allocation Space(Active Heap)。Zygote Space用来管理Zygote进程在启动过程中预加载和创建的各种对象,Zygote Space中不会触发GC,所有进程都共享该区域,比如系统资源。Allocation Space是在Zygote进程fork第一个子进程之前创建的,它是一种私有进程,Zygote进程和fock的子进程在Allocation Space上进行对象分配和释放。

除了这两个Space,还包含以下数据结构:

Card Table: 用于DVM Concurrent GC,当第一次进行垃圾标记后,记录垃圾信息。 Heap Bitmap: 有两个Heap Bitmap,一个用来记录上次GC存活的对象,另一个用来记录这次GC存活的对象。 Mark Stack: DVM的运行时堆使用标记-清除(Mark-Sweep)算法进行GC,不了解标记-清除算法的同学查看Java虚拟机(四)垃圾收集算法这篇文章。Mark Stack就是在GC的标记阶段使用的,它用来遍历存活的对象。

参考

机器码(machine code)和字节码(byte code)是什么?
JVM虚拟机种类
Dalvik和Java字节码的对比
HotSpot是较新的Java虚拟机技术,用来代替JIT技术,那么HotSpot和JIT是共存的吗? - RednaxelaFX的回答 - 知乎
为什么 JVM 不用 JIT 全程编译?
JVM基础系列第4讲:从源代码到机器码,发生了什么?
Java 执行引擎(从字节码到机器码)
Dalvik 和 ART 有什么区别?深扒 Android 虚拟机发展史,真相却出乎意料!
Dalvik虚拟机和ART(Android RunTime)的区别
Android运行环境Dalvik模式和ART模式的区别
说说 Android 虚拟机Dalvik与ART区别在哪里?
虚拟机随谈(一):解释器,树遍历解释器,基于栈与基于寄存器,大杂烩

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章