从字符串拼接的底层原理一窥invokedynamic设计哲学

JDK 9+优化字符串拼接,通过invokedynamic指令调用StringConcatFactory,避免StringBuilder的扩容和编码转换开销,提升性能。新策略预计算长度和编码,一次性分配字节数组,减少复制,支持无缝升级优化。

文章目录

引子:JDK 8之后的字符串优化

从发布JDK 9开始,OpenJDK社区陆续引入了一些对字符串的优化提案,典型的有以下两个:

  • JEP 254: 紧凑字符串String类底层改用byte[]代替原先的char[]char占用2字节),并用coder字段储存String实例的编码(0:Latin-1,1:UTF16),在字符串只包含Latin-1编码的字符时相比以前可以节约一半内存
  • JEP 280: 字符串拼接的优化:将字符串拼接的底层实现,由编译期生成的StringBuilder调用,改为运行时基于invokedynamic指令的动态连接策略,通过StringConcatFactory提供灵活且高效的实现

上一篇博文讲了Lambda表达式是如何用invokedynamic指令来实现的,这次就来探究JDK 8之后,字符串拼接是如何通过invokedynamic指令来实现更好的性能。

查看字符串拼接的字节码实现

按照惯例,先写个例子,通过反编译查看一下字符串拼接的字节码实现(本文所有代码基于Eclipse Temurin JDK 25.0.0+36)。

public class StringConcatExample {
static final String a = "Hello";
public static void main(String[] args) {
int b = 42;
double c = Math.random();
UUID uuid = UUID.randomUUID();
String result = a + " " + b + " " + c + " " + uuid;
System.out.println(result);
}
}

通过IDEA查看字节码,拼接字符串部分的字节码如下(部分省略):

