JDK 25中字符串变得更快


在JDK 25中,我们改进了类String的性能,使得String::hashCode函数大部分是常量可折叠的。例如,如果您使用Strings作为静态不可修改Map中的键,您可能会看到显着的性能改进。

例如
下面是一个相对高级的例子,我们维护了一个不可变的本地调用Map,它的键key是方法调用的名称,值是一个可用于调用相关系统调用的MethodHandle:

// Set up an immutable Map of system calls
static final Map<String, MethodHandle> SYSTEM_CALLS = Map.of(
        “malloc”, linker.downcallHandle(mallocSymbol,…),
        “free”, linker.downcallHandle(freeSymbol…),
        ...);



// Allocate a memory region of 16 bytes
long address = SYSTEM_CALLS.get(“malloc”).invokeExact(16L);

// Free the memory region
SYSTEM_CALLS.get(“free”).invokeExact(address);

该方法接受一个符号和其他参数,通过JDK 22 中引入的外部函数和内存 APIlinker.downcallHandle(…)将原生调用绑定到 Java 中。这是一个相对较慢的过程,并且涉及字节码的旋转。然而,一旦进入,仅类中的新性能改进就允许对键查找和值进行常量折叠,从而将性能提高 8 倍以上:MethodHandleMapString

--- JDK 24 ---

Benchmark                     Mode  Cnt  Score   Error  Units
StringHashCodeStatic.nonZero  avgt   15  4.632 ± 0.042  ns/op

--- JDK 25 ---

Benchmark                     Mode  Cnt  Score   Error  Units
StringHashCodeStatic.nonZero  avgt   15  0.571 ± 0.012  ns/op

注意:上述基准测试使用的不是a,malloc() MethodHandle而是恒int 等函数。毕竟,我们测试的不是a的性能,malloc()而是实际的String查找和MethodHandle性能。

这一改进将有利于任何以字符串为键的不可变 Map,其中的值(任意类型的 V)通过常量字符串查找。

它是如何工作的?
字符串首次创建时,其散列码是未知的。 在第一次调用 String::hashCode 时,会计算出实际的散列码,并将其存储在一个私有字段 String.hash 中。 这种转换听起来可能很奇怪:如果 String 是不可变的,它怎么能改变自己的状态呢? 答案是,从外部无法观察到这种突变;无论是否使用内部 String.hash 缓存字段,String 在功能上的表现都是一样的。 唯一不同的是,它在后续调用中会变得更快。

简单来说:
当你第一次创建一个字符串时,系统还不知道它的"身份证号码"(散列码)。只有当你第一次查看这个身份证(调用hashCode方法)时,系统才会当场计算并把这个号码记在小本本上(存在hash字段里)。
你可能会问:不是说字符串是不能变的吗?怎么还能往里面存东西呢?其实这个变化是偷偷进行的:

  • 外人根本发现不了这个变化
  • 不管记不记这个号码,字符串用起来完全一样
  • 唯一的区别就是记下来之后,下次再查身份证就特别快
就像你的书包(字符串)本身不能变,但是可以在里面偷偷藏个计算器(hash字段),这样下次算数学题就能直接拿出来了。

既然我们已经知道了 String::hashCode 的工作原理,我们就可以揭开其性能变化的面纱了(只有一行代码):内部字段 String.hash 被标记为 JDK 内部 @Stable 注解。 就是这样!

@Stable 告诉虚拟机,它可以读取一次字段,如果它不再是默认值(零),它就可以相信该字段不会再发生变化。 因此,它可以恒定折叠 String::hashcode 操作,并用已知哈希值替换调用。

事实证明,不可变 Map 中的字段和 MethodHandle 的内部结构也以同样的方式受到信任。

这意味着虚拟机可以对整个操作链进行恒定折叠:

  • 计算字符串 "malloc "的散列代码(始终为-1081483544)
  • 探测不可变的 Map(即计算内部数组索引,该索引始终与 malloc 散列代码相同)
  • 检索相关的 MethodHandle(始终位于所述计算索引上)
  • 解析实际的本地调用(始终为本地 malloc() 调用)
实际上,这意味着可以直接调用本地 malloc() 方法,这就是性能大幅提升的原因。 换句话说,操作链完全短路了。

有一个不幸的角落情况,新的改进没有涵盖:
有一种不幸的情况,新的改进并没有涉及:如果字符串的哈希代码恰好为零,常量折叠将不起作用。 如上所述,常量折叠仅适用于非默认值(即 int 字段的非零值)。

不过,我们预计在不久的将来就能解决这个小问题。

你可能会认为,大约 40 亿个不同字符串中只有一个的哈希值为零,这在一般情况下可能是正确的。 然而,最常见的字符串之一(空字符串"")的哈希值为零。 另一方面,包含 1 - 6 个字符(从 (空格)到 Z 的所有字符)的字符串的哈希值都不为零。


最后一点
由于 @Stable 注解仅适用于 JDK 内部代码,因此您不能在 Java 应用程序中直接使用它。 不过,我们正在开发一个新的 JEP,名为 JEP 502:稳定值(预览版),它将提供一些构造,允许用户代码以类似的方式间接受益于 @Stable 字段。