[译] R8 优化: Switch 场景下的枚举

446 阅读3分钟

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

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

在上篇文章介绍枚举 ordinals 的文章中,我们知道 R8 会优化 switch 表达式。在那篇文章中,枚举上 switch 的字节码被省略了,因为实际上还有更多的优化工作要做。

让我们从一个简单的枚举开始,在两个独立的源文件中切换它的内容(这在后面很重要)。

enum Greeting {
  FORMAL, INFORMAL
}
class Main {
  static String greetingType(Greeting greeting) {
    switch (greeting) {
      case FORMAL: return "formal";
      case INFORMAL: return "informal";
      default: throw new AssertionError();
    }
  }

  public static void main(String... args) {
    System.out.println(greetingType(Greeting.INFORMAL));
  }
}

如果我们编译并运行这些文件,输出就如预期的那样。

$ javac Greeting.java Main.java
$ java -cp . Main
informal

上篇文章的讲解中,在 switch 表达式中字节码中显示了对 ordinal() 的调用,那么重新排序 greeting 的常量将中断 Main 的输出。

 enum Greeting {
-  FORMAL, INFORMAL
+  INFORMAL, FORMAL
 }

更改常量顺序后,我们只能重新编译 Greeting.java,但应用程序仍会生成正确的输出。

$ javac Greeting.java
$ java -cp . Main
informal

所以如果字节码仅依赖于 ordinal() 的值,则此代码将生成 formal

1. 深入字节码(Into The Bytecode)

为了更好的理解,我们来看一下 greetingType 方法的字节码。

$ javap -c Main.class
class Main {
  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
}

让我们把字节码分解一下。此方法的第一个字节码有很多信息要解包:

0: getstatic     #2      // Field Main$1.$SwitchMap$Greeting:[I

这行字节码的意思是:在类 Main$1 中查找名为 $SwitchMap$Greeting、类型为 int[] 的静态字段。但是我们并没有在类中定义这个字段,所以它一定是由 javac 生成的。

接下来的两个字节码是调用方法参数中的 ordinal() 方法。

3: aload_0
4: invokevirtual #3      // Method Greeting.ordinal:()I`java

Java 字节码是基于堆栈的,因此 getstaticint[] 值和 ordinal()int 值都保留在堆栈上(如果您不了解基于堆栈的机器是如何工作的,您可以看下面的介绍。)下一条指令使用int[]int 作为其操作数。

7: iaload

iaload 指令在 int[] 中的 ordinal() 返回的索引处查找值。该方法的其余字节码是一个normal switch 语句,它使用数组中的值作为输入。

2. Switch Maps

很明显,$SwitchMap$Greeting 数组是一种机制,它允许我们的代码继续工作,尽管序数改变了它们的值。那么它是如何工作的呢?

编译后,switch 的每个 case 语句都对应一个下标位置,default 分支默认是 0

switch (greeting) {
  case FORMAL: ...   // <-- index 1
  case INFORMAL: ... // <-- index 2
  default: ...       // <-- index 0
}

$SwitchMap$Greeting 数组在运行时填充在 Main$1 的静态初始值设定项中。首先创建空 int[] 并将其分配给 $SwitchMap$Greeting 字段。

0: invokestatic  #1      // Method Greeting.values:()[LGreeting;
3: arraylength
4: newarray      int
6: putstatic     #2      // Field $SwitchMap$Greeting:[I

此数组的长度与常量的数量相同(可能与 case 块的数量不匹配)。这一点很重要,因为序数用作此数组的索引。

下一步字节码指令是准备 swtich 语句中的常量。

 9: getstatic     #2      // Field $SwitchMap$Greeting:[I
12: getstatic     #3      // Field Greeting.FORMAL:LGreeting;
15: invokevirtual #4      // Method Greeting.ordinal:()I
18: iconst_1
19: iastore

第一个 caseFORMAL 序号用作数组中的偏移量,数组中存储了对应的开关索引值 1。对于非正式的序号和值2也是如此。这个 int[] 有效地创建了一个从序号到固定整数值集的映射,该整数值集可能会改变,但不会改变。

switch-map@2x.png 通过使用这个映射,switch 语句可以保持稳定,即使我们重新排列 Greeting 的常量。

3. 优化

当枚举可以与调用方分开重新编译时,javac 创建的开关映射间接寻址非常有用。Android 应用程序被打包为一个单元,因此间接寻址只不过是浪费了二进制大小和运行时开销。

通过 D8 运行上面的示例类问价表明间接寻址得到了维护。

$ java -jar $R8_HOME/build/libs/d8.jar \
      --lib $ANDROID_HOME/platforms/android-29/android.jar \
      --release \
      --output . \
      *.class

$ $ANDROID_HOME/build-tools/29.0.2/dexdump -d classes.dex
 ⋮
[00040c] 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 v1, v0, v1
0008: packed-switch v1, 00000024

然而,R8 执行整个程序分析和优化。它没有必要保留这个间接寻址,因为枚举不能独立于 switch

 [00040c] Main.greetingType:(LGreeting;)Ljava/lang/String;
-0000: sget-object v0, LMain$1;.$SwitchMap$Greeting:[I
 0000: invoke-virtual {v1}, LGreeting;.ordinal:()I
 0003: move-result v1
-0006: aget v1, v0, v1
 0004: packed-switch v1, 00000024

switch 的分支将被重写,说明了输入现在直接使用基于零的序数,而不是开关映射中基于一的值。由于 Main$1 及其数组不再被引用,它就像普通的死代码一样被消除。

只有删除此间接寻址,才能从枚举 ordinal() 优化,从而消除开关。否则,序数值将作为索引流入int[],这在一般情况下是不安全的。

4. Kotlin

Kotlin 中使用的枚举也会出于相同的原因生成类似的间接寻址。

val Greeting.type get() = when (this) {
  Greeting.FORMAL -> "formal"
  Greeting.INFORMAL -> "informal"
}

编译时,Java 字节码显示了类似的机制,但名称不同。

$ javap -c MainKt
public final class MainKt {
  public static final java.lang.String getType(Greeting);
    Code:
      0: aload_0
      1: getstatic     #21     // Field MainKt$WhenMappings.$EnumSwitchMapping$0:[I
      4: swap
      5: invokevirtual #27     // Method Greeting.ordinal:()I
      8: iaload
      9: tableswitch   {
                    1: 36
                    2: 41
              default: 46
         }
     ⋮

生成的类的后缀是 $WhenMappings,而不是名为 $EnumSwitchMapping$0int[]

R8 最初没有检测到 Kotlin 映射,因为这些名称略有不同。R81.6 版(包含在 AGP 3.6 中)将正确检测并消除它们。


switch map 映射消除对于二进制大小和运行时性能来说是一个很好的胜利。更重要的是,通过删除 switch 与其分支逻辑之间的间接寻址,其他优化(如将对 ordinal() 的调用转换为常量)可以消除分支。

更多的 R8 优化帖子即将发布。敬请期待!