Android CPU, Compilers, D8 & R8

译文转自:https://juejin.im/post/5d70fb2ce51d4557ca7fddaa

很好的一篇文章,强烈推荐看下

Android CPU, Compilers, D8 & R8

此为译文,原文:Android CPU, Compilers, D8 & R8 – ProAndroidDev

设想你被分配了一项重要的太空探索任务。你需要建造一艘非常可靠的飞船。你可能会选择普通的 YT-1300 运输机,它非常常见,你也基本知道如何操作它。然而你总是梦想着开一个更牛逼的家伙,你自己已经偷偷训练了很久,事实上千年隼号才是你真正的目标,但这个升级版的飞船要求你像 Han Solo 那样技术娴熟!

最近,Google 对编译器的改进让我很感兴趣,例如 R8 以及 Gradle 构建过程。我想是时候深入了解然后跟你们分享一下这些改进。但是首先,让我们从基础聊起。

当你读完这篇文章:

  • 你会了解 JVM 以及它和 Android 的关系
  • 你将学会阅读字节码
  • 你将对 Android 的编译系统有个大概的了解
  • 你将知道 AOT 和 JIT 是什么,它们与 R8 是如何联系起来的
  • 额外的,你也会了解一些星球大战的东西

所以,沏上一杯咖啡,拿上光剑和小饼干,让我们开始吧。

CPU & JVM

每一个手机上,都有一个 CPU,它非常小,却是所有运算发生的地方。

 

Android CPU, Compilers, D8 & R8_第1张图片

 

 

在最开始的时候,CPU 只能处理简单的数学运算,比如加减乘除。经过这么多年的发展,CPU 已经进化到可以处理非常复杂的运算,比如图片处理、音频解码等等。目前最知名的移动处理器是高通生产的骁龙系列。

但高通并不是唯一的 CPU 制造商。其他制造商生产的 CPU 有些架构与高通相同,有些却不一样。这里我要说:“欢迎来到地狱!”。如果你曾经开发过 C++/C,你就会知道 native code 需要为所有支持的架构编译一份,比如 ARM、ARM64、X86、X64、MIPS 等等。

作为一个 Android 开发者,通常你的应用需要支持多种多样的设备,而这些设备背后的 CPU 架构不尽相同。这基本上意味着你需要为每一种架构编译一个 so 文件。说实话,这可一点都不好玩儿。不过别担心,我不会一直这么抱怨 C++开发有多么不好玩儿的。

 

Android CPU, Compilers, D8 & R8_第2张图片

 

 

JVM 完美解决了这个问题。JVM 在硬件上面添加了一层抽象。通过这种方式,你的应用程序就可以通过 Java 的接口来使用 CPU ,而你也不用去为了不同的 CPU 架构做适配,也不用为了 Mac 上与众不同的蓝牙驱动而烦恼。

 

Android CPU, Compilers, D8 & R8_第3张图片

 

 

javac 编译器将你的 Java 代码编译为字节码(.class 文件),然后你的代码就可以直接在 Java 虚拟机上运行,而不用关心底层操作系统的差异。作为一个应用开发者,你不用去关心设备硬件、操作系统、内存、CPU 的差异,只需要关注业务逻辑,想法设法让你的用户开心就好了。

JVM 内部

 

Android CPU, Compilers, D8 & R8_第4张图片

 

 

JVM 有三个主要的区域。

  1. ClassLoader - 主要职责是加载编译后的字节码(.class 文件),链接,检测损坏的字节码,定位并初始化静态变量和静态代码
  2. Runtime Data - 负责所有的程序数据:栈,方法变量,当然还有我们都非常熟悉的堆
  3. Execution Engine - 负责执行已经加载的代码并清理不在需要的垃圾(GC)

到这里为止,你已经建造了一个基本的载人飞船,耶!但它估计飞的不会太远。让我们继续深挖,看看还有那些可以升级的选项。我想你应该准备好学习 Execution Engine 中的解释器和 JIT 编译器了。

Interpreter & JIT

这两个家伙在一起工作,每当我们运行我们的程序,解释器都需要将字节码解释为机器码再运行。这么做最主要的一个缺点就是当一个方法需要多次执行的时候,每次执行都需要进行解释。想象一下,每当一个帝国士兵被克隆出来的时候,你都需要教他如何格斗、如何握枪、如何征服一个星球,这得多痛苦啊。

