原文出自 jakewharton 关于 D8
和 R8
系列文章第八篇。
在上篇文章中,我们介绍了 D8
和 R8
在编译时期可以通过 -assumevalues
标签指定值的范围,R8
可以通过这个功能优化 SDK_INT
的判断条件。这篇文章(以及接下来的几篇文章)将涵盖 R8
的更小层面的优化,当与其它优化结合时,这些优化效果更好。
除了 Java
的八大基本类型外,还有一种对象类型可以在运行时进行优化:classes(字节码)
。除此之外,string
是一个例外,它在 Java
、Kotlin
、 Java bytecode
和 Dalvik bytecode
中作为特殊处理,同时因为是特殊处理,R8
可以在编译时操纵它。
1. 常量池和字符串
在 Java
或 Kotlin
中定义一个字符串变量,该字符串变量的内容在转换为字节码时会进行特殊处理。在 Java
字节码中对应的是常量池。对于 Dalvik
字节码中被称为字符串数据片段。除了源代码中存在的字符串变量外,这些部分还包括类型、方法、字段和其他结构元素名称的字符串。
当我们通过 javap
指令查看类文件的字节码时,#
后跟一个数字指向常量池的引用。
0: new #2 // class java/lang/StringBuilder
3: dup
4: invokespecial #3 // Method java/lang/StringBuilder."<init>":()V
7: ldc #4 // String A:
复制代码
其中包含了一些有用的注释,这样我们就不必手动查询常量池来了解它们的含义。如果我们使用 javap -v
指令来查看字节码,会看到常量池也被输出了。
Constant pool:
#1 = Methodref #9.#18 // java/lang/Object."<init>":()V
#2 = Class #19 // java/lang/StringBuilder
#3 = Methodref #2.#18 // java/lang/StringBuilder."<init>":()V
#4 = String #20 // A:
⋮
#10 = Utf8 <init>
#11 = Utf8 ()V
⋮
#18 = NameAndType #10:#11 // "<init>":()V
#19 = Utf8 java/lang/StringBuilder
#20 = Utf8 A:
复制代码
#4
代表了一个字符串,并且它的值指向 #20
处,该位置是一个 UTF-8
编码的字符串 A:
,这个值对应我们前面the Java 9 string concat example 一文中的代码片段。
class Java9Concat {
public static String thing(String a, String b) {
return "A: " + a + " and B: " + b;
}
}
复制代码
如果我们使用 dexdump
查看对应的 Dalvik
字节码,我们并没有看到对应的字符串数据片段,而是把字符串放到字节码里面来提高可读性。
0000: new-instance v0, Ljava/lang/StringBuilder; // type@0003
0002: invoke-direct {v0}, Ljava/lang/StringBuilder;.<init>:()V // method@0003
0005: const-string v1, "A: " // string@0002
复制代码
可以看到字符串常量 A:
对应的来源是 0002
位置处,而该位置对应的又是 0003
位置处。
2. 字符串操作
通常在开发中频繁的对字符串操作很不常见,比如你不会通过 new User("OliveJakeHazel".substring(5, 9))
创建一个名字为 Jake
的 User
对象,我们会直接使用 Jake
作为一个字符串变量来使用,而不是通过 substring
来截取。但是也有例外,比如计算字符串的长度。
static String patternHost(String pattern) {
return pattern.startsWith(WILDCARD)
? pattern.substring(WILDCARD.length())
: pattern;
}
复制代码
上面的代码片段来自 OkHttp,用于判断字符串的前缀,然后有条件地删除。那么让我们来看看这个代码片段的 Dalvik
字节码。
[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
复制代码
在 0000-0002
中,WILDCARD
常量被加载并且赋值给 v0
,然后在 startWith
函数中作为参数 (in v2)
。接着在 0008-0011
之间,计算了 v0
的长度并保存在 v1
中,所以后面在这个参数上调用了 substring
方法。
WILDCARD
是一个常量,它的长度也是一个常量,所以在运行时期计算它的长度是一种资源浪费。对于上面的示例代码,我们使用 R8 进行编译,发现 lenght()
方法已经被一个常量值替代。
[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: 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
复制代码
在 0008
位置处加载了一个常量 2
,然后该常量立即传递给了 substring
方法。因为这个计算很简单,删除对 length()
的调用不会改变程序的行为,D8
会执行这个优化!
3. Inlining(内联)
计算字符串的长度并不是在运行时期的唯一优化,比如常见的字符串操作:startWith
、indexOf
、substring
都可以直接用常量来替代。
class Test {
private static final String WILDCARD = "*.";
private static String patternHost(String pattern) {
return pattern.startsWith(WILDCARD)
? pattern.substring(WILDCARD.length())
: pattern;
}
public static String canonicalHost(String pattern) {
String host = patternHost(pattern);
return HttpUrl.get("http://" + host).host();
}
public static void main(String... args) {
String pattern = "*.example.com";
String canonical = canonicalHost(pattern);
System.out.println(canonical);
}
}
复制代码
上面的示例代码稍微复杂一些,涉及到 3 个函数之间的嵌套调用,我们可以把他们之间的调用关系直接内联起来表示。
class Test {
private static final String WILDCARD = "*.";
public static void main(String... args) {
String pattern = "*.example.com";
String host = pattern.startsWith(WILDCARD)
? pattern.substring(WILDCARD.length())
: pattern;
String canonical = HttpUrl.get("http://" + host).host();
System.out.println(canonical);
}
}
复制代码
在 introduced in part 1 of the null analysis 一文中介绍到 R8
的中间表示层(IR)在编译期间使用静态单赋值形式(SSA)来追踪变量的使用路径。尽管 pattern
调用 startsWith
函数,但是因为 pattern
是一个常量 "*.example.com"
,同时 WILDCARD
也是一个常量。所以这里就可以被优化。
String pattern = "*.example.com";
-String host = pattern.startsWith(WILDCARD)
+String host = true
? pattern.substring(WILDCARD.length())
复制代码
所以 else
分支的 dead-code
就要被删除。
String pattern = "*.example.com";
-String host = true
- ? pattern.substring(WILDCARD.length())
- : pattern;
+String host = pattern.substring(WILDCARD.length());
String canonical = HttpUrl.get("http://" + host).host();
复制代码
同样,WILDCARD
常量的长度也是固定的,所以 length()
方法就会被常量替代。
String pattern = "*.example.com";
-String host = pattern.substring(WILDCARD.length());
+String host = pattern.substring(2);
String canonical = HttpUrl.get("http://" + host).host();
复制代码
好了,回到最初的示例代码,我们通过 R8
来编译确认下最终的结果。
$ javac -cp okhttp-3.13.1.jar Test.java
$ cat rules.txt
-keepclasseswithmembers class * {
public static void main(java.lang.String[]);
}
$ java -jar r8.jar \
--lib $ANDROID_HOME/platforms/android-28/android.jar \
--release \
--output . \
--pg-conf rules.txt \
*.class
$ $ANDROID_HOME/build-tools/28.0.3/dexdump -d classes.dex
[0001c0] Test.main:([Ljava/lang/String;)V
0000: const/4 v2, #int 2
0001: const-string v0, "*.example.com"
0003: invoke-virtual {v0, v2}, Ljava/lang/String;.substring:(I)Ljava/lang/String;
0006: move-result-object v2
0007: new-instance v0, Ljava/lang/StringBuilder;
0009: invoke-direct {v0}, Ljava/lang/StringBuilder;.<init>:()V
000c: const-string v1, "http://"
000e: invoke-virtual {v0, v1}, Ljava/lang/StringBuilder;.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
0011: invoke-virtual {v0, v2}, Ljava/lang/StringBuilder;.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
0014: invoke-virtual {v0}, Ljava/lang/StringBuilder;.toString:()Ljava/lang/String;
0017: move-result-object v2
0018: invoke-static {v2}, Lokhttp3/HttpUrl;.get:(Ljava/lang/String;)Lokhttp3/HttpUrl;
001b: move-result-object v2
001c: invoke-virtual {v2}, Lokhttp3/HttpUrl;.host:()Ljava/lang/String;
001f: move-result-object v2
0020: sget-object v0, Ljava/lang/System;.out:Ljava/io/PrintStream;
0022: invoke-virtual {v0, v2}, Ljava/io/PrintStream;.println:(Ljava/lang/String;)V
0025: return-void
复制代码
我们可以看到 startsWith
方法的判断条件已经被删除了,通过这样的优化,我们的 dex
文件变小的同时,程序运行会更快。
4. 其它方法
length()
和 startsWith()
方法可以在编译时用计算的常量值来替代,那么诸如 isEmpty()
、contains()
、endsWith()
、equals()
和 equalsIgnoreCase()
方法同样可以被替代。看到上面的结果让我很不满意,因为优化被留在了表中。让我们把最后一个表单看作源代码,然后分析没有发生的事情。
String pattern = "*.example.com";
String host = pattern.substring(2);
String canonical = HttpUrl.get("http://" + host).host();
System.out.println(canonical);
复制代码
相比上面的例子,因为在编译时期参数和调用者都是已知的常量,我们删除了 startsWith
方法。同样对于常量调用 substring(2)
也是固定的,同样应该被删除。
-String pattern = "*.example.com";
-String host = pattern.substring(2);
+String host = "example.com";
String canonical = HttpUrl.get("http://" + host).host();
复制代码
优化后,HttpUrl.get
方法的参数就是两个字符串连接操作了,这个操作在运行时期应该被删除。
-String host = "example.com";
-String canonical = HttpUrl.get("http://" + host).host();
+String canonical = HttpUrl.get("http://example.com").host();
复制代码
这些看似简单的优化操作并不是那么简单,这些优化可能包含在 R8
的未来版本中。
每一个现有的字符串优化都会返回一个原始值,比如 boolean
或 int
值,这些值可以直接用字节码表示。由于这些优化,如果字符串未被使用,则字符串数据部分可能会压缩。在上面的示例中,由于 WILDCARD
的两个作用(作为 startsWith
的参数和计算 length
)已经被替代,所以它最终不会出现在 dex
文件中。
计算子字符串或在编译时执行串联操作可能会增大字符串的大小。在串联操作中,如果输入字符串仍在应用程序的其他部分中使用,则不会消除这些操作后的字符串。但是,新字符串将始终被添加。所以会造成字符串片段增大。
在本文中的普通程序上进行这些优化将删除 16
个字节码,但会添加 18
个字节的字符串数据。在这种情况下,由于输入字符串不在其他任何地方使用,因此为了净减少 18
个字节(忽略 DEX
的其他部分),将额外删除 20
个字节。
在实际应用中,计算这些是否是正确的选择变得不太清楚。目前,还没有执行这些优化。
5. 总结
当与内联结合使用时,R8
的字符串优化有助于消除 dead-code
,并在处理字符串常量时提高运行时性能。关于字符串的优化可以关注 issuetracker.google.com/issues/119364907
本系列的下一篇文章将讨论编译时创建的字符串常量的优化。
评论