这是我参与更文挑战的第 21 天,活动详情查看: 更文挑战
原文出自 jakewharton 关于 D8 和 R8 系列文章第八篇。
- 原文链接 : R8 Optimization: String Constant Operations
- 原文作者 : jakewharton
- 译者 : Antway
在上篇文章中,我们介绍了 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/1193…
本系列的下一篇文章将讨论编译时创建的字符串常量的优化。