JIT 编译器就是用来解决这个问题的。执行引擎还是使用解释器解析代码,但不同的是,当它发现有重复执行的代码时,它会切换为 JIT 编译器,JIT 编译器会将这些重复的代码编译为本地机器代码,而当同样的方法再次被调用时,已经被编译好的本地机器代码就会被直接运行,从而提升系统的性能。这些重复执行的代码也被称为“热代码(Hot code)”。

 

Android CPU, Compilers, D8 & R8_第5张图片

 

 

这一切又是怎么跟 Android 关联起来的呢?

 

Android CPU, Compilers, D8 & R8_第6张图片

 

 

Java 虚拟机的设计一直以来都是面向有无限电量和几乎无限存储的设备。

而 Android 设备则很不相同。首先电池容量有限,所有的程序都需要为了有限的资源竞争。其次内存的大小有限,存储空间也很有限(跟其他的 JVM 运行设备相比,简直是小的可怜)。因此,当 Google 决定在移动设备上使用 JVM 的时候,他们做了很多的改动 - 包括 java 代码编译为字节码的过程以及字节码的结构等等。下面我们用代码来说明这些变化。

public int method(int i1, int i2) {
    int i3 = i1 * i2;
    return i3 * 2;
}
复制代码

当这段 Java 代码使用普通的 javac 编译器编译为字节码后,看起来大概是这样的:

 

Android CPU, Compilers, D8 & R8_第7张图片

 

 

但是当我们使用 Android 的编译器(Dex Compiler)进行编译时,字节码看起来是这样的:

 

Android CPU, Compilers, D8 & R8_第8张图片

 

 

之所以有这样的区别是因为普通的 Java 字节码是以栈为基础的(所有的变量都存储在栈中),而 dex 格式的字节码是是寄存器为基础的(所有的变量都存储在寄存器中)。后者更加高效并且需要更少的空间。运行 Dex 字节码的 Android 虚拟机被称为 Dalvik.

 

Android CPU, Compilers, D8 & R8_第9张图片

 

 

Davik 虚拟机只能加载和运行使用 Dex 编译器生成的字节码,与普通的 JVM 类似,也使用了解释器和 JIT 编译器。

你有没有意识到你的飞船已经可以在真空中飞行了呢?它获得了极大的提升,所以你需要提高自己的技能才能掌控它。确保你带够了小饼干,你的大脑可能也需要一些糖分的补充。

字节码?

 

Android CPU, Compilers, D8 & R8_第10张图片

 

 

字节码其实就是 Java 代码翻译为 JVM 能够理解的代码。阅读字节码其实非常简单,来看看这个:

 

Android CPU, Compilers, D8 & R8_第11张图片

 

 

每一条指令都由操作码和寄存器(或者常量)组成。这里有一个安卓支持的操作码的完整列表。

与 Java 类型相同的类型:

  • I - int
  • J - long
  • Z - boolean
  • D - double
  • F - float
  • S - short
  • C - char
  • V - void(用作返回值)

类类型会使用完整路径: 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! 现在你已经掌握了基本的概念,让我们继续练习:

 

Android CPU, Compilers, D8 & R8_第12张图片

 

 

.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 的主题上,就像是想要充理解第一部星战,我们需要先观看第四部一样,你懂的。

Android build process

 

Android CPU, Compilers, D8 & R8_第13张图片

 

 

.java 和 .kt 代码文件被 Java 编译器和 Kotlin 编译器协作编译为 .class 文件,这些文件又被编译为 .dex 文件,最终被打包进 .apk 文件。

当你从 play store 下载一个应用的时候,你下载的就是包含了所有 .dex 以及资源的 apk 安装包,并被安装到设备上。当你从 launcher 上点击一个应用图标的时候,系统就会启动一个新的 Dalvik 进程,并将应用包含的 dex 代码加载进来,这些代码进一步在运行时被 Interpreter 解释器解释或者被 JIT 编译器编译。然后你就看到了应用的页面了。

 

Android CPU, Compilers, D8 & R8_第14张图片

 

 

