写点什么

[译] R8 优化: 字符串操作

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

原文出自 jakewharton 关于 D8R8 系列文章第八篇。



在上篇文章中,我们介绍了 D8R8 在编译时期可以通过 -assumevalues 标签指定值的范围,R8 可以通过这个功能优化 SDK_INT 的判断条件。这篇文章(以及接下来的几篇文章)将涵盖 R8 的更小层面的优化,当与其它优化结合时,这些优化效果更好。


除了 Java 的八大基本类型外,还有一种对象类型可以在运行时进行优化:classes(字节码)。除此之外,string 是一个例外,它在 JavaKotlin Java bytecodeDalvik bytecode 中作为特殊处理,同时因为是特殊处理,R8 可以在编译时操纵它。

1. 常量池和字符串

JavaKotlin 中定义一个字符串变量,该字符串变量的内容在转换为字节码时会进行特殊处理。在 Java 字节码中对应的是常量池。对于 Dalvik 字节码中被称为字符串数据片段。除了源代码中存在的字符串变量外,这些部分还包括类型、方法、字段和其他结构元素名称的字符串。


当我们通过 javap 指令查看类文件的字节码时,# 后跟一个数字指向常量池的引用。


0: new           #2  // class java/lang/StringBuilder3: dup4: invokespecial #3  // Method java/lang/StringBuilder."<init>":()V7: 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@00030002: invoke-direct {v0}, Ljava/lang/StringBuilder;.<init>:()V // method@00030005: const-string v1, "A: " // string@0002
复制代码


可以看到字符串常量 A: 对应的来源是 0002 位置处,而该位置对应的又是 0003 位置处。

2. 字符串操作

通常在开发中频繁的对字符串操作很不常见,比如你不会通过 new User("OliveJakeHazel".substring(5, 9)) 创建一个名字为 JakeUser 对象,我们会直接使用 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;)Z0005: move-result v10006: if-eqz v1, 00100008: invoke-virtual {v0}, Ljava/lang/String;.length:()I0011: move-result v10012: invoke-virtual {v2, v1}, Ljava/lang/String;.substring:(I)Ljava/lang/String;000f: move-result-object v20010: 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;)Z0005: move-result v00006: if-eqz v0, 000d0008: const/4 v0, #int 20009: invoke-virtual {v1, v0}, Ljava/lang/String;.substring:(I)Ljava/lang/String;000c: move-result-object v1000d: return-object v1
复制代码


0008 位置处加载了一个常量 2,然后该常量立即传递给了 substring 方法。因为这个计算很简单,删除对 length() 的调用不会改变程序的行为,D8 会执行这个优化!

3. Inlining(内联)

计算字符串的长度并不是在运行时期的唯一优化,比如常见的字符串操作:startWithindexOfsubstring 都可以直接用常量来替代。


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;)V0000: const/4 v2, #int 20001: const-string v0, "*.example.com"0003: invoke-virtual {v0, v2}, Ljava/lang/String;.substring:(I)Ljava/lang/String;0006: move-result-object v20007: new-instance v0, Ljava/lang/StringBuilder;0009: invoke-direct {v0}, Ljava/lang/StringBuilder;.<init>:()V000c: 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 v20018: invoke-static {v2}, Lokhttp3/HttpUrl;.get:(Ljava/lang/String;)Lokhttp3/HttpUrl;001b: move-result-object v2001c: invoke-virtual {v2}, Lokhttp3/HttpUrl;.host:()Ljava/lang/String;001f: move-result-object v20020: sget-object v0, Ljava/lang/System;.out:Ljava/io/PrintStream;0022: invoke-virtual {v0, v2}, Ljava/io/PrintStream;.println:(Ljava/lang/String;)V0025: 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 的未来版本中。


每一个现有的字符串优化都会返回一个原始值,比如 booleanint 值,这些值可以直接用字节码表示。由于这些优化,如果字符串未被使用,则字符串数据部分可能会压缩。在上面的示例中,由于 WILDCARD 的两个作用(作为 startsWith 的参数和计算 length)已经被替代,所以它最终不会出现在 dex 文件中。


计算子字符串或在编译时执行串联操作可能会增大字符串的大小。在串联操作中,如果输入字符串仍在应用程序的其他部分中使用,则不会消除这些操作后的字符串。但是,新字符串将始终被添加。所以会造成字符串片段增大。


在本文中的普通程序上进行这些优化将删除 16 个字节码,但会添加 18 个字节的字符串数据。在这种情况下,由于输入字符串不在其他任何地方使用,因此为了净减少 18 个字节(忽略 DEX 的其他部分),将额外删除 20 个字节。


在实际应用中,计算这些是否是正确的选择变得不太清楚。目前,还没有执行这些优化。

5. 总结

当与内联结合使用时,R8 的字符串优化有助于消除 dead-code,并在处理字符串常量时提高运行时性能。关于字符串的优化可以关注 issuetracker.google.com/issues/119364907


本系列的下一篇文章将讨论编译时创建的字符串常量的优化。

用户头像

Antway

关注

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

专注开源库

评论

发布
暂无评论
[译] R8 优化: 字符串操作