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

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

原文出自 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/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)) 创建一个名字为 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;)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(内联)

计算字符串的长度并不是在运行时期的唯一优化,比如常见的字符串操作: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;)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 的未来版本中。

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

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

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

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

5. 总结

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

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

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