现在你已经有一个像样的货船了!可以起航了!哦不对!你是一个有追求的专业飞行员,你想要的是一个更高级的宇宙飞船,那我们就继续来升级吧!

ART

Dalvik 曾经是一个很不错的解决方案,然而它也有不少局限性。所以呢,google 后来又推出了一个优化后的 Java 虚拟机,叫 ART。ART 与 Dalvik 的主要区别是,它不是在运行时进行解释和 JIT 编译,而是直接运行的提前编译好的 .oat 文件,因此获得了更好更快的运行速度。为了提前编译好 .oat 二进制文件,ART 使用了 AOT 编译器(AOT 是 Ahead of Time 的缩写)

那么,到底什么是 .oat 二进制文件呢?

 

Android CPU, Compilers, D8 & R8_第15张图片

 

 

当你从应用商店下载并安装一个应用的时候,除了解压缩 .apk 文件,系统也会对 .dex 文件进行编译,生成 .oat 文件。

所以当你点击应用图标的时候,ART 直接加载 .oat 文件并运行,而不需要任何的解释和 JIT 步骤。

听起来很不错,但看起来我们的宇宙飞船升级的并不怎么顺利啊。

 

Android CPU, Compilers, D8 & R8_第16张图片

 

 

  • 如前所述,.dex 编译为 .oat 是安装应用过程中的一部分,这就导致了安装或者更新应用变得速度暴慢。另外每当安卓系统有升级,就会有一到两个小时的时间会用来 “Optimizing app”,是可忍孰不可忍,特别是对于当时的 Nexus 用户,每个月都有一次安全升级,真是太痛苦了。
  • 所有的 .dex 文件都被编译为 .oat 文件,即使有些应用代码几乎不怎么被用户使用,比如设置页面、反馈页面等等,所以可以说我们浪费了大量的磁盘空间,对于低端小容量的手机来说尤其是个问题。

 

Android CPU, Compilers, D8 & R8_第17张图片

 

 

但是在银河系中,总是会有绝地武士前来拯救世界。Google 的工程师想出了一个绝妙的点子来解决问题,充分利用了 Interpreter/JIT/AOT 的优点。

  1. 最开始安装的时候并没有 .oat 文件生成,当你第一次运行应用的时候,ART 会使用解释器来解释执行 .dex 代码
  2. 当 Hot Code 被发现的时候,ART 会调用 JIT 来对代码进行编译
  3. 使用 JIT 编译过的代码以及编译选项会存储在缓存中,以后每次执行同样的代码就会使用这里的缓存
  4. 当设备空闲的时候(屏幕熄灭并且在充电),所有的 Hot Code 会被 AOT 编译器使用缓存的编译选项编译为 .oat 文件
  5. 当你再次运行应用的时候,位于 .oat 文件的代码会被直接执行,从而获得更好的性能,而如果要执行的代码不在 .oat 文件中,则回到第一步

 

Android CPU, Compilers, D8 & R8_第18张图片

 

 

平均来说,优化 80% 的应用代码需要运行 8 次应用。

然后,接着是更进一步的优化 - 为什么不在相同的设备之间共享编译选项呢?事实上,Google 就是这么做的。

当一个设备空闲并且连接到一个 WIFI 网络的时候,它会将自身的编译 profile 通过 paly service 共享给 google,当有别的用户使用同样配置的的设备从 play store 下载同一个应用的时候,也会同时下载编译 profile 用来指导 AOT 将经常运行的代码编译为 .oat 存储。这样一来,用户第一次运行的时候就已经是优化好的应用啦。

 

Android CPU, Compilers, D8 & R8_第19张图片

 

 

那这一切跟 R8 有什么关系呢?

 

Android CPU, Compilers, D8 & R8_第20张图片

 

 

Google 的老伙计们付出了巨大的努力来改进编译速度,实际上我们这些努力确实也收到了不错的效果,然而,然而,然而,Dalvik/ART 支持的 opcodes 是非常受限的,在了解了前面的内容之后,你应该明白了为什么。

 

Android CPU, Compilers, D8 & R8_第21张图片

 

 

Java 7-8-9 等等新引入的语言特性并不能直接就能用在 Android 开发中,基本上现在的所有的 Android 开发者还在被困在 Java 6 SE 上。

