这是我参与更文挑战的第 16 天,活动详情查看: 更文挑战
本文原文出自 jakewharton 关于 D8 和 R8 系列文章第三篇。
- 原文链接 : Avoiding Vendor- and Version-Specific VM Bugs
- 原文作者 : jakewharton
- 译者 : Antway
在前两篇文章中介绍了 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,我们将在下一篇文章中开始研究它。