本文原文出自 jakewharton 关于 D8
和 R8
系列文章第五篇。
上篇文章中第一次介绍到 R8
的优化,本篇文章将介绍 R8
针对 Null Data
(空数据)的优化,让我们一起开始吧!
1. R8
fun <T : Any> coalesce(a: T?, b: T?): T? = a ?: b
fun main(vararg args: String) {
println(coalesce("one", "two"))
println(coalesce(null, "two"))
}
复制代码
在上面示例中,coalesce
函数根据参数 a
是否为 null
来进行返回,如果 a
不为 null
,则返回 a
,反之返回 b
。上面的输出结果如下:
在编译时,如果一个函数的函数体很简短,R8
和 ProGuard
会在该函数的调用处将函数体内置到调用函数中。因为 coalesce
很简短,所以它的函数体会被内联嵌套在所有调用它的地方。
fun main(vararg args: String) {
println("one" ?: "two")
println(null ?: "two")
}
复制代码
实际上,Kotlin
编辑器能够在编译时期确认 ?:
操作符运算结果,我们编译上面的代码,查看编译打包后的字节码来验证。
[000180] NullsKt.main:([Ljava/lang/String;)V
0000: sget-object v1, Ljava/lang/System;.out:Ljava/io/PrintStream;
0002: const-string v0, "one"
0004: invoke-virtual {v1, v0}, Ljava/io/PrintStream;.println:(Ljava/lang/Object;)V
0007: sget-object v1, Ljava/lang/System;.out:Ljava/io/PrintStream;
0009: const-string v0, "two"
000b: invoke-virtual {v1, v0}, Ljava/io/PrintStream;.println:(Ljava/lang/Object;)V
000e: return-void
复制代码
如我们所料,字节码中并没有进行条件判断,而是直接调用 println
方法输出 one
和 two
。但是,由于优化发生在 R8
内部,而不是在 Kotlin
编译器之前,所以实际的 Dalvik
字节码包含条件。
[000144] NullsKt.main:([Ljava/lang/String;)V
0000: sget-object v1, Ljava/lang/System;.out:Ljava/io/PrintStream;
0002: const-string v0, "one"
0004: if-nez v0, 0006
0006: const-string v0, "two"
0008: invoke-virtual {v1, v0}, Ljava/io/PrintStream;.println:(Ljava/lang/Object;)V
000b: sget-object v1, Ljava/lang/System;.out:Ljava/io/PrintStream;
000d: const/4 v0, #int 0
000f: if-nez v0, 0010
0010: const-string v0, "two"
0012: invoke-virtual {v1, v0}, Ljava/io/PrintStream;.println:(Ljava/lang/Object;)V
0015: return-void
复制代码
在 0002
处加载常量 one
,然后 0004
判断是否为空,所以一直是不为空的,这样就导致 0006
处的代成成为 dead code
,无法加载 two
。同样的道理对于 000d
处加载 0(代表 null
),然后在 000f
处进行非空检查,这个会一直失败,因为一直为空,然后直接去执行 0010
处的代码。
在上一篇文章中,我们讲到 R8
在 IR
层面优化代码,IR
使用 SSA 来加强一些优化项。使用 SSA
,R8
可以决定程序中的数据的处理流程。对于上面代码第一个 println
的内联数据处理流程可简要描述如下。
SSA
的基本特性是每个变量只赋值一次。这也是字符串 two
赋值给 y
而不赋值给 x
的原因。z
通过 [Φ(欧拉函数)](https://baike.baidu.com/item/%E6%AC%A7%E6%8B%89%E5%87%BD%E6%95%B0)
来选择 x
或 y
的值。结合前面的字节码,可以看到 x
、y
和 z
最后都会赋值给寄存器地址 v0
,而寄存器地址 v0
会被覆盖。注意单次分配只针对 IR
层!
如果我们针对上面的流程图增加一些空信息,因为 x
和 y
都是用常量初始化的,所以它们一定不为空。进而,z
也是不为空的,
当 x
不为 null
时,R8
知道 if-nez
字节码检查 x
非空是恒成立的,所以这个判断是无用的。同样,针对 y
的赋值也是无用的。
通过上面的分析,我们知道 false
分支判断的字节码是无用的 dead-code
,所以我们对分支进行优化,删除无用的判断分支。
从图中可以看到 z
的值就是 Φ
函数对单变量 x
的运算结果,所以我们可以将 z
替换为 x
。
从图中可以看到剩下的部分分别是:一个指向 System.out
的 w
变量、将 one
赋值给 x
和 w
调用 println
函数输出 x
。
上面的介绍是针对示例代码中的第一个 println
函数。第二个 println
函数由于初始化时是 null
,所以跟上面的过程是相反的。x
初始化为 null
,进行非空检查一直是 false
,所以 y
一直是 two
。
通过使用 SSA IR
,R8
能够根据条件进行优化,删除无用代码。
$ kotlinc *.kt
$ 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
[000340] NullsKt.main:([Ljava/lang/String;)V
0000: sget-object v1, Ljava/lang/System;.out:Ljava/io/PrintStream;
0002: const-string v0, "one"
0004: invoke-virtual {v1, v0}, Ljava/io/PrintStream;.println:(Ljava/lang/Object;)V
0007: sget-object v1, Ljava/lang/System;.out:Ljava/io/PrintStream;
0009: const-string v0, "two"
000b: invoke-virtual {v1, v0}, Ljava/io/PrintStream;.println:(Ljava/lang/Object;)V
000e: return-void
复制代码
通过上面的字节码可以看到完全和我们一开始的示例分析一样。
2. Analysis Inside D8(D8 内部分析)
针对上面的例子,我尝试使用 Java
代码进行实现来分析看看。
class Nulls {
public static void main(String... args) {
Object first = "one";
if (first == null) {
first = "two";
}
System.out.println(first);
Object second = null;
if (second == null) {
second = "two";
}
System.out.println(second);
}
}
复制代码
我们对上面的例子进行编译,然后通过 d8
进行打包,最后通过 dumpdex
查看 dex
字节码,发现判断条件仍然被消除了。
$ javac *.java
$ java -jar d8.jar \
--lib $ANDROID_HOME/platforms/android-28/android.jar \
--release \
--output . \
*.class
$ $ANDROID_HOME/build-tools/28.0.3/dexdump -d classes.dex
[000224] Nulls.main:([Ljava/lang/String;)V
0000: sget-object v1, Ljava/lang/System;.out:Ljava/io/PrintStream;
0002: const-string v0, "one"
0004: invoke-virtual {v1, v0}, Ljava/io/PrintStream;.println:(Ljava/lang/Object;)V
0007: sget-object v1, Ljava/lang/System;.out:Ljava/io/PrintStream;
0009: const-string v0, "two"
000b: invoke-virtual {v1, v0}, Ljava/io/PrintStream;.println:(Ljava/lang/Object;)V
000e: return-void
复制代码
发生这种情况的原因是 D8
同样使用 IR
进行优化,并且仍然存在空信息。即使不进行任何 R8
优化,如果在 IR
中存在的判断条件始终为 true
或 false
,D8
同样会进行相关代码的清除优化。
如果我们使用不包含 IR
的 dx
工具编译,则会发现字节码中仍然存在条件判断和无用的代码。
$ $ANDROID_HOME/build-tools/28.0.3/dx --dex --output=classes.dex *.class
$ $ANDROID_HOME/build-tools/28.0.3/dexdump -d classes.dex
[000204] Nulls.main:([Ljava/lang/String;)V
0000: const-string v0, "one"
0002: if-nez v0, 0006
0004: const-string v0, "two"
0006: sget-object v1, Ljava/lang/System;.out:Ljava/io/PrintStream;
0008: invoke-virtual {v1, v0}, Ljava/io/PrintStream;.println:(Ljava/lang/Object;)V
000b: const/4 v0, #int 0
000c: if-nez v0, 0010
000e: const-string v0, "two"
0010: sget-object v1, Ljava/lang/System;.out:Ljava/io/PrintStream;
0012: invoke-virtual {v1, v0}, Ljava/io/PrintStream;.println:(Ljava/lang/Object;)V
0015: return-void
复制代码
因此,R8
对内联嵌套进行优化确实有很好的作用,但是如果源代码中存在常量条件和死代码,D8
在编译时同样会消除它们。
3. 总结
这篇文章只触及 R8
内部数据流分析的表面。下一篇文章将继续扩展可空性分析,讨论 Kotlin
如何在运行时强制实施可空性约束。
评论