[译] R8 优化:Null 数据分析 (第二篇)

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

本文原文出自 jakewharton 关于 D8 和 R8 系列文章第六篇。

在上篇文章中,我们演示了 R8 针对内联方法进行空判断代码的优化,这是由于 R8(和 D8)在 IR 时期通过空判断实现的。当传递给方法的参数是空或非空恒成立时,则 R8 会在编译期直接对 null 判断进行结果计算。

前面两篇文章中的例子基本都是用 Kotlin 写的,同时为了提高字节码的可读性,我有时只粘贴了字节码的一部分。在上篇文章中我们以在 main 函数中调用 coalesce 函数为例开始。

fun <T : Any> coalesce(a: T?, b: T?): T? = a ?: b

fun main(args: Array<String>) {
 println(coalesce("one", "two"))
 println(coalesce(null, "two"))
}
复制代码

上篇文章中,我们使用不同版本的编译器来编译模拟优化处理后的示例,他们的字节码都是以 sget-object v1Ljava/lang/System.out:Ljava/io/PrintStream; 开头,这个字节码的意思是查找静态 System.out 字段的字节码,该字段最终调用 println 方法。

如果我们对上面的 demo 进行编译、打包,然后查看打包后的字节码,可以发现首行的字节码有一点不同。

$ kotlinc *.kt

$ java -jar d8.jar \
    --lib $ANDROID_HOME/platforms/android-28/android.jar \
    --release \
    --output . \
    *.class kotlin-stdlib-1.3.11.jar