为了让我们能使用上 Java 8 的特性,Google 使用了 Transformation 来增加了一步编译过程叫 desugaring,其实就是将我们代码里使用的 java 8 新特性翻译为 Dalvik/ART 能够识别的 java 6 字节码。这不可避免会导致一个问题 - 更长的编译时间。

 

Android CPU, Compilers, D8 & R8_第22张图片

 

 

Dope8

为了解决这个问题,在 Android Studio 3.2 中,Google 使用 D8 替换了旧的 dx 编译器。D8 的主要改进是消除 desuguaring 的过程,让其成为 dex 编译的一部分,从而加快编译速度。

 

Android CPU, Compilers, D8 & R8_第23张图片

 

 

能快多少呢?根据项目的不同表现也不一样。在我们的小项目中,编译 100 次取平均大概会比不用 d8 快 2s.

 

Android CPU, Compilers, D8 & R8_第24张图片

 

 

这里还有一个关于 D8 名字由来的趣事儿,对呀,为什么叫 D8 呢?【D 和 8 分别代表什么呢?能让人产生联想的可能是 Google V8 js 引擎,但并没有关系】

 

Android CPU, Compilers, D8 & R8_第25张图片

 

 

到这里还不是全部。

android.enableR8 = true (experimental AS 3.3)

R8 是 D8 的意外收获,他们的 codebase 是一样的,但 R8 解决了更多的痛点。与 D8 一样,R8 允许我们开发使用 Java 8 的特性,并运行在老的 Davalik/ART 虚拟机中,但不仅仅如此。

R8 帮助使用正确的 opcodes

作为 Android 开发的痛苦之一就是碎片化。Android 设备的种类及其庞大,上次我查看 Play Store 的时候,上面显示有超过两万种设备(说到这里羡慕的瞅一瞅只需要支持 2.5 台设备的 iOS 开发同学们)甚至有些厂商会修改 JIT 编译器的工作机制。这就导致了有一部分设备行为变得很奇怪。

 

Android CPU, Compilers, D8 & R8_第26张图片

 

 

R8 的对 .dex 最大的优化就是它只保留了我们指定支持的设备所能理解的 opcodes,就像下图所示的那样。【这里说的是在某些设备上某些指令会导致崩溃,看起来是会做一些处理】

 

Android CPU, Compilers, D8 & R8_第27张图片

 

 

R8 replace Proguard?

Proguard 也是编译过程中一个 transformation 的步骤,当然也会影响编译时间。为了解决这个问题,R8 在 dex 过程中也会做类似的事情(比如优化、混淆、清理无用的类),而避免了多一步 transformation.

 

Android CPU, Compilers, D8 & R8_第28张图片

 

 

需要注意的是,R8 并不能完全取代 Proguard,它目前还是一个实验性质的只支持一部分 Proguard 功能的工具。可以从这里了解更多。

R8 对 Kotlin 更友好

开发者喜欢 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 生成的指令要少一些。

 

Android CPU, Compilers, D8 & R8_第29张图片

 

 

接下来是等价的 Kotlin 版本的代码:

fun mathLambda() {
    *doSomething***{**n: Int **->**n % 2 == 0 **}**
}

fun doSomething(numericTest: (Int) -> Boolean) {
    numericTest(10)
}
复制代码

 

Android CPU, Compilers, D8 & R8_第30张图片

 

 

很明显的,Kotlin 版本的指令比 Java 版本的多了不少,但 R8 也相比 dx 有更一步的优化。

对于我们的 app 来说,跑 100 次的结果如下:

 

Android CPU, Compilers, D8 & R8_第31张图片

 

 

时间少了 13s,少了 1122 方法,apk 的体积也减少了 348KB,爆炸!

需要提醒你的是 R8 依然是实验性的,Google 的工程师正在努力将它推向 Production Ready. 你想尽快开上千年隼吗,别坐着不动了,帮我们一起加把劲儿,试试 R8,并尝试提交一个 bug 吧!


作者:monkeyM
链接:https://juejin.im/post/5d70fb2ce51d4557ca7fddaa
来源:掘金
著作权归作者所有。商业转载请联系作者获得授权,非商业转载请注明出处。

 

 

你可能感兴趣的:(android,gradle)