public static main([Ljava/lang/String;)V
L0
LINENUMBER 12 L0
BIPUSH 42
ISTORE 1
L1
LINENUMBER 13 L1
INVOKESTATIC java/lang/Math.random ()D
DSTORE 2
L2
LINENUMBER 14 L2
INVOKESTATIC java/util/UUID.randomUUID ()Ljava/util/UUID;
ASTORE 4
L3
LINENUMBER 15 L3
ILOAD 1
DLOAD 2
ALOAD 4
INVOKESTATIC java/lang/String.valueOf (Ljava/lang/Object;)Ljava/lang/String;
INVOKEDYNAMIC makeConcatWithConstants(IDLjava/lang/String;)Ljava/lang/String; [
// handle kind 0x6 : 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;
// arguments:
"Hello \u0001 \u0001 \u0001"
]
ASTORE 5
L4
...

通过字节码,我们发现字符串拼接本质上通过invokedynamic指令(下文简称indy)调用了StringConcatFactory#makeConcatWithConstants方法生成了一个调用点,通过该调用点来完成拼接逻辑。

性能提升多少?Benchmark来说话

不急着探究实现原理,先通过基准测试来看看这个实现相比StringBuilder性能提升了多少。基于紧凑字符串的特点,笔者设计了几个基准测试,代码如下:

点击展开Benchmark代码
@State(Scope.Thread)
@BenchmarkMode(Mode.AverageTime)
@Warmup(iterations = 5, time = 1, timeUnit = SECONDS)
@Measurement(iterations = 5, time = 1, timeUnit = SECONDS)
@Threads(4)
@Fork(2)
@OutputTimeUnit(NANOSECONDS)
public class StringConcatBenchmark {
private static double RANDOM_NUM = Math.random();
private static int NUM = 114514;
private static String RAND_UUID = UUID.randomUUID().toString();
private static String CLASS = StringConcatBenchmark.class.toString();
private static String STR = "some constant string";
@Benchmark
public String sm_latin_stringBuilder() {
return new StringBuilder()
.append("ActionID=[")
.append(NUM)
.append("]")
.toString();
}
@Benchmark
public String sm_latin_indy() {
return "ActionID=[" +
NUM +
"]";
}
@Benchmark
public String sm_utf8_stringBuilder() {
return new StringBuilder()
.append("Action=[")
.append(NUM)
.append("]结束")
.toString();
}
@Benchmark
public String sm_utf8_indy() {
return "Action=[" +
NUM +
"]结束";
}
@Benchmark
public String md_latin_stringBuilder() {
return new StringBuilder()
.append("generate a random number: ")
.append(RANDOM_NUM)
.append(", and a UUID: ")
.append(RAND_UUID)
.append(". number is ")
.append(NUM)
.append(". class is [")
.append(CLASS)
.append("]")
.toString();
}
@Benchmark
public String md_latin_indy() {
return "generate a random number: " +
RANDOM_NUM +
", and a UUID: " +
RAND_UUID +
". number is " +
NUM +
". class is [" +
CLASS +
"]";
}
@Benchmark
public String md_utf8_stringBuilder() {
return new StringBuilder()
.append("generate a random number: ")
.append(RANDOM_NUM)
.append(", and a UUID: ")
.append(RAND_UUID)
.append(". number is ")
.append(NUM)
.append(". 类名为 [")
.append(CLASS)
.append("]")
.toString();
}
@Benchmark
public String md_utf8_indy() {
return "generate a random number: " +
RANDOM_NUM +
", and a UUID: " +
RAND_UUID +
". number is " +
NUM +
". 类名为 [" +
CLASS +
"]";
}
@Benchmark
public String lg_latin_stringBuilder() {
return new StringBuilder()
.append("generate a random number: ")
.append(RANDOM_NUM)
.append(", and a random number: ")
.append(RANDOM_NUM)
.append(", and a UUID: ")
.append(RAND_UUID)
.append(", and a UUID: ")
.append(RAND_UUID)
.append(". number is ")
.append(NUM)
.append(". class is [")
.append(CLASS)
.append(". number is ")
.append(NUM)
.append(". class is [")
.append(CLASS)
.append("].")
.toString();
}
@Benchmark
public String lg_latin_indy() {
return "generate a random number: " +
RANDOM_NUM +
", and a random number: " +
RANDOM_NUM +
", and a UUID: " +
RAND_UUID +
", and a UUID: " +
RAND_UUID +
". number is " +
NUM +
". class is [" +
CLASS +
". number is " +
NUM +
". class is [" +
CLASS +
"].";
}
@Benchmark
public String lg_utf8_stringBuilder() {
return new StringBuilder()
.append("generate a random number: ")
.append(RANDOM_NUM)
.append(", and a random number: ")
.append(RANDOM_NUM)
.append(", and a UUID: ")
.append(RAND_UUID)
.append(", and a UUID: ")
.append(RAND_UUID)
.append(". number is ")
.append(NUM)
.append(". class is [")
.append(CLASS)
.append(". number is ")
.append(NUM)
.append(". 类名为 is [")
.append(CLASS)
.append("].")
.toString();
}
@Benchmark
public String lg_utf8_indy() {
return "generate a random number: " +
RANDOM_NUM +
", and a random number: " +
RANDOM_NUM +
", and a UUID: " +
RAND_UUID +
", and a UUID: " +
RAND_UUID +
". number is " +
NUM +
". class is [" +
CLASS +
". number is " +
NUM +
". 类名为 is [" +
CLASS +
"].";
}
}

测试结果如下:

Benchmark Mode Cnt Score Error Units
StringConcatBenchmark.lg_latin_indy avgt 10 104.612 ± 2.514 ns/op
StringConcatBenchmark.lg_latin_stringBuilder avgt 10 212.535 ± 0.757 ns/op
StringConcatBenchmark.lg_utf8_indy avgt 10 125.950 ± 0.952 ns/op
StringConcatBenchmark.lg_utf8_stringBuilder avgt 10 400.166 ± 5.206 ns/op
StringConcatBenchmark.md_latin_indy avgt 10 53.726 ± 0.254 ns/op
StringConcatBenchmark.md_latin_stringBuilder avgt 10 111.190 ± 0.372 ns/op
StringConcatBenchmark.md_utf8_indy avgt 10 63.433 ± 0.177 ns/op
StringConcatBenchmark.md_utf8_stringBuilder avgt 10 212.225 ± 0.709 ns/op
StringConcatBenchmark.sm_latin_indy avgt 10 8.785 ± 0.242 ns/op
StringConcatBenchmark.sm_latin_stringBuilder avgt 10 9.687 ± 1.253 ns/op
StringConcatBenchmark.sm_utf8_indy avgt 10 10.169 ± 0.044 ns/op
StringConcatBenchmark.sm_utf8_stringBuilder avgt 10 10.470 ± 0.086 ns/op

可以看到,随着字符串长度和参数量的提升,并考虑可能存在的编码转换带来的开销,性能差距会更加明显。

那么问题来了,StringConcatFactory#makeConcatWithConstants的性能为什么比StringBuilder快?

StringBuilder有哪些额外开销

通过查看StringBuilder的源码可以得知,其存在以下几种额外开销:

  • StringBuilder面对的是拼接内容未知的场景,其底层的字节数组在拼接过程中可能触发动态扩容,扩容时存在复制数组的开销;
  • 当拼接内容导致编码从Latin-1升级到UTF16时(如:在英文字符串后追加一个中文字符),会触发内部的inflate操作。这个过程不仅需要分配一个两倍于原数组大小的新字节数组,还需要将旧数组中的全部内容进行转码和拷贝,这是一个不小的开销。
  • 每次调用toString时,StringBuilder需要将字节数组复制到String实例中,这也会带来一次复制数组的开销。

StringConcatFactory是如何解决这些开销的

StringConcatFactory#makeConcatWithConstants生成的调用点从实现上就避免了StringBuilder的这几个开销!这是如何做到的呢?下面就通过源码解析来一步步揭开其面纱。

首先来看看makeConcatWithConstants方法做了哪些工作:

public static CallSite makeConcatWithConstants(MethodHandles.Lookup lookup,
String name,
MethodType concatType,
String recipe,
Object... constants)
throws StringConcatException
{
// 一些参数校验
Objects.requireNonNull(lookup, "Lookup is null");
Objects.requireNonNull(name, "Name is null");
Objects.requireNonNull(recipe, "Recipe is null");
Objects.requireNonNull(concatType, "Concat type is null");
Objects.requireNonNull(constants, "Constants are null");
for (Object o : constants) {
Objects.requireNonNull(o, "Cannot accept null constants");
}
if ((lookup.lookupModes() & MethodHandles.Lookup.PRIVATE) == 0) {
throw new StringConcatException("Invalid caller: " +
lookup.lookupClass().getName());
}
// javac会将拼接表达式编译为一个recipe(我更习惯翻译为模板)
// 其中非常量参数会用\u0001作为占位符
// 例1 "Hello " + a + " world! " + b 会编译为recipe: "Hello \u0001 world! \u0001"
// 如果常量字符串包含了\u0001或\u0002,则会用\u0002作为占位符,并将这部分字符串作为constants参数传入
// 例2 "Is\u0001\u0002 " + a + "!" 会编译为recipe: "\u0002\u0001!" 和constants: "Is\u0001\u0002 "
// 接着recipe和constants会传入parseRecipe方法,拼接好常量部分后按照参数占位符\u0001分割为字符串数组
// 如上述两个例子会解析为
// 例1 {"Hello ", " world! ", ""}
// 例2 {"Is\u0001\u0002 ", "!"}
String[] constantStrings = parseRecipe(concatType, recipe, constants);
if (!concatType.returnType().isAssignableFrom(String.class)) {
throw new StringConcatException(
"The return type should be compatible with String, but it is " +
concatType.returnType());
}
// 限制了字符串拼接的参数量最多为200个(由于indy指令的最终调用点最多支持253个参数)
// 实际上,如果一个字符串拼接中超过了200个参数,javac会将这个大的字符串拼接拆分成多个小的拼接(多条indy指令),
// 最终再通过另一个字符串拼接组合起来
if (concatType.parameterSlotCount() > MAX_INDY_CONCAT_ARG_SLOTS) {
throw new StringConcatException("Too many concat argument slots: " +
concatType.parameterSlotCount() +
", can only accept " +
MAX_INDY_CONCAT_ARG_SLOTS);
}
// 拼接逻辑实现,共有3个不同的策略,当上一个策略不适用时会返回null,fallback到下一个策略
try {
// 适用于0~2个参数的简单拼接策略
MethodHandle mh = makeSimpleConcat(concatType, constantStrings);
// 使用方法句柄的实现策略,需要拼接参数量小于JVM参数`java.lang.invoke.StringConcat.highArityThreshold`
if (mh == null && concatType.parameterCount() <= HIGH_ARITY_THRESHOLD) {
mh = generateMHInlineCopy(concatType, constantStrings);
}
// 以及使用内部隐藏类的实现策略
if (mh == null) {
mh = InlineHiddenClassStrategy.generate(lookup, concatType, constantStrings);
}
mh = mh.viewAsType(concatType, true);
return new ConstantCallSite(mh);
} catch (Error e) {
// Pass through any error
throw e;
} catch (Throwable t) {
throw new StringConcatException("Generator failed", t);
}
}

在做了一系列校验之后,该方法使用了三种实现策略来生成拼接逻辑。

三大实现策略

预置简单拼接逻辑

首先来看看makeSimpleConcat,该实现适用于0~2个参数(包含模板数组中非空字符串数量)的拼接:

private static MethodHandle makeSimpleConcat(MethodType mt, String[] constants) {
int paramCount = mt.parameterCount();
String suffix = constants[paramCount];
if (paramCount == 0) {
// 适用于没有参数的情况(这种情况下应该被编译器直接编译成字面量了,进不到这里)
// newStringifier() 返回的方法句柄指向 java.lang.StringConcatHelper#newStringOf 方法
return MethodHandles.insertArguments(newStringifier(), 0, suffix == null ? "" : suffix);
}
if (paramCount == 1) {
String prefix = constants[0];
if (prefix.isEmpty()) {
if (suffix.isEmpty()) {
// 只有一个参数 且 模板为空,举个例子:
// String str = "" + UUID.randomUUID();
return unaryConcat(mt.parameterType(0));
} else if (!mt.hasPrimitives()) {
// 这里和下面的else if条件属于同一种情况
// 只有一个参数 且 参数中没有基本类型 且 模板前后缀只有一个不为空,视为两个参数的拼接,举个例子:
// String str = "abc" + UUID.randomUUID();
return MethodHandles.insertArguments(simpleConcat(), 1, suffix);
}
} else if (suffix.isEmpty() && !mt.hasPrimitives()) {
return MethodHandles.insertArguments(simpleConcat(), 0, prefix);
}
} else if (paramCount == 2 && !mt.hasPrimitives() && suffix.isEmpty()
&& constants[0].isEmpty() && constants[1].isEmpty()) {
// 只有两个参数 且 参数中没有基本类型 且模板为空,举个例子:
// String str = "" + UUID.randomUUID() + UUID.randomUUID()
return simpleConcat();
}
// 以上场景都不适用,则返回null,交由其他策略处理
return null;
}
// 对应无参数时的拼接实现
private static MethodHandle newStringifier() {
MethodHandle mh = NEW_STRINGIFIER;
if (mh == null) {
// JLA为JavaLangAccess接口的实例,其作用是为java.lang包之外的
// 其他JDK内部类提供访问java.lang包中package-private方法的能力
// 这里最终指向 java.lang.StringConcatHelper#newStringOf 方法
NEW_STRINGIFIER = mh = JLA.stringConcatHelper("newStringOf",
methodType(String.class, Object.class));
}
return mh;
}
// 对应1个参数时的拼接实现
private static MethodHandle unaryConcat(Class<?> cl) {
// 不是基本类型,则使用无参数时的实现
if (!cl.isPrimitive()) {
return newStringifier();
}
// 对所有基本类型做了特殊实现
// 最终指向String#valueOf的各个重载
else if (cl == int.class || cl == short.class || cl == byte.class) {
return intStringifier();
} else if (cl == long.class) {
return longStringifier();
} else if (cl == char.class) {
return charStringifier();
} else if (cl == boolean.class) {
return booleanStringifier();
} else if (cl == float.class) {
return floatStringifier();
} else if (cl == double.class) {
return doubleStringifier();
} else {
throw new InternalError("Unhandled type for unary concatenation: " + cl);
}
}
// 对应2个参数时的拼接实现
private static MethodHandle simpleConcat() {
MethodHandle mh = SIMPLE_CONCAT;
if (mh == null) {
// 最终指向 java.lang.StringConcatHelper#simpleConcat 方法
MethodHandle simpleConcat = JLA.stringConcatHelper("simpleConcat",
methodType(String.class, Object.class, Object.class));
SIMPLE_CONCAT = mh = simpleConcat.rebind();
}
return mh;
}

可以看到,通过StringConcatHelper类中的静态方法,makeSimpleConcat方法能够覆盖了0~2个参数下的绝大部分场景。

方法句柄组合调用树

generateMHInlineCopy为JDK 24之前的默认实现,该策略使用多层方法句柄组合成调用树的方式生成拼接逻辑。拼接逻辑和下面要讲的内部隐藏类策略基本相同,但由于复杂的拼接场景下需要生成大量的内部中间类,故其启动性能并不高,极端情况下C2编译器需要高达2G的内存来编译字符串拼接。因此在JDK23时,添加了JVM参数java.lang.invoke.StringConcat.highArityThreshold,默认值为20,超过这个数值时,转为通过StringBuilder实现拼接。

由于该策略在JDK 24中被内部隐藏类策略所代替,故此处不详述其原理,有兴趣的读者可以自行了解。

内部隐藏类

在JDK 24中,上文提到的java.lang.invoke.StringConcat.highArityThreshold参数的默认值被改为了0,相当于禁用了方法句柄策略。而这个版本引入了新的策略InlineHiddenClassStrategy#generate(由fastjson作者温少贡献)。

方法句柄策略在参数量多的复杂拼接场景下,会生成大量中间类,且启动速度较慢,这里编写了一个Benchmark,测试了不同情况下内部隐藏类策略、方法句柄策略、StringBuilder实现的首次运行时间,结果如下。可以看到方法句柄策略下的启动时间相比内部隐藏类策略确实慢了不少:

Benchmark Mode Cnt Score Error Units
StringConcatBenchmark.lg_latin_indy_innerClass ss 2 2027.700 us/op
StringConcatBenchmark.lg_latin_indy_methodHandle ss 2 10013.550 us/op
StringConcatBenchmark.lg_latin_stringBuilder ss 2 75.300 us/op
StringConcatBenchmark.lg_utf8_indy_innerClass ss 2 1633.050 us/op
StringConcatBenchmark.lg_utf8_indy_methodHandle ss 2 9125.450 us/op
StringConcatBenchmark.lg_utf8_stringBuilder ss 2 111.550 us/op
StringConcatBenchmark.md_latin_indy_innerClass ss 2 1391.700 us/op
StringConcatBenchmark.md_latin_indy_methodHandle ss 2 6603.350 us/op
StringConcatBenchmark.md_latin_stringBuilder ss 2 69.850 us/op
StringConcatBenchmark.md_utf8_indy_innerClass ss 2 1595.300 us/op
StringConcatBenchmark.md_utf8_indy_methodHandle ss 2 6394.100 us/op
StringConcatBenchmark.md_utf8_stringBuilder ss 2 110.300 us/op
StringConcatBenchmark.sm_latin_indy_innerClass ss 2 489.000 us/op
StringConcatBenchmark.sm_latin_indy_methodHandle ss 2 2041.550 us/op
StringConcatBenchmark.sm_latin_stringBuilder ss 2 45.550 us/op
StringConcatBenchmark.sm_utf8_indy_innerClass ss 2 401.000 us/op
StringConcatBenchmark.sm_utf8_indy_methodHandle ss 2 2424.400 us/op
StringConcatBenchmark.sm_utf8_stringBuilder ss 2 32.700 us/op

内部隐藏类策略则通过生成一个隐藏类来完成所需的拼接逻辑。我们可以通过添加JVM参数java.lang.invoke.StringConcatFactory.dump来查看该策略生成的类。回到最初的例子,生成的隐藏类如下(做了一些标注方便理解):

package java.lang;
import jdk.internal.vm.annotation.ForceInline;
// $FF: synthetic class
final class String$$StringConcat extends StringConcatHelper.StringConcatBase {
String$$StringConcat(String[] var1) {
// 传入字符串模板按参数位置分割后的模板数组
// 同时计算出coder(模板的编码)、length(模板长度)、constants(模板数组)
// 该例子中传入的模板数组为{"Hello ", " ", " ", ""}
super(var1);
}
@ForceInline
private static int length(int len, int argInt, String strDouble, String strUUID) {
// StringConcatHelper.stringSize用于计算对应参数的字符串长度
// 该方法对浮点数之外的4种基础类型以及String都做了对应重载
// 针对基础类型的字符串长度计算原理也十分简单:
// 对于char类型,其长度固定为1
// 对于boolean类型,true为4、false为5
// 对于int和long,通过计算其十进制值有多少位,值为负数则再加上负号的1长度,便能得到其字符串长度
return StringConcatHelper.stringSize(StringConcatHelper.stringSize(StringConcatHelper.stringSize(len, argInt), strDouble), strUUID);
}
@ForceInline
private static int prepend(int index, byte coder, byte[] bytes, String[] constantStrs, int argInt, String strDouble, String strUUID) {
// 从后到前将模板和参数填入byte数组中对应位置,完成拼接
// 由于模板数组的长度在编译时就是确定的,故直接引用constantStrs的对应下标
return StringConcatHelper.prepend(
StringConcatHelper.prepend(
StringConcatHelper.prepend(index, coder, bytes, strUUID, constantStrs[2]),
coder, bytes, strDouble, constantStrs[1]),
coder, bytes, argInt, constantStrs[0]
);
}
@ForceInline
final String concat(int argInt, double argDouble, Object argUUID) {
// 先把浮点数和Object类型的变量转为字符串
String strDouble = StringConcatHelper.stringOf(argDouble);
String strUUID = StringConcatHelper.stringOf(argUUID);
// 计算出最终的字符串编码(参数中的int和double显然都是Latin-1编码,故不用计算)
int coder = coder(this.coder, strUUID);
String[] constantStrs; // 模板数组
String constantStrSuffix; // 模板后缀(模板数组的最后一个成员)
// 计算出最终字符串的长度(不包括模板的末尾)
int lenWithoutSuffix = length(this.length, argInt, strDouble, strUUID) - (constantStrSuffix = (constantStrs = this.constants)[3]).length();
// 一次性分配好所需长度的byte数组(传入的constantStrSuffix会一并计算长度,并放到对应位置)
byte[] bytes = StringConcatHelper.newArrayWithSuffix(constantStrSuffix, lenWithoutSuffix, (byte)coder);
// 完成拼接
prepend(lenWithoutSuffix, (byte)coder, bytes, constantStrs, argInt, strDouble, strUUID);
// 调用String类的package-private构造函数,直接传入字节数组和编码,避免了复制开销
return new String(bytes, (byte)coder);
}
@ForceInline
private static int coder(int var0, String var1) {
// 计算编码,简单的位运算逻辑
return var0 | var1.coder();
}
}

至此,相信读者能了解到字符串拼接的工作原理。

性能更优的原因

由于字符串拼接在编译期就可以确定拼接边界的特点,带来了以下几个优势:

  • 遍历参数和模板字符串,提前确定编码,避免编码变更带来的开销;
  • 拼接后的字符串长度可以提前确定,一次性分配好所需长度的byte数组,避免扩容带来的开销;
  • 通过String类的package-private构造方法,直接将拼接后的byte数组传入String,避免复制带来的开销。

总结

通过对字符串拼接的原理探究,不仅仅了解了JDK8以后字符串加号拼接相比StringBuilder快的原因,还能从中一窥indy指令的设计哲学,还记得上篇文章中的疑问吗:

为什么要用复杂的indy机制来实现这些功能?

当时针对Lambda表达式的场景,我得出了一个结论:indy+LambdaMetafactory机制可以实现单例复用,极大地提升了性能,减少了不必要的GC压力。

而如今回望JDK 9~JDK 24中字符串拼接的发展历程,我们又可以得出另一个结论:随着JDK的升级,一些功能可能得到一系列的优化,而借助indy机制,升级前后对应的字节码没有改变,源代码无需重新编译,只需使用新版本JRE运行,即可享受到JDK升级带来的性能优化!