规避供应商以及特定版本的VM Bugs

    xiaoxiao2023-11-11  175

    本文原文出自 jakewharton 关于 D8 和 R8 系列文章第三篇。

    原文链接 : Avoiding Vendor- and Version-Specific VM Bugs原文作者 : jakewharton译者 : 小伟

    在前两篇文章中介绍了 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,我们将在下一篇文章中开始研究它。

    最新回复(0)