[译] D8 优化

这是我参与更文挑战的第 27 天,活动详情查看: 更文挑战

原文出自 jakewharton 关于 D8R8 系列文章第 14 篇。

不,那不是打字错误!虽然到目前为止本系列中的优化都是由 R8 在整个程序优化期间完成的,但 D8 也可以执行一些简单的优化。

D8 是在这篇文章中介绍:作为新的 Java-to-Dalvik 字节码编译器引入 Android 的。它处理 Java8 (以及 Java9 和更高版本)语言特性的移植,以便在 Android上运行。它还可以解决平台中特定于供应商和版本的错误

到目前为止,我们已经从 D8 系列中看到了这一点,但它还有另外两个职责,我们将在本期和下一期文章中介绍:

  • 在不存在旧 API 级别的地方使用 Backporting 方法。
  • 执行局部优化以减小字节码大小和/或提高性能。

我们将在本系列的下一篇文章中讨论 API 的适配。现在,让我们看看 D8 可能执行的一些局部优化。

1. 重写 Switch

在过去的两篇文章中,我们介绍了 switch 的优化。对于 D8R8 为某些 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 是框架中具有良好定义行为的最后一个类。

在第一篇文章发布后的九个月里,字符串上可以优化的方法的数量大幅增长。D8R8 都将计算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 级别上工作。

© 版权声明
THE END
喜欢就支持一下吧
点赞0 分享