Skip to content

JEP 280: Indify String Concatenation | 改进字符串连接操作

摘要

将由 javac 生成的静态 String 连接字节码序列改为使用 invokedynamic 调用 JDK 库函数。这将使得能够在不需要进一步更改 javac 生成的字节码的情况下对 String 连接进行未来优化。

目标

为构建优化的 String 连接处理程序奠定基础,实现时无需修改 Java 到字节码编译器。应尝试多种翻译策略作为这项工作的动机示例。生产(并可能切换到)优化的翻译策略是此 JEP 的一个延伸目标。

非目标

以下不是目标:引入任何可能有助于构建更好的翻译策略的新的 String 和 / 或 StringBuilder API,重新审查 JIT 编译器对优化 String 连接的支持,支持高级 String 插值用例,或探索 Java 编程语言的其他变化。

成功指标

  • javac 生成稳定且具有未来兼容性的字节码序列。意图是在任何主要 Java 版本中最多更改一次字节码形状。

  • String 连接性能不退化。

  • 启动时间和性能达到合理水平而不退化。

动机

目前,javacString 连接翻译为 StringBuilder::append 链。有时,这种翻译并不是最优的,有时我们需要适当地预设 StringBuilder 的大小。-XX:+OptimizeStringConcat 选项在 JIT 编译器中启用了激进的优化,它识别 StringBuilder 的追加链,并进行预设大小和原地复制。这些优化虽然富有成效,但是脆弱且难以扩展和维护;例如,参见 JDK-8043677, JDK-8076758, 和 JDK-8136469。诱人的是改变 javac 本身的翻译;参见例如编译器 -dev 邮件列表上的 最近的提案

当考虑在 javac 中改变任何内容时,每次想要提高性能就改变编译器和字节码形状似乎有些不便。用户通常期望相同的字节码在新版 JVM 上运行得更快。要求用户为了性能重新编译他们的 Java 程序并不友好,而且还会导致测试矩阵爆炸,因为 JVM 应该识别生成的所有字节码变体。因此,我们可能希望使用一些技巧,在字节码中声明连接字符串的意图,然后在运行时扩展该意图。

换句话说,我们正在引入一种 类似于 新字节码的东西,"string concat"。与 Lambda 工作类似,这缩小了 JLS 允许的语言特性(在这种情况下是字符串连接)和 JVMS 没有相应设施的鸿沟,迫使 javac 将其翻译为低级代码,并进一步迫使 VM 实现识别所有低级翻译形式。invokedynamic 允许我们通过在核心库级别提供对字符串连接的保证接口,一举跨越这种阻抗不匹配。接口的实际实现甚至可以使用(潜在不安全的)私有 JVM API 来实现连接,比如固定长度的字符串构建器,与特定用例绑定。直接在 javac 中使用这些 API 将意味着将它们公开给公众。

描述

我们将利用 invokedynamic 的威力:它提供了一种延迟链接的方法,通过在初始调用期间提供引导调用目标的手段。这种方法并不新鲜,我们大量借鉴了当前代码中 翻译 lambda 表达式 的方式。

这个想法是用一个简单的 invokedynamic 调用 java.lang.invoke.StringConcatFactory 来替换整个 StringBuilder 追加过程,该调用将接受需要连接的值。例如,

java
String m(String a, int b) {
  return a + "(" + b + ")";
}

目前编译为:

java
java.lang.String m(java.lang.String, int);
       0: new           #2                  // class java/lang/StringBuilder
       3: dup
       4: invokespecial #3                  // Method java/lang/StringBuilder."<init>":()V
       7: aload_1
       8: invokevirtual #4                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      11: ldc           #5                  // String (
      13: invokevirtual #4                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      16: iload_2
      17: invokevirtual #6                  // Method java/lang/StringBuilder.append:(I)Ljava/lang/StringBuilder;
      20: ldc           #7                  // String )
      22: invokevirtual #4                  // Method java/lang/StringBuilder.append:(Ljava/lang/String;)Ljava/lang/StringBuilder;
      25: invokevirtual #8                  // Method java/lang/StringBuilder.toString:()Ljava/lang/String;
      28: areturn

但即使是在天真的 indy 翻译中,在提议的实现中通过 -XDstringConcat=indy 可显著简化:

