JEP 280:字符串连接的索引化
概述
将 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-8043677、JDK-8076758 和 JDK-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
、基本类型和空字符串。
$ 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-8043677、JDK-8076758、JDK-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)。反之,如果模块系统在此变更之前落地,那么启动成本将会被分摊。