原文出自 jakewharton 关于 D8 和 R8 系列文章第 14 篇。
不,那不是打字错误!虽然到目前为止本系列中的优化都是由 R8 在整个程序优化期间完成的,但 D8 也可以执行一些简单的优化。
D8 是在这篇文章中介绍:作为新的 Java-to-Dalvik 字节码编译器引入 Android 的。它处理 Java8 (以及 Java9 和更高版本)语言特性的移植,以便在 Android上运行。它还可以解决平台中特定于供应商和版本的错误。
到目前为止,我们已经从 D8 系列中看到了这一点,但它还有另外两个职责,我们将在本期和下一期文章中介绍:
1. 重写 Switch
在过去的两篇文章中,我们介绍了 switch 的优化。对于 D8 和 R8 为某些 switch 语句生成的字节码,两者都略带谎言。让我们再看一次其中的一个例子。
enum Greeting { FORMAL, INFORMAL; static String greetingType(Greeting greeting) { switch (greeting) { case FORMAL: return "formal"; case INFORMAL: return "informal"; default: throw new AssertionError(); } }}
复制代码
在上面的 Java 字节码中为 greetingType 方法显示的使用了 lookupswitch 字节码,该字节码在匹配值时具有跳转位置的偏移量。
static java.lang.String greetingType(Greeting); Code: 0: getstatic #2 // Field Main$1.$SwitchMap$Greeting:[I 3: aload_0 4: invokevirtual #3 // Method Greeting.ordinal:()I 7: iaload 8: lookupswitch { 1: 36 2: 39 default: 42 } 36: ldc #4 // String formal 38: areturn 39: ldc #5 // String informal 41: areturn 42: new #6 // class java/lang/AssertionError 45: dup 46: invokespecial #7 // Method java/lang/AssertionError."<init>":()V 49: athrow
复制代码
tableswitch Java 字节码在转换为 Dalvik 字节码时被重写为 packed-switch 字节码。
[000584] Main.greetingType:(LGreeting;)Ljava/lang/String;0000: sget-object v0, LMain$1;.$SwitchMap$Greeting:[I0002: invoke-virtual {v2}, LGreeting;.ordinal:()I0005: move-result v10006: aget v0, v0, v10008: packed-switch v0, 00000017000b: new-instance v0, Ljava/lang/AssertionError;000d: invoke-direct {v0}, Ljava/lang/AssertionError;.<init>:()V0010: throw v00011: const-string v0, "formal"0013: return-object v00014: const-string v0, "informal"0016: return-object v00017: packed-switch-data (8 units)
复制代码
如果我们真的用 D8 编译和索引上面的源文件,它的 Dalvik 字节码输出是不同的。
[0005f0] Main.greetingType:(LGreeting;)Ljava/lang/String; 0000: sget-object v0, LMain$1;.$SwitchMap$Greeting:[I 0002: invoke-virtual {v1}, LGreeting;.ordinal:()I 0005: move-result v1 0006: aget v0, v0, v1-0008: packed-switch v0, 00000017+0008: const/4 v1, #int 1+0009: if-eq v0, v1, 0014+000b: const/4 v1, #int 2+000c: if-eq v0, v1, 0017 000e: new-instance v0, Ljava/lang/AssertionError; 0010: invoke-direct {v0}, Ljava/lang/AssertionError;.<init>:()V 0013: throw v0 0014: const-string v0, "formal" 0016: return-object v0 0017: const-string v0, "informal" 0019: return-object v0-0017: packed-switch-data (8 units)
复制代码
可以看到在 008 位置上的 packed-switch 被替换为一系列的 if/else if 检查。根据索引,你可能会认为这会产生一个更大的二进制,但实际上恰恰相反。原始的 packed-switch 指令会被编译为 packed-switch-data 字节码,并占用 8 个单元长度。 所以整个 packed-switch 总共需要 26 字节指令而 if/else if 只需要 20 个字节指令。
一般只有在字节码节省的情况下,才会重写 switch。这取决于 case 块的数量、是否有fallthrough 以及值是否连续。D8 计算两种形式的成本,然后选择较小的形式。
2. 字符串优化
早在二月份,就写了一篇关于 R8 的字符串常量操作的文章。它介绍一个 OkHttp 的例子,其中对一个常量进行了 String.length 调用优化。
static String patternHost(String pattern) { return pattern.startsWith(WILDCARD) ? pattern.substring(WILDCARD.length()) : pattern;}
复制代码
当使用旧的 dx 编译工具时,输出的就是一个简单的转码。
[0001a8] Test.patternHost:(Ljava/lang/String;)Ljava/lang/String;0000: const-string v0, "*."0002: invoke-virtual {v2, v0}, Ljava/lang/String;.startsWith:(Ljava/lang/String;)Z0005: move-result v10006: if-eqz v1, 00100008: invoke-virtual {v0}, Ljava/lang/String;.length:()I0011: move-result v10012: invoke-virtual {v2, v1}, Ljava/lang/String;.substring:(I)Ljava/lang/String;000f: move-result-object v20010: return-object v2
复制代码
在第 008 行的字节码是 String.length 方法的调用并返回一个值,这个常量对应在 0000 位置。
然而,对于 D8,这个方法调用一个常量,在编译时被检测到并计算为相应的数值。
[0001a8] Test.patternHost:(Ljava/lang/String;)Ljava/lang/String; 0000: const-string v0, "*." 0002: invoke-virtual {v1, v0}, Ljava/lang/String;.startsWith:(Ljava/lang/String;)Z 0005: move-result v0 0006: if-eqz v0, 000d-0008: invoke-virtual {v0}, Ljava/lang/String;.length:()I-0011: move-result v1+0008: const/4 v0, #int 2 0009: invoke-virtual {v1, v0}, Ljava/lang/String;.substring:(I)Ljava/lang/String; 000c: move-result-object v1 000d: return-object v1
复制代码
删除方法调用不是 D8 甚至 R8 通常会做的事情。应用此优化是安全的,因为 String 是框架中具有良好定义行为的最后一个类。
在第一篇文章发布后的九个月里,字符串上可以优化的方法的数量大幅增长。D8 和 R8 都将计算isEmpty()、 startsWith(String)、endsWith(String)、contains(String)、equals(String)、equalsIgnoreCase(String)、contentEquals(String)、hashCode()、length()、indexOf(String)、indexOf(int)、lastIndexOf(String)、lastIndexOf(int)、compareTo(String)、compareToIgnoreCase(String)、substring(int)、substring(int, int),以及常量字符串上的 trim()。显然,在没有 R8 内联的情况下,这些方法中的大多数都不太可能应用,但是当它发生时,它们就在那里了。
3. 已知数组长度
就像你对一个常量字符串调用 length() 方法一样,对于一个固定长度的数组调用 lenght() 方法也没什么奇怪的。
让我们在看一下 OkHttp 中的一个示例:
private fun decodeIpv6(input: String, pos: Int, limit: Int): InetAddress? { val address = ByteArray(16) var b = 0
var i = pos while (i < limit) { if (b == address.size) return null // Too many groups.
复制代码
address.size (在字节码层面是调用 lenght)的使用可以避免重复的 16 个常量。缺点是这个解析循环的每次迭代都会解析 dx 输出中的数组长度。
[00020c] OkHttpKt.decodeIpv6:(Ljava/lang/String;II)Ljava/net/InetAddress;0000: const/16 v5, #int 160002: new-array v0, v5, [B0004: const/4 v1, #int 00005: const/4 v2, #int 00006: if-ge v2, v8, 00360008: array-length v6, v00009: if-ne v1, v6, 000b ⋮
复制代码
在 0000 处字节码中常量 16 被注册到 v5 中,同时被 0002 出字节码引用,所以这个数组的长度就被保存到 v0 中。从 0006 位置开始的循环 i < limit,在循环内部, 在 0008 位置时 v0 标识的数组长度被加载到 v6 中,并且在 0009 处的 if 中使用。
D8 识别出 lenght 的查找是在一个数组引用上进行的,该数组引用不会改变,并且其大小在编译时是已知的。
[00020c] OkHttpKt.decodeIpv6:(Ljava/lang/String;II)Ljava/net/InetAddress; 0000: const/16 v5, #int 16 0002: new-array v0, v5, [B 0004: const/4 v1, #int 0 0005: const/4 v2, #int 0 0006: if-ge v2, v8, 0036-0008: array-length v6, v0-0009: if-ne v1, v6, 000b+0009: if-ne v1, v5, 000b ⋮
复制代码
对 array-length 的调用被删除,同时 if 语句被重写为重用 v5 的值。
就其本身而言,这种模式并不太常见。当 R8 内联生效并且检查 array.length 的方法内联到声明新数组的调用程序中时,它再次发挥了良好的作用。
每个优化都很小。D8 只能在没有外部可见效果且不改变程序行为的情况下执行优化。这在很大程度上限制了它在单个方法体中进行的优化。
在运行时,您无法判断 switch 是否重新连接到 if/else 条件。无法判断对常量字符串的length() 调用是否已替换为其等效的常量值。无法判断在同一方法中初始化的数组上对 length 的调用是否已替换为输入大小。D8 能够执行的每一种优化(以及其他一些优化)都会产生更小、更高效的字节码。当然,当你调用 R8 的全部功能时,它们的影响会成倍增加。
在下一篇文章中,我们将开始讨论 D8 如何在现有类型上对新 API 进行兼容,以便在旧的 API 级别上工作。
评论