本文原文出自 jakewharton 关于 D8 和 R8 系列文章第三篇。
在前两篇文章中介绍了 D8
使用脱糖来兼容 Java
语言新特性。脱糖是很有趣的功能,但它是 D8
的次要功能。D8
的主要职责是将基于堆栈的 Java
字节码转换为基于寄存器的 Dalvik
字节码,以便它可以在 Android
的 VM
上运行。
在 Android
的执行期间,我们认为这种转换(称为 dexing
)是一个可以解决的问题。然而,在构建和推出 D8
的过程中,发现了特定供应商或特定版本上的虚拟机中的 bug
,本文将对此进行探讨。
1. Not A Not
D8
将 Java
字节码编译为 Dalvik
字节码的过程,我们可以通过简单的示例来看:
class Not {
static void print(int value) {
System.out.println(~value);
}
}
复制代码
我们通过 javac
编译查看。
$ javac *.java
$ javap -c *.class
class Not {
static void print(int);
Code:
0: getstatic #2 // Field java/lang/System.out:Ljava/io/PrintStream;
3: iload_0
4: iconst_m1
5: ixor
6: invokevirtual #3 // Method java/io/PrintStream.println:(I)V
9: return
}
复制代码
在上面的字节码中,下标位置 3
处是将参数值进栈,下标位置 4
处是将常量 -1
进栈,下标位置 5
处 是将栈顶两元素进行异或操作。在二进制中 -1
是由一串 1
组成的,异或操作的规则是二进制的每一位不同时该位为 1
,相同为 0
。
00010100 (value)
xor
11111111 (-1)
=
11101011
复制代码
通过上面的结果可以看到,一个数经过异或操作,二进制的很多位都变为 1
了,经过二进制运算,一个数已经和原来发生很大变化。
如果我们通过 D8
去执行上面的 .class
文件,会发现和 Dalvik
字节码没有太大差别。
$ java -jar d8.jar \
--lib $ANDROID_HOME/platforms/android-28/android.jar \
--release \
--output . \
*.class
$ $ANDROID_HOME/build-tools/28.0.3/dexdump -d classes.dex
[000134] Not.print:(I)V
0000: sget-object v0, Ljava/lang/System;.out:Ljava/io/PrintStream;
0002: xor-int/lit8 v1, v1, #int -1
0004: invoke-virtual {v0, v1}, Ljava/io/PrintStream;.println:(I)V
0007: return-void
复制代码
在 0002
位置处同样针对我们输入的参数 v1
和 -1
进行异或操作,并把结果存到 v1
中。这是一个非常简单的 Java
运算,如果你不知道更好,就不会再为此考虑。但在这篇文章中应该会告诉你还有更多的这类内容。
所有的 Dalvik
字节码都可以在 Android 的开发指导网站上获取,如果你仔细看,能发现在一元运算中包含一个 not-in
的字节码,这是一种更高效的方式来替代参数与 -1
的位运算操作,为什么没有使用呢?
答案就在于老版本的 dx
工具中,它没有使用 not-in
指令。
$ $ANDROID_HOME/build-tools/28.0.3/dx \
--dex \
--output=classes.dex \
*.class
[000130] Not.print:(I)V
0000: sget-object v0, Ljava/lang/System;.out:Ljava/io/PrintStream;
0002: xor-int/lit8 v1, v2, #int -1
0004: invoke-virtual {v0, v1}, Ljava/io/PrintStream;.println:(I)V
0007: return-void
复制代码
老版本的 dx
在 dalvik/dx/
目录下,如果我们对它的代码进行 grep
过滤,就可以找到哪些常量使用了 not int
指令。
$ grep -r -C 1 'not-int' src/com/android/dx/io
OpcodeInfo.java-522- public static final Info NOT_INT =
OpcodeInfo.java:523: new Info(Opcodes.NOT_INT, "not-int",
OpcodeInfo.java-524- InstructionCodec.FORMAT_12X, IndexType.NONE);
复制代码
所以 dx
工具中是有 not-in
指令的,我们也在代码中过滤出来了,但是当编译为 class
文件时就没有了。为了作对比,我还在过滤的时候包含了 if-eq
指令。
$ grep -r -C 1 'NOT_INT' src/com/android/dx/cf
$ grep -r -C 1 'IF_EQ' src/com/android/dx/cf
code/RopperMachine.java-885- case ByteOps.IFNULL: {
code/RopperMachine.java:886: return RegOps.IF_EQ;
code/RopperMachine.java-887- }
复制代码
通过对比,发现无论使用什么 Java
字节码,dx
工具都不会使用 not-in
指令。这很不幸,但归根结底没什么大不了的。
问题的原因是源于这样一个事实:因为字节码从来没有被标准的 dexing
工具使用过,一些供应商他们不会费心在他们的 dalvik-vm
的 jit
中支持它!一旦 D8
出现并开始使用完整的字节码集,在这些特定的手机上运行的 JIT
编译的应用程序就会崩溃。因此,在这种情况下,即使 D8
希望这样做,但是为了防止崩溃也不能使用 NOT INT
指令。
随着 API 21
版本的 ART VM
环境发布,所有的手机现在已经支持 not-in
指令,因此使用 D8
时添加 --min-api 21
将会使字节码使用 not-in
指令。
$ java -jar d8.jar \
--lib $ANDROID_HOME/platforms/android-28/android.jar \
--release \
--min-api 21 \
--output . \
*.class
$ $ANDROID_HOME/build-tools/28.0.3/dexdump -d classes.dex
[000134] Not.print:(I)V
0000: sget-object v0, Ljava/lang/System;.out:Ljava/io/PrintStream;
0002: not-int v1, v1
0003: invoke-virtual {v0, v1}, Ljava/io/PrintStream;.println:(I)V
0006: return-void
复制代码
在 0002
处看到了我们期望的 not-in
指令。
与 Android
兼容其它语言特性类似,D8
可以改变单个字节码的格式以确保兼容性。随着生态系统和最低 API
级别的提高,D8
将自动使用效率更高的字节码。
2. Long Compare
即使所有使用中的字节码指令都受支持,但特定供应商的 JIT
与其他任何类型的软件一样,也可能包含错误。这在 OKHTTP
和 OKIO
中的代码中就发生了。
两个库都有移动和统计字节的处理操作。他们的方法经常从检查负计数(这是无效的)开始,然后是零计数(没有工作要做)。
class LongCompare {
static void somethingWithBytes(long byteCount) {
if (byteCount < 0) throw new IllegalArgumentException("byteCount < 0");
if (byteCount == 0) return; // Nothing to do!
// Do something…
}
}
复制代码
我们查看编译后的字节码发现 0
被加载到堆栈中,并且进行了两次比较。
$ javac *.java
$ java -jar d8.jar \
--lib $ANDROID_HOME/platforms/android-28/android.jar \
--release \
--output . \
*.class
$ $ANDROID_HOME/build-tools/28.0.3/dexdump -d classes.dex
[000138] LongCompare.somethingWithBytes:(J)V
0000: const-wide/16 v0, #int 0
0002: cmp-long v2, v3, v0
0004: if-ltz v2, 000b
0006: cmp-long v2, v3, v0
0008: if-nez v2, 000a
…
复制代码
结合上面的字节码,cmp-long
会产生一个小于 0
、等于 0
或大于 0
的数。在每次比较之后,分别进行小于零的检查和非零的检查。但是,如果单个 cmp-long
产生比较结果,那么为什么 index 0006
会再次执行它呢?
这是因为如果在小于零的检查之后立即执行非零检查,则一些特定供应商的 JIT
会崩溃。这将导致程序在只处理 long
时看到不可能的异常,例如 NullPointerException
。
还是以上面的例子为例,在 API 21
的 ART
虚拟机的引入解决了这个问题。通过指定 ——min api 21
生成只执行单个 cmp-long
操作的字节码。
$ java -jar d8.jar \
--lib $ANDROID_HOME/platforms/android-28/android.jar \
--release \
--min-api 21 \
--output . \
*.class
$ $ANDROID_HOME/build-tools/28.0.3/dexdump -d classes.dex
[000138] LongCompare.somethingWithBytes:(J)V
0000: const-wide/16 v0, #int 0
0002: cmp-long v2, v2, v0
0004: if-ltz v2, 0009
0006: if-nez v2, 0008
…
复制代码
通常 D8
为了兼容性而修改优化字节码的格式。所以当你的应用程序不再支持那些有缺陷供应商实现的 Android
版本时,字节码会变得效率更高。但是,尽管 ART
在整个生态系统中为虚拟机带来了规范化,消除(或至少减少)这些特定于供应商的缺陷,但它并不能免除缺陷本身。
3. Recursion(递归)
供应商提供的 ART
本身有 bug
会影响特定的 Android
版本,随着 D8
的普及,会突然让一些 ART
的 bug
暴露出来。
毫无疑问,下面演示的 bug
示例是精心设计的,但是代码是从一个实际应用程序抽取出来的,并被提炼成一个独立的示例。
import java.util.List;
class Recursion {
private void f(int x, double y, double u, double v, List<String> w) {
f(x, y, u, v, w);
f(x, y, u, v, w);
f(x, y, u, v, w);
f(x, y, u, v, w);
f(x, y, u, v, w);
f(x, y, u, v, w);
f(x, y, u, v, w);
f(x, y, u, v, w);
f(x, y, u, v, w);
w.add(g(y, u, v));
}
private String g(double y, double u, double v) {
return null;
}
}
复制代码
在 Android 6.0(API 23)
上 ART
的 AOT
编译器上添加了调用分析用于执行内联方法。上面的函数 f
包含了大量的递归方法调用,所以 dex2oat
编译器编译的时候消耗了设备上的所有内存引起 crash
。幸运的是针对这种情况的递归调用,在 Android 7.0(API 24)
上进行了修复。
在低于 API 24
的版本上,D8
会改变 dex
文件从而引起这个崩溃。所以在研究解决方案前,我们重现一下这个崩溃。
$ javac *.java
$ java -jar d8.jar \
--lib $ANDROID_HOME/platforms/android-28/android.jar \
--release \
--min-api 24 \
--output . \
*.class
复制代码
我们给 D8
指定 --min-api 24
来编译一个 dex
文件,并把编译的 dex
文件放到一个 API 23
的设备上,会看 dex2oat
拒绝编译该 dex
文件。
$ adb shell push classes.dex /sdcard
$ adb shell dex2oat --dex-file=/sdcard/classes.dex --oat-file=/sdcard/classes.oat
$ adb logcat
…
11-29 13:57:08.303 4508 4508 I dex2oat : dex2oat --dex-file=/sdcard/classes.dex --oat-file=/sdcard/classes.oat
11-29 13:57:08.306 4508 4508 W dex2oat : Failed to open .dex from file '/sdcard/classes.dex': Failed to open dex file '/sdcard/classes.dex' from memory: Unrecognized version number in /sdcard/classes.dex: 0 3 7
11-29 13:57:08.306 4508 4508 E dex2oat : Failed to open some dex files: 1
11-29 13:57:08.309 4508 4508 I dex2oat : dex2oat took 7.440ms (threads: 4)
复制代码
在 dex 文件格式规范中,dex
文件的头 8
个字节应该是 DEX
字符,然后下一行是版本号,接着是一个空字节。因为我们指定了 --min-api 24
,所以 dex 文件的版本号就是 037
,我们现在来查看确认下。
$ xxd classes.dex | head -1
00000000: 6465 780a 3033 3700 e595 2d8c 49b5 d6b6 dex.037...-.I...
复制代码
为了能在这台旧设备中安装,我们必须指定版本号为 035
,这个很简单,我们通过任何 16
进制编辑器都可以进行修改,我使用的是 xxd
完成转换操作。
$ xxd -p classes.dex > classes.hex
$ nano classes.hex # Change 303337 to 303335
$ xxd -p -r classes.hex > classes.dex
复制代码
通过改变版本号,这个 dex
文件可以在 Android 6.0
的设备上编译了。
$ adb shell push classes.dex /sdcard
$ adb shell dex2oat --dex-file=/sdcard/classes.dex --oat-file=/sdcard/classes.oat
Segmentation fault
复制代码
如上面所示,我们重现了 ART
的这个 crash
。如我们所料,如果我们在 Android 7.0
的设备上运行这个 dex
文件就不会出现这个 crash
。
下面我们将 dex
文件名称进行修改,并且删除 --min-api 24
指定重新进行编译。
$ mv classes.dex classes_api24.dex
$ java -jar d8.jar \
--lib $ANDROID_HOME/platforms/android-28/android.jar \
--release \
--output . \
*.class
复制代码
查看 dex
字节码看看差异。
$ $ANDROID_HOME/build-tools/28.0.3/dexdump -d classes_api24.dex
[000190] Recursion.f:(IDDDLjava/util/List;)V
0000: invoke-direct/range {v7, v8, v9, v10, v11, v12, v13, v14, v15}, LRecursion;.f:(IDDDLjava/util/List;)V
…
0018: invoke-direct/range {v7, v8, v9, v10, v11, v12, v13, v14, v15}, LRecursion;.f:(IDDDLjava/util/List;)V
001b: move-object v0, v7
001c: move-wide v1, v9
001d: move-wide v3, v11
001e: move-wide v5, v13
001f: invoke-direct/range {v0, v1, v2, v3, v4, v5, v6}, LRecursion;.g:(DDD)Ljava/lang/String;
0022: move-result-object v8
0023: invoke-interface {v15, v8}, Ljava/util/List;.add:(Ljava/lang/Object;)Z
0026: return-void
catches : (none)
$ $ANDROID_HOME/build-tools/28.0.3/dexdump -d classes.dex
[000198] Recursion.f:(IDDDLjava/util/List;)V
0000: invoke-direct/range {v7, v8, v9, v10, v11, v12, v13, v14, v15}, LRecursion;.f:(IDDDLjava/util/List;)V
…
0018: invoke-direct/range {v7, v8, v9, v10, v11, v12, v13, v14, v15}, LRecursion;.f:(IDDDLjava/util/List;)V
001b: move-object v0, v7
001c: move-wide v1, v9
001d: move-wide v3, v11
001e: move-wide v5, v13
001f: invoke-direct/range {v0, v1, v2, v3, v4, v5, v6}, LRecursion;.g:(DDD)Ljava/lang/String;
0022: move-result-object v8
0023: invoke-interface {v15, v8}, Ljava/util/List;.add:(Ljava/lang/Object;)Z
0026: return-void
0027: move-exception v8
0028: throw v8
catches : 1
0x0018 - 0x001b
Ljava/lang/Throwable; -> 0x0027
复制代码
通过对比编译的两个 dex
文件,有问题的 dex
文件中包含了额外的字节码 move-exception
和 throw
,以及所有的 catches
条目。通过插入这个 try-catch
块,AOT
编译器禁用对方法内联的调用分析,try-catch
模块作用的范围是从 0x0018
到 0x001b
。如果我们在源码中删除一个 f
的递归调用,就不会引起 AOT
的这个编译错误,因为量还不足够大。
同样的代码,如果我们使用旧的 dx
编译器编译并不会在 Android 6.0
上引起崩溃,因为通过旧的 dx
编译器效率不高同时使用寄存器来禁止内联分析。
4. 总结
上面的三个例子是 Android
虚拟机中的一些特定供应商版本的错误。正如前面文章中介绍的语言功能消除一样,D8
仅在必要时根据您的最低 API
级别为这些 bug
应用兼容解决方案。VM
的 bug
不仅在老版本中出现,新版本同样也有 bug
。重要的是要记住,所有这些问题都不是由 D8
引起的。与 dx
相比,D8
以更有效地使用寄存器和更高效地排序字节码。为了进一步优化 dex
,我们必须求助于 D8
的优化兄弟 R8
,我们将在下一篇文章中开始研究它。
评论