写点什么

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

用户头像
Antway
关注
发布于: 1 小时前

原文出自 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 . Maininformal
复制代码


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


 enum Greeting {-  FORMAL, INFORMAL+  INFORMAL, FORMAL }
复制代码


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


$ javac Greeting.java$ java -cp . Maininformal
复制代码


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

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

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


$ javap -c Main.classclass 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_04: 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: arraylength4: newarray      int6: putstatic     #2      // Field $SwitchMap$Greeting:[I
复制代码


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


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


 9: getstatic     #2      // Field $SwitchMap$Greeting:[I12: getstatic     #3      // Field Greeting.FORMAL:LGreeting;15: invokevirtual #4      // Method Greeting.ordinal:()I18: iconst_119: iastore
复制代码


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



通过使用这个映射,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:[I0002: invoke-virtual {v1}, LGreeting;.ordinal:()I0005: move-result v10006: aget v1, v0, v10008: 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 MainKtpublic 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 优化帖子即将发布。敬请期待!

用户头像

Antway

关注

持续精进,尽管很慢 2019.05.27 加入

专注开源库

评论

发布
暂无评论
[译] R8 优化:  Switch 场景下的枚举