深入理解Android虚拟机及编译系统

>【版权申明】非商业目的可自由转载

出自:shusheng007

概述

做Android开发前后也有几年了,多数时候还是在写业务代码,很少去研究总结一下基础的东西。然而基础知识才是程序员技术及发展潜力的试金石,根基打牢了,搞啥都快。

今天心中突然有个疑问:一个App从源代码到安装文件,再到安装到设备上,最后呈现在用户面前,这期间都经历了什么?我发现自己不能完全而清楚的知晓其中的细节,于是就去调查了一下,总结在这篇文章里。提前打个招呼,本文具有一定的技术难度和广度,所以需要你具有相当的技术基础来阅读,当然我会尽力使用最容易理解的方式去叙述,这也是咱的一贯作风,绝不能丢。一篇博文再牛逼,别人都看不懂,意义也不大,毕竟我写的不是科研文章。

本文会涉及到如下几个方面:

  • JVM与Android的关系
  • Java字节码基础
  • Android虚拟机,Dalvik与ART
  • Android构建系统
  • Android编译器
  • App运行原理,理解AOT、JIT、Dex 等相关术语

虚拟机技术

CPU 与JVM

本文读者应该都知道,我们开发的程序是运行在设备的CPU上的,然而我们世界是多样性的,CPU也不例外。现实中存在各种架构的CPU,例如ARM, Arm64, x86, x64, MIPS,架构不同那么CPU的指令及执行方式也就不同。而我们总是希望我们的程序可以运行在各种CPU上,你的App总不能因为小米和华为手机使用了不同架构的CUP就二选一吧?

那这个问题怎么解决呢?

最常用的方式就是针对不同的CPU架构,将程序编译为对应CPU的机器码文件。例如你有一款App要同时支持ARM和x86架构的手机,那么你就要为这两种手机各编译一个安装包,而且他们之间不能互换。

而等到类似于Java这种虚拟机语言出现后,人们就多了一个选择。在程序和硬件设备之间增加了一个虚拟层,让程序运行在虚拟层里,虚拟层运行在硬件上面,那么程序员再也不用关心各种各样的CPU架构了,那是虚拟层的事情,这个虚拟层俗称虚拟机

如下图所示,在虚拟机的帮助下就可以实现:一次编码,到处运行的效果,这也是Java当年提出时候的口号。

图片来源:Android CPU, Compilers, D8 & R8

如果想要详细了解虚拟机的知识,建议阅读 《深入理解Java虚拟机》这本国人写的神书,没想到国人也能写出如此棒的书。这里只要知道,java代码通过javac编译器编译成了ByteCode(字节码)文件,而字节码文件运行在虚拟机上就好了。

Interpreter & JIT

我都知道JVM可以执行字节码,那么其是如何执行的呢?

现代虚拟机一般有两种执行方式,根据具体的使用场景各有侧重。例如运行在client端与运行在server端的虚拟机侧重就不同。client端更注重响应性,例如一个程序半天启动不起来,那用户就会骂娘。而server端更注重执行效率,启动慢点没关系,反正也不是经常重启。

  • Interpreter :解释执行。一边把字节码翻译成当前硬件平台的机器码一边执行。优点是启动快,缺点就是执行效率太低下了。Interpreter 对应的编译器称为解释执行编译器
  • JIT(Just In Time) :即时编译。当一些代码被频繁执行到时,虚拟机就将其编译成机器码。这些被频繁执行的代码有个专有名词:“热点代码” ,相信大家最熟悉的JVM就是sun公司的的虚拟机HotSpot,其也是因为热点探测技术比较牛逼而得名。JIT 对应的编译器为即时编译器

值得说明的是此处只触及到了虚拟机的皮毛,如果有兴趣的同学可以查阅相关资料,虚拟机技术的水那可深啊!

下图描述了JVM执行字节码的两种策略


Java字节码(ByteCode)

