原文出自 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:[I
0002: invoke-virtual {v2}, LGreeting;.ordinal:()I
0005: move-result v1
0006: aget v0, v0, v1
0008: packed-switch v0, 00000017
000b: new-instance v0, Ljava/lang/AssertionError;
000d: invoke-direct {v0}, Ljava/lang/AssertionError;.<init>:()V
0010: throw v0
0011: const-string v0, "formal"
0013: return-object v0
0014: const-string v0, "informal"
0016: return-object v0
0017: 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;)Z
0005: move-result v1
0006: if-eqz v1, 0010
0008: invoke-virtual {v0}, Ljava/lang/String;.length:()I
0011: move-result v1
0012: invoke-virtual {v2, v1}, Ljava/lang/String;.substring:(I)Ljava/lang/String;
000f: move-result-object v2
0010: 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 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
⋮
复制代码
在 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
级别上工作。
评论