最近阅读了一下Android
编译和运行相关的文章,大概了解了整体运行过程,在这里总结一下。
整篇文章相对而言不涉及到过多的技术细节,没有什么基础的人也能够通过这篇文章了解到Android
的相关知识。
这篇文章内容较多,主要内容有:
JVM
和Android
的关系- 字节码
Android
构建系统AOT
和JIT
的含义,以及它们和R8
的关系Android
运行系统
在此之前,先要说一下一些基础知识。
一、CPU & JVM
每台手机设备都具有CPU
,CPU
性能强弱是手机流畅性影响因素之一,这里不展开讨论这个问题。目前手机端有名的CPU
应当是高通的骁龙系列。当然也有其他架构的CPU
,如ARM
、ARM64
、x86
、x86_64
、MIPS
。针对不同架构的CPU
进行开发时,需要对不同的CPU
架构生成不同的.so
文件,这是一件很麻烦的事情。

这个时候JVM
(Java虚拟机)就展示出了它的优势。JVM(Java Virtual Machine)
会在硬件层面上增加一层抽象层。也就是说,你开发的app
只需要支持Java API
构成的”CPU”即可,再也不用被繁多的CPU
架构困扰,也不用做额外的适配工作。

Java
代码只需要使用javac
编译器编译成字节码文件(.class
文件),然后代码就可以跑在JVM
上,和操作系统隔离。一次编译,到处运行。这样,开发者无需考虑系统类型、设备类型、内存以及CPU
,只要把精力集中在业务逻辑就可以了。
1.1 JVM内部结构

下面来说说JVM
,JVM
主要由三部分构成:
ClassLoader
负责加载编译后的Java
文件(.class
),验证链接关系,检测字节码是否正确,为静态变量和代码分配内存以及初始化。- 运行时数据
负责所有的程序数据:stack
、方法、变量还有Heap
- 执行引擎
执行编译完成并加载的代码和GC
了解完以上知识之后,就可以开始继续了解解释器(Interpreter
)和JIT
编译器了。
1.2 Interpreter & JIT
这两兄弟是一起工作的。每次跑程序时,Interpreter
选取应当执行的.class
字节码,然后将其实时翻译成机器码执行。这么做有一个缺点,如果一个方法或者逻辑被多次调用,那么每次调用都要重新翻译一次,效率特别低,实际上翻译出来的机器码可以重复使用。
在这种情况下,JIT(Just In Time)
就派上用场了。执行引擎在Interpreter
的帮助下将字节码翻译成机器码,如果发现有重复执行的代码,JIT
就开始工作,将这部分频繁调用的代码翻译成机器码,当程序再次调用这个部分的逻辑时,执行引擎会直接使用JIT
翻译完成的机器码,这样就能够提升系统的性能表现,这部分代码也叫Hot Code
。

1.3 Dalvik
那么上面说的这些和Android
有什么关系呢?