java
java.lang.String m(java.lang.String, int);
       0: aload_1
       1: ldc           #2                  // String (
       3: iload_2
       4: ldc           #3                  // String )
       6: invokedynamic #4,  0              // InvokeDynamic #0:makeConcat:(Ljava/lang/String;Ljava/lang/String;ILjava/lang/String;)Ljava/lang/String;
      11: areturn

BootstrapMethods:
  0: #19 invokestatic java/lang/invoke/StringConcatFactory.makeConcat:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;

请注意我们如何在不进行装箱的情况下传递一个 int 参数。在运行时,引导方法(BSM)运行并链接实际执行连接操作的代码。它使用适当的 invokestatic 调用重写了 invokedynamic 调用。这从常量池加载了常量字符串,但我们可以利用 BSM 静态参数直接将这些和其他常量传递给 BSM 调用。这就是提议中 -XDstringConcat=indyWithConstants 的含义。

java
java.lang.String m(java.lang.String, int);
       0: aload_1
       1: iload_2
       2: invokedynamic #2,  0              // InvokeDynamic #0:makeConcat:(Ljava/lang/String;I)Ljava/lang/String;
       7: areturn

BootstrapMethods:
  0: #15 invokestatic java/lang/invoke/StringConcatFactory.makeConcatWithConstants:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/invoke/CallSite;
    Method arguments:
      #16 \u0001(\u0001)

注意我们只将动态参数("a""b")传递给 BSM。静态常量在链接期间已经处理过了。BSM 方法提供了一个配方,告诉连接动态和静态参数的顺序,以及静态参数是什么。这种策略还单独处理空值、基本类型和空字符串。

关于默认的字节码形式应该是哪一种,这是一个悬而未决的问题。有关字节码形式、连接风格和性能数据的更多详细信息可以在 实验笔记 中找到。提议的引导方法 API 可以在 此处 找到。完整的实现可以在 sandbox 分支中找到:

bash
$ hg clone http://hg.openjdk.java.net/jdk9/sandbox sandbox
$ cd sandbox/
$ sh ./common/bin/hgforest.sh up -r JDK-8085796-indyConcat
$ sh ./configure
$ make images

可以通过以下方式查看基线和修补运行时之间的差异:

bash
$ hg diff -r default:JDK-8085796-indyConcat
$ cd langtools/
$ hg diff -r default:JDK-8085796-indyConcat
$ cd jdk/
$ hg diff -r default:JDK-8085796-indyConcat

基准测试可以在 此处 找到。

提议的实现成功构建了 JDK,运行了回归测试(包括测试 String 连接的新测试),并在所有平台上通过了冒烟测试。可以使用多种策略进行实际的连接操作。我们提议的实现表明,当我们将 javac 生成的字节码序列移动到注入相同字节码的 BSM 中时,吞吐量不会受到影响,从而验证了这种方法。优化的策略表明在默认的 StringBuilder 长度不足或 VM 的优化失败时,性能表现与基线相当或更好。

有关更多数据,请参阅 实验笔记

替代方案

对于这个提案,有三个替代方案。

第一个明显的替代方案:javac 本身中更改字节码序列,通过将提议实现中的字节码生成策略之一移植过去。虽然在短期内简单,但它违背了与整个 Java 社区的非言语契约:JVM 足够聪明,可以识别和优化现有的字节码。我们不能轻易地强制用户每次我们想要调整 String 连接转换时都重新编译他们的 Java 程序。每当字节码形状发生变化时,JIT 编译器就会被迫识别一个新形式,这会极大地增加测试矩阵,并为不知情的用户设置奇怪的性能(误)行为。如果我们为了性能原因而改变字节码形状,我们最好在一个重要的版本发布中进行一次,并以一种兼容未来的方式进行。同样,使用私有 API 进行优化的字符串连接是不可行的:javac 生成的代码只应使用公共 API。

第二个替代方案: 引入一个可变参数的 StringConcat.concat(Object... args) 方法,并使用它来连接参数。主要的缺点是将基本类型装箱,然后将所有参数装箱到 Object[] 中。虽然先进的 JIT 可能会识别这些习惯用法,但我们将自己置于已经复杂的优化编译器中的新的和未经测试的优化的掌握之下。invokedynamic 基本上做了同样的事情,但它支持各种目标方法签名。

