译文转自:https://juejin.im/post/5d70fb2ce51d4557ca7fddaa
很好的一篇文章,强烈推荐看下
此为译文,原文:Android CPU, Compilers, D8 & R8 – ProAndroidDev
设想你被分配了一项重要的太空探索任务。你需要建造一艘非常可靠的飞船。你可能会选择普通的 YT-1300 运输机,它非常常见,你也基本知道如何操作它。然而你总是梦想着开一个更牛逼的家伙,你自己已经偷偷训练了很久,事实上千年隼号才是你真正的目标,但这个升级版的飞船要求你像 Han Solo 那样技术娴熟!
最近,Google 对编译器的改进让我很感兴趣,例如 R8 以及 Gradle 构建过程。我想是时候深入了解然后跟你们分享一下这些改进。但是首先,让我们从基础聊起。
当你读完这篇文章:
所以,沏上一杯咖啡,拿上光剑和小饼干,让我们开始吧。
每一个手机上,都有一个 CPU,它非常小,却是所有运算发生的地方。
在最开始的时候,CPU 只能处理简单的数学运算,比如加减乘除。经过这么多年的发展,CPU 已经进化到可以处理非常复杂的运算,比如图片处理、音频解码等等。目前最知名的移动处理器是高通生产的骁龙系列。
但高通并不是唯一的 CPU 制造商。其他制造商生产的 CPU 有些架构与高通相同,有些却不一样。这里我要说:“欢迎来到地狱!”。如果你曾经开发过 C++/C,你就会知道 native code 需要为所有支持的架构编译一份,比如 ARM、ARM64、X86、X64、MIPS 等等。
作为一个 Android 开发者,通常你的应用需要支持多种多样的设备,而这些设备背后的 CPU 架构不尽相同。这基本上意味着你需要为每一种架构编译一个 so 文件。说实话,这可一点都不好玩儿。不过别担心,我不会一直这么抱怨 C++开发有多么不好玩儿的。
JVM 完美解决了这个问题。JVM 在硬件上面添加了一层抽象。通过这种方式,你的应用程序就可以通过 Java 的接口来使用 CPU ,而你也不用去为了不同的 CPU 架构做适配,也不用为了 Mac 上与众不同的蓝牙驱动而烦恼。
javac 编译器将你的 Java 代码编译为字节码(.class 文件),然后你的代码就可以直接在 Java 虚拟机上运行,而不用关心底层操作系统的差异。作为一个应用开发者,你不用去关心设备硬件、操作系统、内存、CPU 的差异,只需要关注业务逻辑,想法设法让你的用户开心就好了。
JVM 有三个主要的区域。
到这里为止,你已经建造了一个基本的载人飞船,耶!但它估计飞的不会太远。让我们继续深挖,看看还有那些可以升级的选项。我想你应该准备好学习 Execution Engine 中的解释器和 JIT 编译器了。
这两个家伙在一起工作,每当我们运行我们的程序,解释器都需要将字节码解释为机器码再运行。这么做最主要的一个缺点就是当一个方法需要多次执行的时候,每次执行都需要进行解释。想象一下,每当一个帝国士兵被克隆出来的时候,你都需要教他如何格斗、如何握枪、如何征服一个星球,这得多痛苦啊。
JIT 编译器就是用来解决这个问题的。执行引擎还是使用解释器解析代码,但不同的是,当它发现有重复执行的代码时,它会切换为 JIT 编译器,JIT 编译器会将这些重复的代码编译为本地机器代码,而当同样的方法再次被调用时,已经被编译好的本地机器代码就会被直接运行,从而提升系统的性能。这些重复执行的代码也被称为“热代码(Hot code)”。
这一切又是怎么跟 Android 关联起来的呢?
Java 虚拟机的设计一直以来都是面向有无限电量和几乎无限存储的设备。
而 Android 设备则很不相同。首先电池容量有限,所有的程序都需要为了有限的资源竞争。其次内存的大小有限,存储空间也很有限(跟其他的 JVM 运行设备相比,简直是小的可怜)。因此,当 Google 决定在移动设备上使用 JVM 的时候,他们做了很多的改动 - 包括 java 代码编译为字节码的过程以及字节码的结构等等。下面我们用代码来说明这些变化。
public int method(int i1, int i2) {
int i3 = i1 * i2;
return i3 * 2;
}
复制代码
当这段 Java 代码使用普通的 javac 编译器编译为字节码后,看起来大概是这样的:
但是当我们使用 Android 的编译器(Dex Compiler)进行编译时,字节码看起来是这样的:
之所以有这样的区别是因为普通的 Java 字节码是以栈为基础的(所有的变量都存储在栈中),而 dex 格式的字节码是是寄存器为基础的(所有的变量都存储在寄存器中)。后者更加高效并且需要更少的空间。运行 Dex 字节码的 Android 虚拟机被称为 Dalvik.
Davik 虚拟机只能加载和运行使用 Dex 编译器生成的字节码,与普通的 JVM 类似,也使用了解释器和 JIT 编译器。
你有没有意识到你的飞船已经可以在真空中飞行了呢?它获得了极大的提升,所以你需要提高自己的技能才能掌控它。确保你带够了小饼干,你的大脑可能也需要一些糖分的补充。
字节码其实就是 Java 代码翻译为 JVM 能够理解的代码。阅读字节码其实非常简单,来看看这个:
每一条指令都由操作码和寄存器(或者常量)组成。这里有一个安卓支持的操作码的完整列表。
与 Java 类型相同的类型:
类类型会使用完整路径: Ljava/lang/Object;
数组类型使用 [ 前缀,后面跟着具体的类型: [I, [Ljava/lang/Object;, [[I
当一个方法有多个参数时,这些参数类型可以直接拼接在一起,我们来练习一下:
obtainStyledAttributes(Landroid/util/AttributeSet;[III)
obtainStyledAttributes 显然就是方法名了,Landroid/util/AttributeSet; 第一个参数就是 AttributeSet 类了,[I 第二个参数就是一个 Integer 类型的数组了,后面又连续跟着两个 I 说明还有两个 Integer 类型的参数。
据此可以推断出对应的 Java 方法声明为:
obtainStyledAttributes(AttributeSet set, int[] attrs, int defStyleAttr, int defStyleRes)
Yaay! 现在你已经掌握了基本的概念,让我们继续练习:
.method swap([II)V ;swap(int[] array, int i)
.registers 6
aget v0, p1, p2 ; v0=p1[p2]
add-int/lit8 v1, p2, 0x1 ; v1=p2+1
aget v2, p1, v1 ; v2=p1[v1]
aput v2, p1, p2 ; p1[p2]=v2
aput v0, p1, v1 ; p1[v1]=v0
return-void
.end method
复制代码
对应的 Java 代码如下:
void swap(int array[], int i) {
int temp = array[i];
array[i] = array[i+1];
array[i+1] = temp;
}
复制代码
等一下,第六个寄存器在哪儿?!眼神不错,当一个方法是一个对象实例的一部分的时候,它有一个默认的参数 this,总是存储在寄存器 p0 中。而如果一个方法是静态方法的话,p0 参数有别的意义(不指代 this)。
让我们来看另外的一个例子。
const/16 v0, 0x8 ;int[] size 8
new-array v0, v0, [I ;v0 = new int[]
fill-array-data v0, :array_12 ;fill data
nop
:array_12
.array-data 4
0x4
0x7
0x1
0x8
0xa
0x2
0x1
0x5
.end array-data
复制代码
对应的 java 代码是
int array[] = {
4, 7, 1, 8, 10, 2, 1, 5
};
复制代码
最后一个。
new-instance p1, Lcom/android/academy/DexExample;
;p1 = new DexExample();
invoke-direct {p1}, Lcom/android/academy/DexExample;->()V
;calling to constructor: public DexExample(){ ... }
const/4 v1, 0x5 ;v1=5
invoke-virtual {p1, v0, v1}, Lcom/android/academy/DexExample;->swap([II)V . ;p1.swap(v0,v1)
复制代码
对应的 Java 代码是
DexExample dex = new DexExample();
dex.swap(array,5);
复制代码
现在你有了阅读字节码的超能力,恭喜恭喜!
在我们开始进入 D8 和 R8 之前,我们还需要回到 Android JVM 也就是 Dalvik 的主题上,就像是想要充理解第一部星战,我们需要先观看第四部一样,你懂的。
.java 和 .kt 代码文件被 Java 编译器和 Kotlin 编译器协作编译为 .class 文件,这些文件又被编译为 .dex 文件,最终被打包进 .apk 文件。
当你从 play store 下载一个应用的时候,你下载的就是包含了所有 .dex 以及资源的 apk 安装包,并被安装到设备上。当你从 launcher 上点击一个应用图标的时候,系统就会启动一个新的 Dalvik 进程,并将应用包含的 dex 代码加载进来,这些代码进一步在运行时被 Interpreter 解释器解释或者被 JIT 编译器编译。然后你就看到了应用的页面了。
现在你已经有一个像样的货船了!可以起航了!哦不对!你是一个有追求的专业飞行员,你想要的是一个更高级的宇宙飞船,那我们就继续来升级吧!
Dalvik 曾经是一个很不错的解决方案,然而它也有不少局限性。所以呢,google 后来又推出了一个优化后的 Java 虚拟机,叫 ART。ART 与 Dalvik 的主要区别是,它不是在运行时进行解释和 JIT 编译,而是直接运行的提前编译好的 .oat 文件,因此获得了更好更快的运行速度。为了提前编译好 .oat 二进制文件,ART 使用了 AOT 编译器(AOT 是 Ahead of Time 的缩写)
那么,到底什么是 .oat 二进制文件呢?
当你从应用商店下载并安装一个应用的时候,除了解压缩 .apk 文件,系统也会对 .dex 文件进行编译,生成 .oat 文件。
所以当你点击应用图标的时候,ART 直接加载 .oat 文件并运行,而不需要任何的解释和 JIT 步骤。
听起来很不错,但看起来我们的宇宙飞船升级的并不怎么顺利啊。
但是在银河系中,总是会有绝地武士前来拯救世界。Google 的工程师想出了一个绝妙的点子来解决问题,充分利用了 Interpreter/JIT/AOT 的优点。
平均来说,优化 80% 的应用代码需要运行 8 次应用。
然后,接着是更进一步的优化 - 为什么不在相同的设备之间共享编译选项呢?事实上,Google 就是这么做的。
当一个设备空闲并且连接到一个 WIFI 网络的时候,它会将自身的编译 profile 通过 paly service 共享给 google,当有别的用户使用同样配置的的设备从 play store 下载同一个应用的时候,也会同时下载编译 profile 用来指导 AOT 将经常运行的代码编译为 .oat 存储。这样一来,用户第一次运行的时候就已经是优化好的应用啦。
Google 的老伙计们付出了巨大的努力来改进编译速度,实际上我们这些努力确实也收到了不错的效果,然而,然而,然而,Dalvik/ART 支持的 opcodes 是非常受限的,在了解了前面的内容之后,你应该明白了为什么。
Java 7-8-9 等等新引入的语言特性并不能直接就能用在 Android 开发中,基本上现在的所有的 Android 开发者还在被困在 Java 6 SE 上。
为了让我们能使用上 Java 8 的特性,Google 使用了 Transformation 来增加了一步编译过程叫 desugaring,其实就是将我们代码里使用的 java 8 新特性翻译为 Dalvik/ART 能够识别的 java 6 字节码。这不可避免会导致一个问题 - 更长的编译时间。
为了解决这个问题,在 Android Studio 3.2 中,Google 使用 D8 替换了旧的 dx 编译器。D8 的主要改进是消除 desuguaring 的过程,让其成为 dex 编译的一部分,从而加快编译速度。
能快多少呢?根据项目的不同表现也不一样。在我们的小项目中,编译 100 次取平均大概会比不用 d8 快 2s.
这里还有一个关于 D8 名字由来的趣事儿,对呀,为什么叫 D8 呢?【D 和 8 分别代表什么呢?能让人产生联想的可能是 Google V8 js 引擎,但并没有关系】
到这里还不是全部。
R8 是 D8 的意外收获,他们的 codebase 是一样的,但 R8 解决了更多的痛点。与 D8 一样,R8 允许我们开发使用 Java 8 的特性,并运行在老的 Davalik/ART 虚拟机中,但不仅仅如此。
作为 Android 开发的痛苦之一就是碎片化。Android 设备的种类及其庞大,上次我查看 Play Store 的时候,上面显示有超过两万种设备(说到这里羡慕的瞅一瞅只需要支持 2.5 台设备的 iOS 开发同学们)甚至有些厂商会修改 JIT 编译器的工作机制。这就导致了有一部分设备行为变得很奇怪。
R8 的对 .dex 最大的优化就是它只保留了我们指定支持的设备所能理解的 opcodes,就像下图所示的那样。【这里说的是在某些设备上某些指令会导致崩溃,看起来是会做一些处理】
Proguard 也是编译过程中一个 transformation 的步骤,当然也会影响编译时间。为了解决这个问题,R8 在 dex 过程中也会做类似的事情(比如优化、混淆、清理无用的类),而避免了多一步 transformation.
需要注意的是,R8 并不能完全取代 Proguard,它目前还是一个实验性质的只支持一部分 Proguard 功能的工具。可以从这里了解更多。
开发者喜欢 Kotlin,这门神奇的语言让我们可以书写更优雅易读易维护的代码,然而,Kotlin 生成的字节码指令比对应的 Java 版本要多一些。
我们来用 Java 8 的 lambda 语句进行一下测试:
class MathLambda {
interface NumericTest {
boolean computeTest(int n);
}
void doSomething(NumericTest numericTest) {
numericTest.computeTest(10);
}
}
private void java8ShowCase() {
MathLambda math = new MathLambda();
math.doSomething((n) -> (n % 2) == 0);
}
复制代码
R8 生成的指令比 dx 生成的指令要少一些。
接下来是等价的 Kotlin 版本的代码:
fun mathLambda() {
*doSomething***{**n: Int **->**n % 2 == 0 **}**
}
fun doSomething(numericTest: (Int) -> Boolean) {
numericTest(10)
}
复制代码
很明显的,Kotlin 版本的指令比 Java 版本的多了不少,但 R8 也相比 dx 有更一步的优化。
对于我们的 app 来说,跑 100 次的结果如下:
时间少了 13s,少了 1122 方法,apk 的体积也减少了 348KB,爆炸!
需要提醒你的是 R8 依然是实验性的,Google 的工程师正在努力将它推向 Production Ready. 你想尽快开上千年隼吗,别坐着不动了,帮我们一起加把劲儿,试试 R8,并尝试提交一个 bug 吧!
作者:monkeyM
链接:https://juejin.im/post/5d70fb2ce51d4557ca7fddaa
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。