Java字节码是Java虚拟机规范里的一套指令集,Java虚拟机可以执行由其按照.class文件结构构成的文件,字节码由操作符与参数组成。 Dalvik字节码是JVM字节码的子集,但是JVM执行的.class文件是基于栈的,而Dalvik/ART执行的.dex文件是基于寄存器的,所以不可以混用。

Android 虚拟机

上面叨叨了半天JVM就是因为它是Android虚拟机的基础,虽然Android基本是将Java那套东西照搬到了移动设备上,然而因为移动设备资源受限的特殊性,例如电池、内存、CUP的运算能力及功耗等都是受限的,造成了其与Java虚拟机还是有很大的不同的。

有人说Android割裂了Java生态系统,其实说的是有道理的,因为Java的标准字节码文件.class文件是不能直接在Android虚拟机上运行的。 虽然字节码指令是一样的,但是可执行的文件格式却不一样,JVM执行的是.class文件,而Android虚拟机执行的是.dex文件。

Android 虚拟机前后共有两套

- Dalvik:Android 在4.4 版本上同时提供了Dalvik与ART, 但是Dalvik是作为默认执行环境的,我们的源代码最终会编译为.dex文件,然后运行在Dalvik上,.dex 表示 Dalvik EXecutable文,Dalvik 的原理可以类比JVM。 Android5.0 以后就被完全废弃了。

- ART 其是Android Runtim 的缩写。 Android 从5.0以后就完全使用了新的虚拟机ART,其与Dalvik有很大的区别。其推出的目的主要是为了提高Android的执行效率,减少卡顿现象。那它是怎么做的呢?ART 采用了一种叫AOT (ahead of time) 来代替目前的在 runtime 时的 Interpreter 与 JIT。ART 不是等到App运行的时候才去运行dex文件,而是在App安装的时候就通过 AOT编译器.dex文件编译为对应的.oat二进制文件,当用户点击App的启动图标时,ART直接加载.oat文件去执行。其中那个.oat文件就是一个ELF文件,其是当前机器的可执行的文件了。

从前面的分析可以看出,ART 通过安装时将.dex预编译为机器的可执行文件,省去Dalvik在运行时才解释或者即时编译的过程而提高执行效率,详情待接下来在分析Android编译器时再说。

Android 编译流程

一个App从源代码到.apk安装文件都经历了哪些过程呢?

下图非常清楚的描述了这一过程:

1. 通过AAPT(Android Assets Packing Tool) 编译资源文件,将资源文件打包编译并生成生成R.java文件,就是放各种资源Id的那个文件。

2. 通过Java编译器javac.java 源代码文件编译为.class字节码文件

3. 通过Dalvik 编译器 将.class文件转化为.dex文件
4. 通过Apk builder 将打包后的资源与.dex文件一起生成APK文件。

Android 程序执行流程

Delvik 虚拟机启动App流程



因为只有Android4.4 以下的OS才使用Dalvik,而市场上此版本及以下的设备已经很少了,所以无需过于关注相关知识了,但是也应该有所了解,因为后面的ART也是为了解决Dalvik存在的问题而提出的。

如上图所示,在Dalvik虚拟机上首次启动App一定是使用Interpreter解释执行的,期间会探测热点代码,使用JIT编译执行,如果我没记错的话,JIT应该是在Android2.2之后加入的,可见最早期的Android很缓慢。

ART 安装及启动App流程

前面说过,ART是在App安装的时候将.apk文件减压,并将.dex文件预编译为.oat可执行文件,当App启动的时候就不需要在Runtime解释执行了,但是这种方式也有它自己的缺点。

  1. 增加了App的安装时间,这个很容易理解,因为多了一个预编译过程。
  2. 增加了App所需要的安装空间,与Dalvik相比,手机上多了一份.oat文件。特别是无论一个App的某一功能是否被使用到以及被使用的频率,例如一个App的某个功能,用户几乎不会去打开,ART都将其编译为.oat文件就显得有点低效了。

