在JDK 25中,我们改进了类String的性能,使得String::hashCode函数大部分是常量可折叠的。例如,如果您使用Strings作为静态不可修改Map中的键,您可能会看到显着的性能改进。
例如
下面是一个相对高级的例子,我们维护了一个不可变的本地调用Map,它的键key是方法调用的名称,值是一个可用于调用相关系统调用的MethodHandle:
// Set up an immutable Map of system calls |
该方法接受一个符号和其他参数,通过JDK 22 中引入的外部函数和内存 APIlinker.downcallHandle(…)将原生调用绑定到 Java 中。这是一个相对较慢的过程,并且涉及字节码的旋转。然而,一旦进入,仅类中的新性能改进就允许对键查找和值进行常量折叠,从而将性能提高 8 倍以上:MethodHandleMapString
--- JDK 24 --- |
注意:上述基准测试使用的不是a,malloc() MethodHandle而是恒int 等函数。毕竟,我们测试的不是a的性能,malloc()而是实际的String查找和MethodHandle性能。
这一改进将有利于任何以字符串为键的不可变 Map
它是如何工作的?
字符串首次创建时,其散列码是未知的。 在第一次调用 String::hashCode 时,会计算出实际的散列码,并将其存储在一个私有字段 String.hash 中。 这种转换听起来可能很奇怪:如果 String 是不可变的,它怎么能改变自己的状态呢? 答案是,从外部无法观察到这种突变;无论是否使用内部 String.hash 缓存字段,String 在功能上的表现都是一样的。 唯一不同的是,它在后续调用中会变得更快。
简单来说:
当你第一次创建一个字符串时,系统还不知道它的"身份证号码"(散列码)。只有当你第一次查看这个身份证(调用hashCode方法)时,系统才会当场计算并把这个号码记在小本本上(存在hash字段里)。
你可能会问:不是说字符串是不能变的吗?怎么还能往里面存东西呢?其实这个变化是偷偷进行的:
- 外人根本发现不了这个变化
- 不管记不记这个号码,字符串用起来完全一样
- 唯一的区别就是记下来之后,下次再查身份证就特别快
既然我们已经知道了 String::hashCode 的工作原理,我们就可以揭开其性能变化的面纱了(只有一行代码):内部字段 String.hash 被标记为 JDK 内部 @Stable 注解。 就是这样!
@Stable 告诉虚拟机,它可以读取一次字段,如果它不再是默认值(零),它就可以相信该字段不会再发生变化。 因此,它可以恒定折叠 String::hashcode 操作,并用已知哈希值替换调用。
事实证明,不可变 Map 中的字段和 MethodHandle 的内部结构也以同样的方式受到信任。
这意味着虚拟机可以对整个操作链进行恒定折叠:
- 计算字符串 "malloc "的散列代码(始终为-1081483544)
- 探测不可变的 Map(即计算内部数组索引,该索引始终与 malloc 散列代码相同)
- 检索相关的 MethodHandle(始终位于所述计算索引上)
- 解析实际的本地调用(始终为本地 malloc() 调用)
有一个不幸的角落情况,新的改进没有涵盖:
有一种不幸的情况,新的改进并没有涉及:如果字符串的哈希代码恰好为零,常量折叠将不起作用。 如上所述,常量折叠仅适用于非默认值(即 int 字段的非零值)。
不过,我们预计在不久的将来就能解决这个小问题。
你可能会认为,大约 40 亿个不同字符串中只有一个的哈希值为零,这在一般情况下可能是正确的。 然而,最常见的字符串之一(空字符串"")的哈希值为零。 另一方面,包含 1 - 6 个字符(从 (空格)到 Z 的所有字符)的字符串的哈希值都不为零。
最后一点
由于 @Stable 注解仅适用于 JDK 内部代码,因此您不能在 Java 应用程序中直接使用它。 不过,我们正在开发一个新的 JEP,名为 JEP 502:稳定值(预览版),它将提供一些构造,允许用户代码以类似的方式间接受益于 @Stable 字段。