$ $ANDROID_HOME/build-tools/28.0.3/dexdump -d classes.dex
[00023c] NullsKt.main:([Ljava/lang/String;)V
0000: const-string v0, "args"
0002: invoke-static {v2, v0}, Lkotlin/jvm/internal/Intrinsics;.checkParameterIsNotNull:(Ljava/lang/Object;Ljava/lang/String;)V
0005: sget-object v1, Ljava/lang/System;.out:Ljava/io/PrintStream;
复制代码

字节码不是先替代我们写的函数到函数体,而是先调用 Intrinstrics.checkParameterIsNotNull 函数,该函数是在编译时的后面运行时验证。

Kotlin 的类型系统为空引用建模。通过将 Array<String> 作为 main 函数的参数,即它是非空的。但是它是 public API ,所以任何人都可以调用。为了进行空约束,Kotlin 编译器在每个 public API 函数中插入非空参数的防御检查。

下面我们使用 D8 来编译上面的源代码,看看有哪些改变。

$ cat rules.txt
-keepclasseswithmembers class * {
  public static void main(java.lang.String[]);
}
-dontobfuscate

$ java -jar r8.jar \
    --lib $ANDROID_HOME/platforms/android-28/android.jar \
    --release \
    --output . \
    --pg-conf rules.txt \
    *.class kotlin-stdlib-1.3.11.jar

$ $ANDROID_HOME/build-tools/28.0.3/dexdump -d classes.dex
[000314] NullsKt.main:([Ljava/lang/String;)V
0000: if-eqz v1, 0011
0002: sget-object v1, Ljava/lang/System;.out:Ljava/io/PrintStream;
…
0010: return-void
0011: const-string v1, "args"
0013: invoke-static {v1}, Lkotlin/jvm/internal/Intrinsics;.throwParameterIsNullException:(Ljava/lang/String;)V
复制代码

在上面 D8 编译的字节码中,我们可以看到字符串常量的加载和 Intrinsics 方法的调用已经被标准的空检查 if-eqz 替代。如果空检查成立,则会跳转到字节码的最后抛出异常。正常情况下 args 是非空的,此时程序会从 0000 正常执行到 0010

我们可能会想“因为是内联函数,所以上面的字节码看起来和 R8 处理的很像”。在上一篇文章中,coalesce 函数被内联所以才会有 Instrinsics.checkParameterIsNotNull 字节码实现,我们可以快速看下 Instrinsics.checkParameterIsNotNull 的实现。

public static void checkParameterIsNotNull(Object value, String paramName) {
  if (value == null) {
    throwParameterIsNullException(paramName);
  }
}
复制代码

实际 R8 在处理的时候并不是我们想象的内联形式,如果是内联的话,上面的 if 肯定会出现在函数的最上面。除此之外,尽管上面的方法很小,但是它已经超出了 R8 定义内联的阈值范围。这里有几个方式可以产生这种效果。

第一种技巧是增加 R8 内联的阈值范围。由于 checkParameterNotNull 仅用于调用参数的非空检查,因此方法的内联阈值将增大,方法体也为空,因此它成为合格的,并且是内联的。

第二个技巧是 R8 识别出这两个对参数执行空检查的字节码,然后抛出异常。当识别出这种模式时,R8 假定它是方法执行的不常见路径。为了对公共路径进行优化,将反转空检查,以便非空大小写紧跟检查。异常引发代码被推送到方法的底部。

但是,checkParameterNotNullif 检查与字节码 R8 的序列不匹配,需要识别参数检查模式。if主体包含静态方法调用,而不是引发异常。最后一个技巧是R8有一个内在的,它识别出对intrinsics.throwParameterIsNullException` 的调用等价于抛出一个异常。这样可以使主体正确匹配模式。

这三个技巧结合起来解释了为什么 R8 产生我们上面看到的字节码。

记住,对于非 Kotlin 调用程序可能对于每个可见的方法,每个非空参数都有此代码。在复杂的程序中,都会出现大量这样的情况!

使用 R8 将静态方法调用替换为标准的空检查,并将异常情况移动到方法的末尾,代码将保留检查的安全性,同时最小化性能影响。

1. Combining Null Information

在上篇文章中介绍了 R8 通过利用空信息删除无用的空检查。在本文的上半部分中,我们介绍了 R8 通过提高内联阈值来忽略空检查,同时代替 Kotlin 提供的 Intrinsic 方法,使用标准的 if-eqz 指令进行空检查。貌似这两个特性可以在一些方面进行合并归类。

在下面的示例中,我们新增了 String.double 函数,

fun String.double(): String = this + this

fun coalesce(a: String?, b: String?): String? = a ?: b

fun main(args: Array<String>) {
  println(coalesce(null, "two")?.double())
}
复制代码

在进行 D8 编译前,首先来列举下存在的非空检查:

  1. args 参数的检查,因为 main 方法是 public
  2. coalesce 函数返回值的检查,因为会调用 double 函数;
  3. coalesce 函数第一个参数的非空检查,因为决定是返回 firstsecond
  4. double 函数的接受者要检查,因为它是一个 public 方法。

下面我们通过 D8 编译来进行验证。

[000310] NullsKt.main:([Ljava/lang/String;)V
0000: if-eqz v1, 0019
0002: const-string v1, "two"
0004: new-instance v0, Ljava/lang/StringBuilder;
0006: invoke-direct {v0}, Ljava/lang/StringBuilder;.<init>:()V
0009: invoke-virtual {v0, v1}, Ljava/lang/StringBuilder;.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
000c: invoke-virtual {v0, v1}, Ljava/lang/StringBuilder;.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
000f: invoke-virtual {v0}, Ljava/lang/StringBuilder;.toString:()Ljava/lang/String;
0012: move-result-object v1
0013: sget-object v0, Ljava/lang/System;.out:Ljava/io/PrintStream;
0015: invoke-virtual {v0, v1}, Ljava/io/PrintStream;.println:(Ljava/lang/Object;)V
0018: return-void
0019: const-string v1, "args"
001b: invoke-static {v1}, Lkotlin/jvm/internal/Intrinsics;.throwParameterIsNullException:(Ljava/lang/String;)V
复制代码

除了保护参数的检查之外,所有的空检查都被清除了。

因为 R8 可以证明 coalesce 返回一个非空引用,所以可以消除所有下游的空检查。这意味着不需要安全调用,而是将其替换为普通方法调用。双功能接收器上的空检查也被消除。

2. No Inlining Required

迄今为止的例子包括帮助减少产量的内嵌,实际上,内联不会像在这些小例子中那样发生。这并不能阻止所有空检查的清除。

虽然我发现 Kotlin 的例子在这里很有说服力,因为强制的、零的检查,Java 的优化是有趣的,因为它的行为是相反的。Java 不在公共方法参数上设置防御性空检查,因此数据流分析可以使用其他模式来进行空值信号,即使没有内联。

final class Nulls {
  public static void main(String[] args) {
    System.out.println(first(args));
    if (args == null) {
      System.out.println("null!");
    }
  }

  public static String first(String[] values) {
    if (values == null) throw new NullPointerException("values == null");
    return values[0];
  }
}
复制代码

Java 中,每个引用都可能是空的。因此,在像 first 这样的方法中看到主动进行空检查并不少见(即使在注释 @nonnull 时也是如此)。library 方法可能很大,或者在应用程序的所有地方调用,因此它们通常不是内联的。为了模拟这一点,我们可以显式地告诉 R8 作为 rules.txt 中的一种方法保持。

 -keepclasseswithmembers class * {
   public static void main(java.lang.String[]);
 }
 -dontobfuscate
+-keep class Nulls {
+   public static java.lang.String first(java.lang.String[]);
+}
复制代码

我们看到,即使没有通过内联优化,实际的结果还是可以接受的。

[000144] Nulls.first:([Ljava/lang/String;)Ljava/lang/String;
0000: if-eqz v1, 0006
0002: const/4 v0, #int 0
0003: aget-object v1, v1, v0
0005: return-object v1
0006: new-instance v1, Ljava/lang/NullPointerException;
0008: const-string v0, "values == null"
000a: invoke-direct {v1, v0}, Ljava/lang/NullPointerException;.<init>:(Ljava/lang/String;)V
000d: throw v1

[000170] Nulls.main:([Ljava/lang/String;)V
0000: sget-object v0, Ljava/lang/System;.out:Ljava/io/PrintStream;
0002: invoke-static {v1}, LNulls;.first:([Ljava/lang/String;)Ljava/lang/String;
0005: move-result-object v1
0006: invoke-virtual {v0, v1}, Ljava/io/PrintStream;.println:(Ljava/lang/String;)V
0009: return-void
复制代码

首先,R8 再次颠倒了空检查,因此引发异常的异常情况位于索引 0006 处方法的底部。此方法的正常执行将从索引 00000005 并返回。

总之,args 及其打印的显式空检查已消失。这是因为 R8 检测到 args 是入口,在那里在调用之后不可能为空。因此,在调用 first 之后发生的任何空检查都不需要发生。

3. 总结

所有这些示例都很小,并且有些人为的,但是它们演示了 R8 在可空性和空检查方面所做的数据流分析的一部分。在整个应用程序的范围内,无论是 JavaKotlin 还是混合的,不必要的空校验和未使用的分支可以在不牺牲它们提供的安全性的情况下被消除。

下周的 R8 文章将介绍我最喜欢的工具特性。这也是一个我认为产生最好的演示,并与每个安卓开发者共鸣。敬请期待!

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