Google的那些天才工程师既然发现了问题,那肯定就会去想办法优化的。技术的每一次进步,都是站在前面技术的肩膀上的,这一次也不例外,Google的工程师将Interpreter,JITAOT 三种技术相结合来优化这个过程。

  1. 当你首次安装并一个App的时候,AOT不将.dex文件编译为.oat文件,这一步减少了安装时间。系统通过Interpreter的方式来启动App.
  2. 当在App运行过程中探测到了热点代码"hot code",就使用JIT编译
  3. 这些通过JT编译的平台代码及编译配置文件都会被存储在缓存中,加快下次访问的速度
  4. 当设备处于空闲时间时,AOT 编译器就会启动,结合编译配置文件将热点代码编译为.oat可执行文件
  5. 当再次运行App时,ART就可以直接运行.oat文件了



一款App经过8轮这样的处理基本上就优化好了。Google的工程师没有止步于此,他们仍然在思考,为什么不把编译配置文件共享呢?于是 按照这一思路Google通过Google Play 对这一过程做了更高级的优化,当某一款安装了此App的设备处于空闲及WIFI联网情况下时,将其产生的编译配置文件上传到Google Play上,那么其他用户的此款设备安装此App的时候,Google Play 就知道如何预编译这款App了,这样用户在首次安装的时候,AOT就会完成精准的预编译,那么App的启动及运行就会很流畅。但是很遗憾,这个优化距离我们很遥远。。。原因众所周知.

Android编译器

根据前面的介绍,Android 虚拟机是不能直接执行.class文件的,而其只能执行.dex文件,所以就必须有一个将Java字节码.class文件转化为Dalvik字节码.dex文件的的编译器。

Dex compiler

众所周知,Android最为人诟病的就是其碎片化,其中系统版本碎片化也很严重,往往是市场上各种版本的Android系统长期存在,版本收缩速度堪称龟速,甚至有的设备至出厂后就不能够升级,经两年好像稍有改善。所以一款App往往要同时支持很多版本,那就要求Dex编译器编译出来的.dex字节码可以同时运行在多个版本的Dalvik/ART上。

Android第一版发布时候使用的是JDK6,即只支持Java6的字节码指令集。但是到现在Java已经发展到Java13了,期间加了新的字节码,增加了好多新的语言特性,以及新的API,Android生态系统总不能一直让开发者使用Java6来开发吧,那样估计开发者要起来反抗了?所以Google的想办法支持Java7、8、9...

脱糖(Desugaring )

什么是脱糖呢?这个词我猜应该是来至语法糖。由于我们的执行环境Dalvik/ART不支持新的语言特性而我们又想要使用,源代码使用了语法糖,那么编译的时候就需要脱糖,例如Lambdas表达式。

Google 在实现脱糖这个功能时也经历了各种尝试,如果有兴趣可以查看 Jake Wharton的这篇博客 Android's Java 8 Support


上图展示了使用旧的dex编译器的编译过程,我们可以发现使用Kotlin是不需要脱糖这一步的。

脱糖包括两个方面

- Java 新语言特性的支持

就是新版本Java 引入的语言特性。 例如 `lambda`表达式、接口的默认方法以及方法引用等,这些是被最先支持的。支持的方式是通过将这些语言特性还原为对应的老式写法。

这个在插件 **Android gradle plugin 3.0.0** ,对应为Android studio 3.0 以上就支持了。

```

android {

...

compileOptions {

sourceCompatibility JavaVersion.VERSION_1_8

targetCompatibility JavaVersion.VERSION_1_8

}

}

```

- Java 新语言 Api的支持

对新版本API的支持。例如Java 8 新引入的`java.util.stream` 已经新的时间api `java.time`。这个不好弄了,因为老版本Android携带的jre根本没有这套东西,怎么办呢?

编译器(D8/R8)帮你实现一套,然后打包成`.dex`文件加到你的apk中,然后让你的代码使用这里面的实现。

这个在插件 Android gradle plugin 4.0.0,对应为Android studio 4.0以上才支持。在你的modul中gradle 配置如下代码即可