第三个替代方案: 继续按照目前的做法,并扩展 -XX:+OptimizeStringConcat 来覆盖更多情况。缺点是在 JIT 编译器中进行优化需要使用编译器 IR 明确说明 toString 转换和存储管理,这使得扩展变得困难,并且需要罕见的专业知识才能正确实现。此外,字节码形状中微小的差异和怪癖会破坏 -XX:+OptimizeStringConcat,我们必须在 javac 中特别小心以避免这种情况。参见例如 JDK-8043677JDK-8076758JDK-8136469

这三个替代方案基本上都使我们处于脆弱的编译器优化的掌握之下。

测试

大多数 Java 代码都会经常使用字符串连接。除了常规的功能和性能测试外,我们计划使用微基准测试来研究这个变化的性能模型。可能需要开发针对不同策略的新回归测试。

风险和假设

其他与 String 相关的变化。 这个改变可能会与其他 String 相关的变化产生冲突,特别是与 Compact Strings (JEP 254) 冲突。缓解计划是生成与当前 JDK 完全相同的字节码序列,然后启用 "Compact Strings" 的 JVM 将执行相同的代码路径,不受任何 String 连接翻译的影响。事实上,我们的初步实验表明我们的提案与 Compact Strings 完美地配合。

java.base 豁免。 由于这个功能使用核心库功能 (java.lang.invoke) 来实现核心语言功能 (String 拼接),我们必须豁免 java.base 模块使用标识化的 String concat。否则,当 java.lang.invoke.* 机制需要 String concat 工作时,就会发生循环依赖,而 java.lang.invoke.* 机制又需要 java.lang.invoke.* 机制。这种豁免可能会限制从这个功能中观察到的性能改进,因为许多 java.base 类将无法使用它。我们认为这是一个可以接受的缺点,应该由 VM 的优化编译器来解决。

维护成本。 传统的 String concat 转换不会消失,因此我们必须同时维护 -XX:+OptimizeStringConcat 和 VM 中的新转换策略优化。javac 仍然需要为无法使用标识化 String concat 的情况提供回退,特别是一些系统类。这并不构成一个重大问题,因为我们的性能故事包括在大多数策略中涉及 -XX:+OptimizeStringConcat

兼容性:不支持 invokedynamic 的平台。 一旦它们看到标识化的 String concat,它们应该怎么办?对于 Lambda 已经有了经验,在非 indy 拼写的平台上必须将其去除,并重新静态生成同样的代码,这个代码在 indy 即时生成的代码中会发出。虽然对于天真的 String concat 生成的代码序列比 LambdaFactory 更简单,但它仍然在 VM 代码中创建了更多的复杂性。由于 javac 仍然保留发出传统 String concat 序列的代码,前述平台可以使用此回退。

兼容性:其他编译器。 其他(非 javac)编译器是否应该发出相同的字节码?我们是否建议改变所有编译器实现以使用新的翻译策略?由于传统的 String concat 不会消失,其他编译器可以自由地发出它们今天发出的相同字节码序列。它们可以根据需要逐步改变一些翻译为提议的基于 indy 的翻译方案。

兼容性:字节码操作 / 织入。 即使没有源代码更改,工具也需要处理它们不习惯的新版 indy,只需重新编译。我们认为这不是一个主要问题,因为工具已经可以处理 Lambda,并且它们需要识别 invokedynamic。对于曾经使用 javac 生成的 StringBuilder::append 链进行工具化的工具需要进行更正。

静态占用。String concat-heavy 代码中,类文件大小可能会成为一个问题。那里有一些机器生成的文件,几乎用尽了 CP 中的所有条目,并进行了大量的字符串连接。似乎标识化的 String concat 的方法字节码占用更低,常量池中有 java.lang.invoke 机制的额外静态开销,加上每个 concat 形状的一点开销。请参见上面的注释以获取更多数据。

启动开销。 这项工作前进的风险是启动开销可能是禁止的。Lambda 的实现已经引入了这些种类的翻译策略,但只有在使用 Lambda 时才需要支付代价。对于 String concat,一旦 invokedynamic 编译到字节码中,现有代码中使用 + 的用法就无法逃避(除非更改代码以避免 + 并显式调用 StringBuilder)。这个风险与初始化 invokedynamic 机制相关(参见 JDK-8086045),因此接受这里的启动回归意味着其他需要初始化 invokedynamic 的更改(特别是模块系统(JEP 261))不会有启动回归。相反,如果模块系统先于这个改变到来,那么启动成本将被摊销。

依赖