跳到主要内容

JEP 280:字符串连接的索引化

QWen Max 中英对照

概述

javac 生成的静态 String 拼接字节码序列更改为使用 invokedynamic 调用 JDK 库函数。这将使得未来对 String 拼接的优化成为可能,而无需对 javac 生成的字节码进行进一步更改。

目标

为构建优化的 String 拼接处理器奠定基础,且无需更改 Java 到字节码的编译器即可实现。本工作的动机示例应尝试多种转换策略。生成(并可能切换到)优化的转换策略是此 JEP 的延伸目标。

非目标

目标并不包括:引入任何可能有助于构建更好转换策略的新的 String 和/或 StringBuilder API、重新审视 JIT 编译器对优化 String 拼接的支持、支持高级的 String 插值使用案例,或者探索对 Java 编程语言的其他变更。

成功指标

  • javac 生成了一个稳定的、面向未来的字节码序列。目标是在任何主要的 Java 发布版本中,至多只改变一次字节码的结构。

  • String 拼接性能不会出现倒退。

  • 启动时间以及达到性能所需的时间不会超出合理范围。

动机

当前,javac 会将 String 拼接操作转换为 StringBuilder::append 链式调用。但有时这种转换并不是最优的,并且有时我们需要适当地预设 StringBuilder 的大小。-XX:+OptimizeStringConcat 选项能够在 JIT 编译器中启用激进的优化,这些优化能够识别 StringBuilder 的 append 链式调用,并进行预设大小和就地复制的操作。尽管这些优化很有成效,但它们非常脆弱,难以扩展和维护;例如,请参见 JDK-8043677JDK-8076758JDK-8136469。因此,修改 javac 中的转换方式变得很有吸引力;例如,请参见 compiler-dev 列表中的近期提案

当我们考虑对 javac 中的任何内容进行更改时,每次为了提升性能而修改编译器和字节码格式似乎都不太方便。用户通常期望相同的字节码在较新的 JVM 上运行得更快。要求用户为了性能重新编译他们的 Java 程序并不友好,而且还会使测试矩阵变得复杂,因为 JVM 应该能够识别生成的字节码的所有变体。因此,我们可能需要采用某种技巧,在字节码中声明字符串连接的意图,然后在运行时扩展该意图。

换句话说,我们正在引入一种类似于新字节码的内容,“字符串连接”。正如在 Lambda 工作中所做的那样,这弥合了 JLS(Java 语言规范)允许某种语言特性(在本例中为字符串连接)与 JVMS(Java 虚拟机规范)未提供适当设施之间的差距,迫使 javac 将其转换为低级代码,并进一步迫使虚拟机实现识别所有低级转换形式。invokedynamic 允许我们通过在核心库级别提供一个有保证的字符串连接接口,一举克服这种阻抗不匹配。该接口的实际实现甚至可以使用(潜在不安全的)私有 JVM API 来完成字符串连接操作,例如固定长度的字符串构建器,绑定到特定的用例。直接在 javac 中使用这些 API 则意味着将它们公开给公众。

描述

我们将使用 invokedynamic 的强大功能:它通过在初始调用期间提供一次性引导调用目标的手段,提供了惰性链接的功能。这种方法并不新鲜,我们大量借鉴了当前用于转换 lambda 表达式 的代码。

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

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

当前编译为:

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.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.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 方法会获得一个“配方”,该配方指示了动态和静态参数的连接顺序以及静态参数的内容。此策略还分别处理 null、基本类型和空字符串。

应该将哪种字节码风格设为默认值,这是一个开放性的问题。更多关于字节码形态、连接风格以及性能数据的详细信息,请参阅实验笔记。提议的引导方法 API 可以在这里找到。完整的实现可以在沙盒分支中找到:

$ 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

通过使用以下方法,可以看到基线和修补运行时之间的差异:

$ 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

基准测试可以在这里找到:http://cr.openjdk.java.net/~shade/8085796/

所提出的实现成功地构建了 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 拼接。否则,当 java.lang.invoke.* 机制需要 String 拼接才能工作时,就会发生循环依赖,而反过来这又需要 java.lang.invoke.* 机制。这种豁免可能会限制从该功能中观察到的性能改进,因为许多 java.base 类将无法使用它。我们认为这是一个可以接受的缺点,应该由虚拟机的优化编译器来弥补。

维护成本。 旧的 String 拼接转换方式不会消失,因此我们必须同时维护 -XX:+OptimizeStringConcat 和虚拟机中针对较新转换策略的优化。javac 仍然需要为无法使用索引化 String 拼接的情况提供回退方案,尤其是某些系统类。这并不会带来显著问题,因为我们的性能策略在大多数情况下仍会涉及 -XX:+OptimizeStringConcat

兼容性:那些不支持 invokedynamic 的平台。 它们在遇到经过 indified 优化的字符串拼接时应该怎么做?在 Lambda 表达式的实现中已经有相关经验了,对于不支持 invokedynamic 的平台,它们需要将 invokedynamic 指令反糖化(desugar),并重新生成与 invokedynamic 动态生成的代码等效的静态代码。虽然为简单的 String 拼接生成的代码序列比 LambdaFactory 更简单,但它仍然会在虚拟机代码中引入更多复杂性。由于 javac 仍然保留了生成传统 String 拼接序列的代码,上述不支持 invokedynamic 的平台可以使用这种回退方案。

兼容性:其他编译器。 其他(非 javac)编译器是否应该生成相同的字节码?我们是否建议更改所有编译器实现以使用新的翻译策略?由于传统的 String 拼接不会消失,其他编译器可以自由地继续生成它们当前的字节码序列。他们可以根据需要逐步将部分翻译转换为基于 indy 的翻译方案。

兼容性:字节码操作器/编织器。 工具需要能够在它们不习惯的地方处理新的 indys,即使没有源代码更改,仅仅因为重新编译的缘故。我们认为这不是一个主要问题,因为工具预计已经能够处理 Lambdas,因此它们需要识别 invokedynamic。对于那些曾经对 javac 生成的 StringBuilder::append 链进行插桩的工具,需要进行修正。

静态占用空间。 在大量使用 String 拼接的代码中,类文件的大小可能会成为一个问题。目前存在一些机器生成的文件,它们几乎用尽了常量池(CP)中的所有条目,并进行了大量的字符串拼接操作。看起来,对于内联化的 String 拼接,方法字节码的占用空间要小得多,但常量池中会因 java.lang.invoke 机制而增加额外的静态开销,而且每种拼接形态还会带来一些额外的开销。更多数据请参见上面的说明。

启动开销。 存在一个风险,即启动开销可能会过高,从而阻碍这项工作的推进。Lambda 表达式的实现引入了这类转换策略,但只有在使用 Lambda 时才需要付出代价。而对于字符串拼接,一旦 invokedynamic 被编译到字节码中,现有代码中对 + 的使用将无法避免这一开销(除非修改代码以避免使用 + 并显式调用 StringBuilder)。这种风险与 invokedynamic 机制的初始化有关(参见 JDK-8086045),因此如果在此处接受启动性能的退化,就意味着其他需要初始化 invokedynamic 的变更不会带来额外的启动性能退化,尤其是 模块系统 (JEP 261)。反之,如果模块系统在此变更之前落地,那么启动成本将会被分摊。

依赖