```

android {

defaultConfig {

// Required when setting minSdkVersion to 20 or lower

multiDexEnabled true

}

compileOptions {

// Flag to enable support for the new language APIs

coreLibraryDesugaringEnabled true

// Sets Java compatibility to Java 8

sourceCompatibility JavaVersion.VERSION_1_8

targetCompatibility JavaVersion.VERSION_1_8

}

}

dependencies {

coreLibraryDesugaring 'com.android.tools:desugar_jdk_libs:1.0.5'

}

```

随着Android的发展Dex编译器当然也会发展,下面介绍一下最新的两个编译器。

D8

D8全称是Dope8,咱也不知道是应该翻译为笨蛋8号呢,还是酷毙了8号,最好还是别翻译了,D8挺好听。其是Android最新的Dex编译器,替换了老的Dex编译器。

以前脱糖这一个过程是作为编译的一个单独步骤进行的,编译为.class后就是脱糖,脱糖后执行ProGuard,然后再编译为.dex文件。D8将脱糖和编译为.dex文件这两步合并为一步来执行了。D8脱糖后的字节码精确度和执行效率都更高



R8

R8是基于D8的,可以认为R8是D8的一种高级执行模式,其与D8最大的区别是对D8产生Dalvik字节码的过程进行了优化。D8将Java字节码转换为Dalvik字节码过程是:先将Java字节码转换为 intermediate representation(IR),然后再将IR输出为Dalvik 字节码,从IR到Dalvik字节码的过程中基本不做优化,而R8在此过程中会进行优化。

Java生态一般使用ProGuard对字节码进行优化,而R8将ProGuard这一步的功能给整合到了Dalvik字节码生成环节中了,不管是ProGuard还是R8主要从下面几个方面进行优化:

  • 收缩 :去掉没有使用的类,方法,字段等等
  • 代码优化:从指令层面上优化,例如指令重排等
  • 混淆 :将类名称,方法名称等混淆为无意义的名称。

在我最开始做Android开发的时候,我一直以为ProGuard的唯一作用就是为了混淆代码,足见一个人刚入门一个行当的时候是多么的无知。


R8 除了完成类似ProGuard的功能外还有一个针对Android生态系统的改进,即根据设备虚拟机和API版本来产生相应的Dalvik字节码。 什么意思呢?我们接下来简单的聊一下:

这个问题主要还是由于Android系统的碎片化造成的。最初Android定义了一套Dalvik 字节码指令,并提供了一个dex 编译器,而这个编译器一直没有使用其中的某些Dalvik指令,例如not-int,其他的手机开发商一看官方的dex编译器都不使用这些指令,那么就懒得支持了。但是当Google 提供新的dex编译器D8的时候,又用到了那些指令。这就尴尬了,使用D8编译的App在老的设备上就崩溃了,因为那个设备的虚拟机压根就不认识D8产生的某些字节码指令。所以D8就需要根据虚拟机版本及API版本来确定产生的相应的字节码,说来说去就是要向下兼容。如果市场上永远只有一个Android版本,根本就不会有这些屁事,但是我们我们是成年人,我们的承认现实!

ProGuard 与 R8的优化功能谁更厉害呢?毫无疑问是ProGuard,这是广大开发者费了15年的时间不断优化的结果,而R8只有Google在搞,而且时间很短,而且只在Android生态系统中使用。 如果局限于Android生态系统的话,R8随着不断的发展应该会比ProGuard 更适合。

总结

本文图片均出自 Android CPU, Compilers, D8 & R8

本文只从宏观的角度阐述了一下Android编译相关的问题,如果读者对某个部分感兴趣,应该从字节码层面进行具体的研究。

如果你都看到这里了,我相信本文值得你一赞,给美文点赞是咱程序员的美德。


参考文章: Android CPU, Compilers, D8 & R8 Android's Java 8 Support ProGuard vs R8

编辑于 06-10