JVM
设计运行的硬件环境和Android
设备不一样,JVM
最初是为电视机顶盒设计的,拥有“无限”电量,而一般来说手机设备有4000mah
已经是很大的电池了,另外Android
设备存储空间比较小(现在其实挺大的)。因此,Google
修改了JVM
的整体结构,包括Java
代码编译过程和字节码结构,以适应手机设备。编译过程的差异将在下一节讲述,字节码结构则下面用代码实例说明一下。
1 | public int method(int i1, int i2) { |
上面这段代码生成的.class
字节码如下:

使用Android
的Dex
编译器编译代码生成的.dex
字节码如下:

两张图对比可以明显看出来,.dex
字节码会比.class
字节码更简洁。
另外,Java
字节码是基于栈结构(所有的变量都存储在栈上)的,而Dex
字节码基于寄存器结构(所有变量存储在寄存器中)。
基于栈结构的字节码,在执行操作时需要先将操作数加载到操作栈上,然后计算,最后再将得到的结果从栈上弹出到局部变量表中。

而基于寄存器的字节码可以直接读取寄存器中存储的数据,然后一步到位将结果存入另外的寄存器。Dex
这种字节码相对Java
字节码而言更高效,需要的空间也更少。
在Android 5.0
以前,Android系统中解析Dex
字节码的虚拟机叫做Dalvik
。
Dalvik
加载和解析执行Dex
字节码的方式和JVM
使用JIT
以及Interpreter
一样。
二、Android 构建过程

.java
和.kt
文件由Java/Kotlin
编译器编译成.class
文件。这些.class
文件则通过Dex
编译器编译成.dex
文件,最终和资源文件等打包成.apk
文件。
可以看到,Android
构建其实和Java
语言有很大关系。Java
语言基本每年更新一个新版本,目前最新的是Java 12
。而相对于Java
的迅速迭代而言,Android
虚拟机则慢了很多,很多语言新特性无法直接在虚拟机上使用。因此,Android
构建过程中,除了要将class
文件转化成dex
文件之外,还有一个重要的任务是将新特性转化成老版本Java
能够运行的特性,也就是所谓的“脱糖”。

2.1 Jack & Jill
为了解决这个问题,Google
在16年推出了Jack & Jill
这套编译机制。具体流程如下:

Google
想使用Jack
工具直接将Java
代码编译成dex
文件,至于应用所使用过的第三方库,则使用Jill
工具编译成中间文件然后再编译成dex
文件。
这样看起来简单明了,没有那么多中间过程。但实际效果没有预期那么好。Google
需要重新实现Java生态所有特性支持,如注解,工作量很大;并且有些第三方库依赖于Java字节码神效;另外,经过实际应用,发现这个工具速度慢,耗内存,且不支持instant run
。Google
在17年废弃了这个编译工具链。
2.2 D8
为了改善构建环境,Google
在Android Studio 3.2
中使用D8(Dope 8)
取代了原来的Dex
编译器。这个替换操作主要的目的是将脱糖操作和class2dex
操作合并,减少构建时间。

那么D8
有多快呢?这得分工程规模来看。小APP
的话,100
次构建取平均值之后,优化的时间为2s
。

官方描述D8
的优点有:
- 编译更快、时间更短;
- DEX 编译时占用内容更小;
- .dex 文件大小更小;
- D8 编译的 .dex 文件拥有相同或者是更好的运行时性能;
这还没完,Google
还有一个大招。
2.3 R8
R8
是D8
的衍生产品,他们使用相同的代码库,但是R8
还解决了其他问题。跟D8
一样,R8
也能将Java
新feature
提供给开发者使用,但不仅限于此。
R8
带来的一个最大改进是优化.dex
中的代码,只保留了支持应用所需要的API
,删除了没有用上的类和方法。
另外,R8
能够替代Proguard
。Proguard
是一个在构建过程中使用到的混淆工具,在.class
转换.dex
过程中,R8
会替代Proguard
的工作,比如优化、混淆代码,移除无用的类。
用Java 8的lambda表达式做测试:
1 | class MathLambda { |
使用R8
能够减少生成的字节码行数。

R8
在Android Studio 3.4
被引入并默认开启,官方宣称能够优化逻辑代码,混淆代码和清除无用的类和方法。
同一个APP,分别使用Dex+Proguard和R8构建100次,取平均值,结果如下:

如上图,在构建过程中,使用R8
减少了13s
构建时间,同时减少了112
个方法,生成的apk
也小了348kb
。整体而言,结果很不错。
举一个R8
优化字符串操作的例子,如下代码:

直接编译成字节码如下:

从字节码可以看到,每次都先计算字符串WILDCARD
的长度,然后取子字符串。其实字符串常量WILDCARD
长度为固定的,所以没有必要一次次重新计算,编译期计算出来就可以。优化之后的字节码如下:

可以看到0008
行直接将一个固定值赋给了变量v0
,然后在获取子字符串进行之后的操作。
三、Android运行过程
3.1 Dalvik虚拟机
从应用商店下载一个apk
文件,安装后,点击图标icon
,然后就可以看到APP
运行在了手机上。那么中间发生了哪些事情?
APK
安装文件一般包含了所有的资源文件(图片,图标和布局等)和代码文件,安装时系统会把这些资源存储到内存中。当用户点击应用图标时,手机会启动一个Dalvik
进程,然后将该app
的dex
文件加载到内存,同时Dalvik
虚拟机将会把dex
字节码通过Interpreter
或JIT
翻译成机器码,最终这款app
运行在了你手机上。

当应用要使用到某部分代码(字节码)时,Dalvik
会通过Interpreter
将字节码实时翻译成机器码运行,经过一段时间后,如果有部分代码被经常调用(比如刷抖音时调用的请求视频数据逻辑),那么这部分代码会被定义成hotcode
,随后被JIT
翻译成机器码存储在内存中。当这部分代码再次被调用时,就不需要实时翻译,直接使用翻译好的机器码即可,这样就提高了运行效率。
这个过程和餐厅接待客人的一些现象很相似。Interpreter
就是客人来了之后现场点现场做,JIT
则是根据之前的经验,将这个客人常点的菜提前先做好,这样就能提高上菜速度。
JIT
机制是Android 2.2
引入的,之前全靠Interpreter
实时翻译,可想而知之前卡顿有多严重。
3.2 ART
Dalvik
对于手机设备来说是一个很好的解决方案,但是它。因此Google
对JVM
进行了改进,推出了一种新的JVM
,称为ART
虚拟机。Dalvik
和ART
最大的区别在于ART
不会在运行时对字节码进行翻译执行,而是把这个过程提前到了安装过程中。ART
使用AOT(Ahead of Time)
编译器来将字节码翻译成.oat
文件。

APP
被安装时,ART
将Dex
字节码预编译成.oat
文件,每次启动APP
时系统直接读取.oat
文件运行即可,不再使用JIT
以及Interpreter
这种即时翻译的工具,大大提升了运行流畅度。
这样看起来好像AOT
好像没什么毛病,Google
当初也觉得自己找到了终极解决方式,但实际上AOT
还是会有一些问题:
AOT
在安装过程中进行预编译行为,这样安装和更新APP
时间相比原来就会大大增加。另外,升级Android
系统时,系统会把所有程序重新安装一次,想想那么多APP
要重新安装编译成.oat
文件,头都大了。- 空间大小。
AOT
将整个.dex
文件都翻译为.oat
文件,包括那种很少使用或者根本不会被使用到的代码(比如第一次打开APP
的设置向导或者开屏界面)。根据Google
相关的数据,常用的代码大概占所有代码的15~20%
左右,这样就浪费了很大一部分空间,在一些小存储容量的低端机上这个问题尤其明显。
既然这也不好,那也不好,那就集中两种方式的优点就好了。Google
工程师想出了一个新点子:Interpreter + JIT + AOT
混合编译,具体方案如下:
- 安装的时候不进行预编译,也即不生成
.oat
文件。app
第一次启动时,ART
使用Interpreter
来实时翻译.dex
文件。 - 当出现
Hot Code
时,使用JIT
进行翻译,并将翻译后的机器码存入缓存(内存)中,之后调用Hot Code
时直接从缓存中取。 - 当设备空闲时,比如锁屏,
Hot Code
会被AOT
编译器编译成oat
文件存入本地存储空间。 app
再次启动时,如果存在.oat
文件,那么直接使用.oat
文件,否则从步骤1开始。

国内的厂商会有“基于用户操作习惯进行学习,APP打开速度不断提高
”的说法,有一部分是这个混合编译方案的功劳。
根据官方数据,平均来看,app运行8次之后,这个机制能够优化80%的空间。
这个混合机制为Android N(5.0)
引入的,也正是这个时候开始用户对Android
的运行效率看法有了改观。
3.3 PGO
经过上面这些动作,Android
的运行速度其实已经有很大的改观。但还是有可以改进的地方。
在说AOT
混合编译的时候系统会生成一个profile
,这个profile
记录了hotcode
的信息,哪些类和哪些方法会被经常调用。而对于大多数人来说,同一个APP
的hotcode
区别不大,其实可以共用,因此Google
在2018 Google I/O
大会上提出了Cloud Profiles
的方案。具体原理如下:

这个方案依赖Google Play
来完成。当一个设备为空闲状态并且连接到WiFi
时,Google Play Service
会将编译后的文件共享,之后如果有一样的手机从Googole Play
中下载这个APP
时,终端会收到其他人的hotcode
信息,这样用户在第一次使用时就能获得良好的体验。
但实际上,一个人的hotcode
无法代表所有人的hotcode
信息,那么需要多少个样本才能拿到一个比较稳定的hotcode profile
呢?根据官方的数据,这个数字还挺小的。

平均而言,30
个profile
已经能达到一个比较稳定的水平,而且效果也不差。

然而这些方案对于国内来说都没什么用…因为没法使用Google Play
。但Android 9
开始,Google
提供了一个内置hotcode
的方案,也就是说,可以在构建期间往APP
中放置hotcode
信息,这样系统在安装APP
时直接将这部分代码编译成机器码,速度会有很大的提升。
不过这部分内容在国内资料很少,Google
搜索了之后也才发现官方给了这一篇文章,Build With PGO,有兴趣的可以自己继续了解一下。
3.4 鸿蒙系统 & 方舟编译器
说到了编译器和Android
的运行机制,不得不说一下最近大热的华为鸿蒙系统和方舟编译器。这里先放一张官网给的架构图:

在网上搜集了一些信息之后,不得不说华为的愿景很大。想把Java/Kotlin、C++
直接编译成能够运行的机器码,据了解的信息,普通的APP
经过华为的编译器之后,包体积会增大一些。另外,对于语言的动态特性,比如Java
的多态,直接在编译期处理成静态特性。这些都是华为官方宣称的优点,不知道华为具体是怎么做的,拭目以待吧。
参考文档
- https://proandroiddev.com/android-cpu-compilers-d8-r8-a3aa2bfbc109
- https://source.android.google.cn/devices/tech/perf/pgo#case-study-pgo-for-art
- https://juejin.im/post/5ca480b66fb9a05e1d26bb7a
- https://www.infoq.com/news/2019/04/play-cloud-art-profiling-android/
- https://juejin.im/post/5d4bdb23e51d453c2577b747
- https://zhuanlan.zhihu.com/p/62794593
- https://juejin.im/post/5cbe60796fb9a0324d43ab97#heading-5
- https://blog.csdn.net/Mr_dsw/article/details/90141647
- https://zhuanlan.zhihu.com/p/65307730