Android CPU, Compilers, D8 & R8

8,047

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

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

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

当你读完这篇文章:

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

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

CPU & JVM

每一个手机上,都有一个 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 内部

JVM 有三个主要的区域。

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

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

Interpreter & 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 类型相同的类型:

  • 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! 现在你已经掌握了基本的概念,让我们继续练习:

.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;-><init>()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

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

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

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

ART

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Dope8

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

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

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

到这里还不是全部。

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 编译器的工作机制。这就导致了有一部分设备行为变得很奇怪。

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

R8 replace Proguard?

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

需要注意的是,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 生成的指令要少一些。

接下来是等价的 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 吧!