1. 概述
JVM 和 Java 编译器一直在默默优化各种细节,哪怕是最不起眼的操作也不会放过。字符串拼接就是其中一个被深度优化的场景。
本文将深入剖析 Java 9 引入的一项重要优化:基于 invokedynamic
的字符串拼接机制。这不仅是性能的提升,更体现了 JVM 在运行时动态优化上的强大能力。
2. Java 9 之前的实现方式
在 Java 9 之前,非字面量的字符串拼接(比如变量拼接)底层是通过 StringBuilder
实现的。来看一个简单的例子:
String concat(String s, int i) {
return s + i;
}
使用 javap -c
查看其字节码:
java.lang.String concat(java.lang.String, int);
Code:
0: new #2 // class StringBuilder
3: dup
4: invokespecial #3 // Method StringBuilder."<init>":()V
7: aload_0
8: invokevirtual #4 // Method StringBuilder.append:(LString;)LStringBuilder;
11: iload_1
12: invokevirtual #5 // Method StringBuilder.append:(I)LStringBuilder;
15: invokevirtual #6 // Method StringBuilder.toString:()LString;
✅ 关键点:
- 编译器自动将
+
操作符翻译为StringBuilder
的创建、追加和toString()
调用。 - 这种方式本身已经相当高效,是经过充分验证的工程实践。
但这种方式的局限性在于:优化策略固化在字节码中,一旦类文件生成,底层拼接逻辑就无法再改变。Java 9 的 invokedynamic
方案正是为了解决这个痛点。
3. invokedynamic 机制
从 Java 9 开始(作为 JEP 280 的一部分),字符串拼接改用 invokedynamic
指令实现。
3.1 核心思想
⚠️ 核心动机:实现“运行时可变”的拼接策略。
这意味着,JVM 可以在不修改字节码的前提下,动态替换底层的拼接实现。即使你的应用是用旧版本 JDK 编译的,只要运行在新版本 JVM 上,也能自动享受到最新的优化策略,无需重新编译。
✅ 其他优势:
- 字节码更简洁、更小。
- 减少了对特定 API(如
StringBuilder
)的硬编码依赖,更具灵活性。
3.2 整体流程
我们可以把一次字符串拼接看作一个函数调用:输入参数(字符串、整数等),输出结果(拼接后的字符串)。
新的 invokedynamic
机制工作流程如下:
- 定义函数签名:例如
(String, int) -> String
。 - 准备实际参数:比如拼接
"The answer is "
和42
。 - 调用 Bootstrap Method:JVM 在首次执行
invokedynamic
指令时,会调用一个预定义的“引导方法”(Bootstrap Method),传入函数签名、参数和一些元信息。 - 生成并链接实现:引导方法根据签名和参数,动态生成一个最优的拼接逻辑,并封装在一个
MethodHandle
中。 - 执行拼接:后续调用直接通过
MethodHandle
执行生成的逻辑,得到最终字符串。
📌 一句话总结:编译时只生成“规范”,运行时才“链接”具体实现。 这种解耦是 invokedynamic
的精髓所在。
4. 字节码与链接机制
让我们看看 Java 9+ 编译器为同一个 concat
方法生成的字节码:
java.lang.String concat(java.lang.String, int);
Code:
0: aload_0
1: iload_1
2: invokedynamic #7, 0 // InvokeDynamic #0:makeConcatWithConstants:(LString;I)LString;
7: areturn
✅ 对比 Java 8:
- ❌ 旧版:7 条指令,明确创建
StringBuilder
并调用其方法。 - ✅ 新版:仅 3 条指令,核心逻辑被一个
invokedynamic
指令替代,简洁粗暴。
这里的 (LString;I)LString
是方法描述符,表示接受一个 String
和一个 int
,返回一个 String
。
4.1 Bootstrap Method 表
使用 javap -c -v
查看更详细的信息,会发现一个 BootstrapMethods
表:
BootstrapMethods:
0: #25 REF_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:
#31 \u0001\u0001
- **
makeConcatWithConstants
**:这是StringConcatFactory
类中的一个静态方法,作为引导方法。 - 当 JVM 首次遇到这个
invokedynamic
指令时,就会调用此引导方法。 - 引导方法会返回一个
ConstantCallSite
,它内部持有一个指向实际拼接逻辑的MethodHandle
。 - 后续所有对该
invokedynamic
的调用,都会直接跳转到这个MethodHandle
指向的代码,效率极高。
4.2 关键参数
引导方法接收几个关键参数:
MethodType
:即(LString;I)LString
,描述了拼接操作的签名。\\u0001\\u0001
:这是一个“配方”(recipe),定义了字符串的结构。
5. 配方(Recipe)机制
配方是 invokedynamic
拼接的核心优化之一,它区分了字符串中的常量部分和变量部分。
5.1 示例分析
考虑一个 Person
类:
public class Person {
private String firstName;
private String lastName;
// constructor
@Override
public String toString() {
return "Person{" +
"firstName='" + firstName + '\'' +
", lastName='" + lastName + '\'' +
'}';
}
}
其字节码为:
0: aload_0
1: getfield #7 // Field firstName:LString;
4: aload_0
5: getfield #13 // Field lastName:LString;
8: invokedynamic #16, 0 // InvokeDynamic #0:makeConcatWithConstants:(LString;LString;)L/String;
13:areturn
对应的 BootstrapMethods
表:
BootstrapMethods:
0: #28 REF_invokeStatic StringConcatFactory.makeConcatWithConstants // truncated
Method arguments:
#34 Person{firstName=\'\u0001\', lastName=\'\u0001\'} // The recipe
5.2 配方的作用
- **
\\u0001
**:这是一个占位符(placeholder),代表一个动态参数。 - **
Person{firstName='
,', lastName='
,'}
**:这些都是字符串常量(literal)。
✅ 优势:
- 大幅减少参数数量:无论字符串模板多复杂,引导方法只需要接收所有动态参数(
firstName
,lastName
)和一个配方字符串。 - 提升效率:避免了将大量常量字符串作为参数传递,减少了栈操作和方法调用开销。
可以把配方理解为一个字符串模板,其中 \\u0001
就是待填充的变量。
6. 字节码的两种“风味”(Flavors)
JVM 提供了两种生成 invokedynamic
字节码的策略,可通过编译器选项控制。
6.1 indy-with-constants(默认)
- 特点:使用
makeConcatWithConstants
引导方法和配方机制。 - 优点:字节码紧凑,参数少。
- 触发方式:Java 9+ 默认行为。
6.2 indy-only-args
- 特点:不使用配方,将字符串中的每一个部分(无论是常量还是变量)都作为独立参数传递。
- 触发方式:编译时加上
-XDstringConcat=indy
选项。
public java.lang.String toString();
Code:
0: ldc #16 // String Person{firstName=\'
2: aload_0
3: getfield #7 // Field firstName:LString;
6: bipush 39
8: ldc #18 // String , lastName=\'
10: aload_0
11: getfield #13 // Field lastName:LString;
14: bipush 39
16: bipush 125
18: invokedynamic #20, 0 // InvokeDynamic #0:makeConcat:(LString;LString;CLString;LString;CC)LString;
23: areturn
- 引导方法:
makeConcat
。 - 参数:7 个,分别对应
"Person{firstName='"
,firstName
,\'
,", lastName='"
,lastName
,\'
,}
。
⚠️ 注意:这种模式生成的字节码更长,参数更多,通常不推荐,主要用于测试或特定场景。
6.3 回到过去
你甚至可以用 -XDstringConcat=inline
选项,让编译器回到 Java 8 的 StringBuilder
方式,这在性能对比或排查问题时非常有用。
7. 拼接策略(Strategies)
引导方法最终会返回一个 MethodHandle
,这个 MethodHandle
指向的才是真正的拼接逻辑。JVM 内部有多种策略来生成这个逻辑。
7.1 六种策略
策略名称 | 描述 | 特点 |
---|---|---|
BC_SB |
字节码 - StringBuilder | 在运行时动态生成与 Java 8 完全相同的 StringBuilder 字节码,并用 Unsafe.defineAnonymousClass 加载。 |
BC_SB_SIZED |
字节码 - StringBuilder (有初始容量) | 与 BC_SB 类似,但会尝试预估 StringBuilder 的初始容量,减少扩容。 |
BC_SB_SIZED_EXACT |
字节码 - StringBuilder (精确容量) | 在生成字节码前,先将所有参数转换为字符串,精确计算出所需容量。 |
MH_SB_SIZED |
MethodHandle - StringBuilder (有初始容量) | 使用 MethodHandle 直接调用 StringBuilder API,通过 MethodHandle 链接,会预估容量。 |
MH_SB_SIZED_EXACT |
MethodHandle - StringBuilder (精确容量) | 与 MH_SB_SIZED 类似,但能精确计算容量。 |
MH_INLINE_SIZE_EXACT |
MethodHandle - 内联 (精确容量) | 默认策略。不使用 StringBuilder ,而是直接操作 byte[] 数组,自己管理内存和拼接逻辑,就像 StringBuilder 内部做的那样。 |
7.2 如何切换策略
可以通过 JVM 系统属性在运行时切换策略:
-Djava.lang.invoke.stringConcat=MH_INLINE_SIZED_EXACT
将 MH_INLINE_SIZED_EXACT
替换为上述任意策略名称即可。
8. 结论
Java 9 引入的 invokedynamic
字符串拼接机制,是一次非常成功的底层优化:
- ✅ 解耦编译时与运行时:让 JVM 能在不修改字节码的情况下升级优化策略。
- ✅ 性能提升:默认的
MH_INLINE_SIZE_EXACT
策略通过内联和精确容量计算,达到了极高的效率。 - ✅ 灵活性高:提供了多种字节码生成方式和运行时策略,方便调试和调优。
这项优化是 JVM 持续演进的缩影。对于开发者而言,虽然日常编码无需关心这些细节,但理解其背后原理,有助于我们写出更高效的代码,也能在遇到性能问题时,知道从哪个方向去排查。
延伸阅读: