java 字符串变得更快了
在 JDK 25 中,我们改进了String
类的性能,使String::hashCode
函数大部分时间都是 constant foldable 的。例如,如果您在静态不可修改的 Map
中使用字符串作为键,您可能会看到性能的显著提高。
示例
下面是一个相对高级的示例,我们维护一个不可变的本地调用 Map
,其键是方法调用的名称,值是可用于调用相关系统调用的 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);
linker.downcallHandle(...)
方法使用符号和附加参数,通过 JDK 22 中引入的 Foreign Function & Memory API 将本地调用绑定到 Java MethodHandle
。这是一个相对缓慢的过程,涉及字节码的旋转。但是,一旦输入到 Map
中,仅 String
类的新性能改进就可实现键查找和值的不断折叠,从而将性能提高 8 倍以上:
--- 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
注意:上述基准不是使用 malloc()
MethodHandle
,而是使用 int
identity
函数。毕竟,我们测试的不是 malloc()
的性能,而是实际的String
查找和 MethodHandle
性能。
这一改进将使任何以字符串为键、通过常量字符串查找值(任意类型的 V)的不可变 Map<String, V>
受益。
如何工作?
首次创建字符串时,其散列码是未知的。在第一次调用 String::hashCode
时,会计算出实际的散列代码,并将其存储在私人字段 String.hash
中。这种变换听起来可能很奇怪:如果 String
是不可变的,它怎么能改变自己的状态呢?答案是,从外部无法观察到这种突变;无论是否使用内部 String.hash
缓存字段,String
在功能上的表现都是一样的。唯一不同的是,它在后续调用中会变得更快。
既然我们已经知道 String::hashCode
是如何工作的,那么我们就可以揭开其性能变化的面纱了(只需一行代码):内部字段 String.hash
被标记为 JDK 内部 @Stable
注解。就是这样!
@Stable
告诉虚拟机,它可以读取该字段一次,如果它不再是默认值(零),它可以相信该字段永远不会再改变。因此,它可以恒定折叠 String::hashcode
操作,并用已知哈希值替换调用。事实证明,不可变的 Map
中的字段和 MethodHandle
的内部结构也以同样的方式受到信任。这意味着虚拟机可以对整个操作链进行constant-fold
:
- 计算字符串 “malloc ”的哈希代码(始终为
-1081483544
) - 探测不可变的
Map
(即计算内部数组索引,该索引对于malloc
散列代码始终是相同的) - 检索相关的
MethodHandle
(始终位于上述计算索引中) - 解析实际的本地调用(始终是本地
malloc()
调用)
实际上,这意味着可以直接调用本地 malloc()
方法调用,这就是性能大幅提升的原因所在。换句话说,操作链完全短路了。
有哪些 “如果 ”和 “但是”?
有一种不幸的情况是新改进没有涵盖的:如果字符串的哈希代码恰好为零,常量折叠将不起作用。如上所述,常量折叠仅适用于非默认值(即 int
字段的非零值)。不过,我们预计在不久的将来就能解决这个小问题。您可能会认为,大约 40 亿个不同字符串中只有一个的哈希代码为零,这在一般情况下可能是正确的。然而,最常见的字符串之一(空字符串“”)的哈希值为零。另一方面,包含 1 – 6 个字符(从 ` `(空格)到 Z 的所有字符)的字符串的哈希值都不为零。
最后说明
由于 @Stable
注解仅适用于 JDK 内部代码,因此您不能在 Java 应用程序中直接使用它。不过,我们正在开发一个新的 JEP,名为 JEP 502:稳定值(预览版),它将提供允许用户代码以类似方式间接受益于 @Stable
字段的结构。
下一步是什么?
您今天就可以下载 JDK 25,看看性能的提升会给您当前的应用程序带来多大的好处、
本文文字及图片出自 Strings Just Got Faster
你也许感兴趣的:
- Oracle:为后量子密码学做准备
- JDK 24 来了!每个 Java 开发人员都必须了解的改变游戏规则的功能
- 甲骨文披露 Java 即将推出的五项新功能
- Java 24 新功能示例
- 【外评】不要把 Rust 写成 Java
- “甲骨文牌”Java正在死亡
- 您现在可以像运行 Python 一样运行 Java
- 从 Java 8 迁移到 Java 17 (二):Java 中值得注意的 API 变化
- 从 Java 8 迁移到 Java 17:新功能大汇总
- Oracle 再夺 Java 命?大公司用 Java 要小心了!
我觉得阅读 Java 的 String 类的整个改进历史很有意思。
多年来,Java 的字符串类的实现一次又一次地得到改进,提高了性能并减少了内存使用量。而我们 Java 开发人员除了更新我们使用的 JRE 之外,不需要做任何工作就能获得这些改进。
当然,所有的 “低垂果实 ”都在几年前被摘取了。如今,我确信大多数 Java 应用程序几乎无法从 String 的进一步改进中获得任何明显的改进,比如我们正在讨论的这篇文章中的改进。
当我开始从事软件开发、SDE 并很快晋升为 SRE 时,我讨厌 Java。极端的 OOP 范式让我无法理解企业类的情况。但短短几年后,我开始欣赏它,将其视为一种真正的、身经百战的生态。现在,我认为它比 Rust 和 Python 等现代趋势要好得多。
这些小众优化仍然很重要。OOP 模型允许它们以更低调的方式实现。这是在价值数十亿美元的平台背景下。通过一些基本的性能测试和 API 重放,我们每天可以节省数千美元。没有人会因此而沾沾自喜。也许周五可以吃点披萨。
在使用 Java 5 年之后,我遇到了一个让我大开眼界的时刻–通过一些聪明的同事,我发现这些额外的 “东西 ”都不是 Java 语言固有的,而是自己强加给自己的。
事实证明,你可以不用这些东西来编写 Java。没有 getters 和 setters,没有接口或依赖注入,没有单独的应用服务器(只需在 jar 中嵌入一个即可)。没有继承。确实没有 OOP(只有数据类和静态方法)。
只有简单的、类似于 C 语言的代码,以及令人惊叹的库生态系统和令人难以置信的快速奇迹–大语言模型(LLM) 和一个简单的内置类型系统(虽然现在有了大语言模型(LLM)自动补全功能,这个问题就不那么重要了),这使得代码实际上可以通过自动补全功能自行编写。
如果你有足够的能力/文化来忽略那些迫使你使用这些 “东西 ”的力量,那么这确实是一种很棒的开发体验。
我也喜欢 Java。
但这种自由思考的 Java 定义与 Java 生态系统中的主流观念相冲突,在工作场所会遭到很多反对。
所以我放弃了 Java,不是因为它的语言,而是因为它的人,以及围绕它的强制文化。
在任何有合作的地方,过度工程化都会悄然兴起。这不是 Java 的问题,而是公司的问题。新来的队友重构了完美运行的 sql 管道;队友将故事点作为故障单系统的必填字段;队友刚刚看了一场关于 rust 的会议演讲,想要应用它等等。大多数工程师都不是追求简单的禅宗大师;他们对所需的业务成果把握不清,因此会通过增加复杂性和间接性来拖延时间,并将技术作为终点而非旅途的重点。
> 迷失方向,对预期业务成果把握不足,因此他们通过增加复杂性来拖延时间
我认为这是业务人员和开发人员为了保护自己的薪水而不务正业的混合结果。业务人员要想取得成功,就必须制定一项能赚钱的战略。开发人员则需要制定战略,消除实现该战略的技术障碍。缺乏商业战略往往会让过于笼统的技术平台看起来很有吸引力。
> 关注技术,仿佛技术就是目的
太常见了。复杂性应该被视为敌人,而不是老朋友。
没错,但也有一些无聊的工程师,他们无法强迫自己编写企业级代码,除非让自己觉得有趣。我绝对相信这就是 Clojure 存在并广泛应用于金融科技领域的原因。
对多层模式的极端关注,实际上一个简单的函数就足够了,这是 Java 生态系统和文化的问题。只是有太多人在学习 Java,他们从未学习过其他语言或生态系统,更不用说模式了,他们认为 “我学过 Java,Java 可以做所有事情,我不需要学习其他东西!”,现在他们觉得需要通过无意识地应用设计模式来解决所有问题。
那你怎么解释……其他所有由 5 个以上的人编写的软件?
嗯,肯定不是通过谈论 AbstractFactoryProxy 来解释的。而是通过讨论系统的哪些部分是模块化的,系统允许什么样的灵活性,系统具有哪些功能。在这种情况下,像 AbstractBlaBlubFooBar 这样的低级实现细节根本不在讨论之列。
在计算机编程中,OOP 的杂乱无章远不止这些。
大约 15 年前某人的推文(根据记忆转述):
“我们仅用 5 万行代码就重写了 20 万行 Java 代码。猜猜我们用的是什么语言?
一天后的后续推文
“是 Java”。
> 没有继承性。
是的。比起继承,我更喜欢组合。
在 OOAD 的鼎盛时期,这种东西很难卖。UML、Fusion、RUP,等等等等。
我以前学过 LISP,这似乎是显而易见的,根本不值一提。这无疑让我与同事们区别开来。
顺便说一句:我们本地的设计模式研究小组曾讨论过 Arthur J. Riel 的《面向对象的设计启发式》[1996] https://archive.org/details/objectorientedde0000riel https://www.amazon.com/Object-Oriented-Design-Heuristics-Art… 这可能是最早发表的对那些夸夸其谈的企业 ThoughtWorks 风格的脑筋急转弯的反击。
> 不……依赖注入
是的: 模拟(mocking?) 只要翻转所有权关系就可以了。正如 Riel 以及其他许多人已经向这个漠不关心的世界解释过的那样。
如果你在组合时要做的第一件事就是从父对象向所包含的子对象发送大量委托方法,以使父对象可替代子对象,而且子对象在运行时不会被替换,那么你会更喜欢继承。
也许吧。我个人没有做过类似的事情。虽然我不太明白。
我主要做 AST 和(场景)图。我更喜欢哑对象,把大部分行为和逻辑都塞进解释器(设计模式)实现中。作为一只非常简单的熊,我的工作记忆无法跟踪散落在各处的所有智能。
我当前的例子是处理 SQL 语句;我认为表达式评估器(子集)是一个非常特殊的用例。我倾向于使用所谓的 “外部 ”迭代器来处理行走树,以便将所有逻辑集中在一处。与之相比,访问者、监听器甚至 Active Objects 都是如此。对于这种用例来说,“外部 ”迭代器是可行的,因为它是有边界的,而且不太可能改变(例如扩展点)。
当然,具体情况具体分析。现在我只是在胡言乱语,抱歉,可能离题太远了。
我曾看到有人将 Java 描述为公司为尽可能高效地轮换掉平庸的程序员而设计的,而不会让他们轻易把事情搞砸,从这个角度来看,Java 是很有道理的。它的语义简洁到了斯巴达式的地步(甚至不能定义自己的操作符重载),可以很容易地复制另一个类并稍加修改,但又不会给其他人造成混乱(继承)……。
还有 C#,大多数热衷于软件开发的人都会发现 C# 要好用得多,但对于廉价的海外血汗工厂来说,C# 可能更难对付。
我真的不认为这种观点会过时,即使它在当时更接近真实。在我看来,现在的Go才是最简洁的语言,而Java则是最无聊的开源主力。与现在的许多堆栈(python Ruby node)相比,Javavm 的性能非常高,同时它的并发编程故事仍然非常引人入胜,而且从 8 及以后的版本开始,Javavm 拥有了很多不错的语言特性。Lambdas 和 streams 是 8 的重头戏,但我认为虚拟线程的发展,甚至像 scope 变量这样的新事物,都是现在在 java 中构建新事物的令人信服的理由。
一种语言需要恰到好处的表现力,这样才能既不被滥用,又能编写出易于使用的库。
Java 经历了这一演变,实现了泛型、lambdas 等,我相信它在不过分复杂方面取得了很好的平衡(看看它的规范就知道了–与 C++ 或 C# 不同,与它的时代相比,它仍然是一种非常小的语言)。
Go 试图重新发明这种进化,却没有吸取 Java 的教训。他们会添加越来越多的功能,直到他们的 “简单 ”不再适用(尽管我个人认为他们的简单始终只是简单),原因很简单,因为你需要一些表现力来获得更好的库,而这些库以后会真正简化用户代码。
相关链接:https://www.tedinski.com/2018/01/30/the-one-ring-problem-abs…
与 Go 相比,JVM 的问题在于 GC;它需要大量的预留内存。Go 程序使用的内存要少得多。而且 SDK 很笨重,这对容器映像来说可能是个问题–不过可以说这应该被视为无关紧要,因为如果操作正确,你只需下载一次基础映像。
jlink 允许你去掉不必要的东西(比如运行时本身的文档),只提取运行时中你需要的部分(当然你的项目必须使用模块来支持),然后积极地压缩,得到一个相当小的包,它可以在一个空操作系统上运行,除了 libc 之外没有其他依赖。它仍然是一堆文件,所以为了获得良好的用户体验,你必须将它作为一个容器(或类似 .exe 或 appimage 的东西)来发布,但就大小而言,它确实接近 Go。
https://www.baeldung.com/jlink
这是一个可配置的属性,Java 首先就有一堆 GC。
此外,在这些类型的 GC 中不使用那么多内存会直接影响性能。这一点在重 GC 的应用程序/基准测试中得到了很好的体现。
我们曾为定制的高性能 GC 每月支付一百万美元,但通过大量的开发工作,我们终于摆脱了这种状况,并控制住了五个 9 的延迟。
我曾尝试减少 Keycloak 的内存使用量,但最终放弃了。对于并发用户少于 10 人的服务器来说,500-1500 MB 的内存实在是太离谱了。这还是在使用外部数据库的情况下。
你试过什么?作为最后一次尝试,你可以检查 visualvm 来查看实际内存使用情况,然后添加几个百分比的余量,并将其设置为堆大小。
.NET中的问题要少得多(它的GC调整介于两者之间,尤其是在使用SRV GC + DATAS时,比如在容器场景中,Go很有趣地不知道cgroups设置的限制,需要外部软件包来解决)。它比 Go 本身预分配了更多内存,但换来的是更高的分配吞吐量。Java 允许更高的分配吞吐量,它有多个更复杂的 GC 实现,但正如你所说,它并不擅长减少应用程序持续使用的 RSS。
> Java 最终成为了无聊的开源主力。
这句话让我很吃惊。我都不记得上次运行开源 Java 是什么时候了。
从我的头顶上?Bazel 是我用得最多的 Java 程序。Hadoop/hive 和类似的东西也大量使用 Java,但我不确定这些东西现在还在用多少。
我不是说开放源代码中没有 Java。我知道你提到的那些项目。但我并不运行它们。而且它们绝对称不上 “无聊的开源主力军”。
有几个 Java 项目,甚至有一两个还算成功。但 Java 在开源项目中是非常罕见的,而不是无聊的主力军。
如果我在一个使用 Bazel 的项目上工作,那么当然,我每天都会使用 Bazel。
但是,如果我让你在 Java、Make、Linux、gcc、llvm、.deb 中选择,哪一个才是开源中 “无聊的主力军”,Java 真的会是 “那个 ”吗?
当然,也许你可以排除大多数不 “无聊 ”的选项,比如 llvm。但无论如何,“make ”都是赢家。当然,从定义上讲,你很难去想那些无聊的工作母机,因为它的本质就是你不会去想它。
现在,我发现在我的开发机器上安装 java 的唯一原因就是 Arduino IDE 和我的 Android 开发环境。在开源领域,这是个非常小众的东西。
如今,大多数 Java 应用程序都 100% 基于开源堆栈,其中有数以百计的库和框架,Java 在企业领域占据主导地位,因此它是一个巨大的开源主力军,只是比 Linux、gcc 等更不起眼而已。
好吧,显然我们对开源 “主力军 ”一词的定义大相径庭。
它并不意味着 “基于 Java 的项目超过零”。也不是指 “大多数(开源?)Java 应用程序都基于开源”。后者几乎是循环论证,只有甲骨文公司的法律诡计使其不是循环论证。
> 和 Java 主导企业领域
我没说过什么企业。很明显,Java 在企业领域是巨大的。
> 所以它是一个巨大的开源主力军
这句话的转折很奇怪。企业,然后又回到开源?
> 只是比 Linux、gcc 等更不起眼。
默默无闻?我认为 Java 就像 Linux 一样是一个强大的品牌。在一般的开发者中,我认为 gcc 要比 Linux 默默无闻得多。程序员中没有人没听说过 Java,但很多人从未听说过 gcc。
> 好吧,显然我们对开放源代码的 “主力 ”一词的定义大相径庭。
你说了它不是什么,却忘了分享你自己的定义。
>这句话的转折很奇怪。企业,然后又回到开源?
是什么让你如此惊讶?一个并不排斥另一个,企业用户也是用户。Java 世界里的大部分东西都不是客户端的,所以很多用户不会直接观察到它们,但开源 Java 技术为他们做了很多工作,占了代码库的很大份额。
Ghidra 还
半个互联网都在运行它……除非你刻意避开 Java 栈,否则就会遇到它。它是规模最大的三大生态系统之一,另外两个竞争者是 JS 和 Python。
互联网?是的。但这不是我们要讨论的话题。
缺少操作符重载虽然有点恼人,但在实践中很少成为真正的问题。操作符只是一个看起来很有趣的方法。那又怎样?
Java 中还有更糟糕的基本问题。例如,缺乏适当的数字塔。或者需要依赖注解来表示无效性这样的基本问题。
在处理任何类型的数字代码时,这都是一个巨大的烦恼。或者自定义集合。或者标准库中其他人无法使用的其他东西。
> 可以很容易地复制另一个类并稍加修改,但不会给其他人造成混乱(继承)。
这听起来像是灾难的秘诀,因为它通常会使代码更难阅读。
同意,当程序员试图变得过于聪明时,继承往往会被过度使用。类的层次结构越扁平越好
我记得有一次在一个专业论坛上,C# 版块有很多关于架构的问题,而 Java 版块几乎没有。工具的丰富性造成了混淆正确与否的可能性。在 Java 中,许多设计决策在很久以前就已经趋同于某种主流设计,因此你不再考虑它,而是专注于业务。这有时和 getter 的冗长一样糟糕(幸好记录风格正在受到重视),但在大多数情况下,这并没有什么问题。
你是真的开始欣赏那种让人无法理解类情况的 OOP,还是逐渐转向了更简单的 OOP,主要由接口和实现接口的类(而不是扩展其他类)组成?
根据我的经验,如果尽可能避免扩展类,OOP 其实是很好用的。
> 这些小众的优化仍然很重要。OOP 模型可以让它们在实现时不那么大张旗鼓。
如果你指的是文章中的优化,那么我认为不需要 OOP 模型,只要有封装就足够了。
我不确定 “只要避免任何 OOP,OOP 就是令人愉快的 ”这一论点是否站得住脚。
OOP 是封装、抽象、继承和多态的结合。忽略继承并不意味着完全避免 OOP。
还要注意的是,OP 说的是 “避免扩展类”,但没有说 “避免实现接口”,因此他们并没有在广义上禁止继承。
我认为 “避免扩展类 ”之所以出现在这里,是因为在设计类时,如果你没有预见到类很容易被扩展,那就跟不可能一样;如果你预见到了类可能被扩展的方式,那么如果你一开始就把类设计得更灵活,用户往往会更容易接受。
封装、抽象和多态性可以通过其他方式实现。继承是 OOP 的唯一定义属性。
如果去掉所有与继承有关的东西,并试图修复对象这种漏洞百出的抽象,那么这种语言的规模就会小得多(与 Go 或 StandardML 相比,没有继承的语言会有多小)。
至于继承: 构成优于继承。我猜,…. 就是不做 OOP 吧?
我想说的是,OOP 的 “穷人的闭包 ”方面,即能够将一些上下文与行为打包,是日常代码中最有用的部分。只有在偶尔的情况下,继承接口以外的东西才是有价值的。
至于这是对 OOP 的认可还是批评,还有待解释。
对于库来说,OOP 是件好事,但却会导致应用程序的代码过于复杂。
在很长一段时间里,Java 的每个类都是一个库,我不认为这是 OOP 的失败,而是 Java 的失败。
但我很乐观,我认为最近增加的记录和模式匹配等功能是朝着正确方向迈出的一步。
> 你是真的开始欣赏那种让人无法理解类情况的 OOP,还是逐渐转向了更简单的 OOP,主要由接口和实现接口的类(而不是扩展其他类)组成?
我就是这么想的。给我更多继承层次较浅的类。在这里,我认为 go 的方法是有道理的。
技术债务越多,事情只会变得越复杂。你能做的最好的办法就是用另一种抽象来管理它。
您可以使用 JVM 而无需使用 OOP,例如 Scala、Clojure、Python、Javascript、Ruby 等。
然后,您就可以从 Java 无与伦比的企业级加固库、监控等生态系统中获益。
在企业环境中,这确实是你的决定。
> 除了更新我们使用的 JRE 之外,无需做其他工作
你以前尝试过更新生产使用的 JRE 吗?
是的。我将一些资源库从 Java 8 升级到了 Java 21。
Java 8 -> 9 是最大的麻烦来源,除此之外基本上没有什么问题。
您只需更改一行(JRE 版本),就能获得更快的 JVM 和更好的 GC。
现在有了 ZGC,垃圾回收问题基本上就迎刃而解了。
我曾开发过一款软件,在单个(尽管相当大)盒子上通过单个 JVM 以每秒近 500 万次请求提供服务,尽管分配率非常高(约 60GB/秒),我仍能看到低于单毫秒的 GC 停顿(约 800 usec p99 停止世界)。
JVM 堪称软件工程的奇迹。
我已经在不同版本的 Java 中完成了多次,只需付出微不足道的努力。当然,难度可能因项目而异。
现在有了 OpenRewrite [1] 这样的项目和优秀的 LLM,事情就简单多了。
[1]: https://docs.openrewrite.org
我喜欢听到更多关于这方面的信息,尤其是历史背景,但却没有很好的 Java 相关文章。您介意分享一些建议/指点吗?非常感谢。
Joshua Bloch 的《Effective Java》是一个很好的起点。他在书中分享了 Java 早期的一些故事,至少顺便提到了 String 类的一些历史。
啊,我当然记得这些轶事!对于更现代的 Java,您还推荐哪些其他资源(即使是花絮)?像这样的原创文章应该好好珍藏。
tl;dr:JVM 支持字符串的 Unicode,但在可能的情况下使用 1 字节字符串(以前是 UTF-16),尽管它实际上并没有使用 UTF-8。
根据您寻找的文档类型,您可能会喜欢 JEP: https://openjdk.org/jeps/254 或 Shipilev 的幻灯片。
或 Shipilev 的幻灯片(pdf 警告):https://shipilev.net/talks/jfokus-Feb2016-lord-of-the-string…
Shipilev 的网站 (https://shipilev.net/#lord-of-the-strings) 以及从上面的 JEP 链接到其他 JEPS,都是进一步阅读的好地方。
(我好像看到过一篇关于实现字符串压缩功能的专题文章,但我不确定是谁写的,也不知道是在哪里,或者我想的是别的什么东西。事实上,我觉得可能是 https://shipilev.net/blog/2015/black-magic-method-dispatch/,尽管标题是这样的)。
非常喜欢。非常感谢。昨天我突发奇想,一直在查看 JDK 的 String 提交历史,希望能从中发现一些小花絮。
Shipilev 的网站看起来是一个很有吸引力的资源。感谢您的指点!
这是一个很好的视频,其中介绍了大量的优化,尤其是围绕连接的优化:https://youtu.be/z3yu1kjtcok?si=mOdZ5sh5rp8LNyap
非常感谢!我周末会去看看、
> 你介意分享一些建议/指针吗?
我想,但不幸的是,我遇到了 NullPointerException(空指针异常)。
我建议你改用 Rust 试试;它的借用检查器能确保你不能以不安全的方式共享指针。
我知道这是个俏皮的回答,但是…
在 Java 中,您无法以不安全的方式共享 “指针”。即使是数据竞赛也是完全安全的。
我确实笑了,所以这就是原因。
反过来说,Java 开发人员多年来一直在处理次优字符串,却无能为力。
这篇文章提到了一个我之前从未听说过的新 JEP: “稳定值
https://openjdk.org/jeps/502
https://cr.openjdk.org/~pminborg/stable-values2/api/java.bas…
我不明白所建议的 StableValue 与记录或值类在功能上有什么区别。
他们将 StableValue 定义为
Records were defined as:
And Value Objects/Classes as:
记录和值对象都是不可变的,因此只有在创建或静态初始化时才能设置其内容。
记录字段不能懒惰初始化。StableValue 的关键在于懒初始化,这意味着只有当它们携带非默认值时(即初始化后),它们的值才是稳定的。如果不需要懒初始化,可以直接使用普通的 final 字段。对于非最终字段,如果没有 StableValue,JIT 优化器就无法判断它是否稳定。
值对象的实现将能在内部使用 StableValue 进行懒计算和/或缓存派生值。
我不知道,这些大多是不知情的猜测,但记录对象和值对象的区别在于内容缺乏对象标识。
在我看来,这可能意味着两件事。
其一,JVM 可以删除 “任何内容”,就像现在理论上可以删除字符串一样。相等的虚拟对象是相同的,而不是依赖于对象标识。
其二,它可以复制 VO 的内容,将其合并为一个单元。
通常情况下,Java 对象和记录都是指针组成的圆球。每个字段都指向其他内容。
对于值对象来说,情况可能并非如此。与其说 VO 是指针的集合,倒不如说它更像一个包含结构体的 C 结构–一个单一、连续的内存块。
因此,对象是指针的集合。记录是不可变指针的集合。值对象是(可能是)一个内聚的、连续的内存块,用于表示其内容。
手写解释: 稳定值被用作静态常量,不同之处在于可以在运行时对其进行初始化。一旦初始化,JVM 就会将其视为完全常量。这类似于 Kotlin 中的 lateinit,只不过是在 JVM 层面上。
记录也是不可变的,但您可以像创建普通类一样,在整个应用程序中创建和删除记录。
> 作为静态常量使用
是的,但要提醒大家,它不是与类相关联的静态常量,也不是编译时的常量。
也许这样说更好: 稳定值是懒惰的,在第一次使用时就会被设置,从而产生初始化前和初始化后的状态。数据只设置一次,这意味着你无法观察到数据的变化(即看起来是不可变的),但你可以观察到,在比较预设值和未设定值的实例时,资源利用率降低了–内存或时间减少了,或值初始化的其他副作用减少了。
因此,即使数据不可变,具有稳定值的类最终也会为每个稳定值提供两种状态的行为组合。而不可变记录或没有稳定值的类则没有这种行为变化。
不过,从大的方面来说,JVM 的热点优化一直都有这样的问题。
对于字符串而言,在计算等式时是否使用哈希代码(作为获得负结果的快速途径)变得非常重要。如果不使用,那么两个等号实例就会有不同的表现(尽管产生的数据相同),至少在一个依赖哈希码的操作中是这样。
对。甲骨文公司应该重新考虑这里的命名:稳定 -> 懒惰
但这也可以通过记录和值类的静态 init 方法来实现,对吗?
在不控制构造函数的情况下,如何对 HashMap 进行后期初始化?
嗯,我明白你的意思了–是的,这似乎与 Kotlin 的 `lateinit` 在语义上是相同的。
没错。但据我了解,这将在 JVM 层面进行优化,因此一旦您初始化了常量,JIT 编译器就可以充分利用这一事实。
所以每次获取字符串的哈希值都会泄漏 4 字节内存?
我假设它在包含对象的上下文中是静态的。因此,当字符串被收集时,它也会被收集。
字符串哈希值是作为字符串对象的一部分存储的。它的初始化值为 0,但在第一次调用 hashCode() 时会被设置为字符串的实际哈希值(这就是为什么它会被计算出来)。
(时会被设置为字符串的真实哈希值(这就是为什么如果你的特殊字符串的哈希值恰好为 0,它会被反复计算的原因)
> 它类似于 Kotlin 中的 lateinit,只不过是在 JVM 层面上。
如果不在 JVM 上,你认为 lateinit 在哪一级发生?
我猜他们的意思是,这个功能是内置于 JVM 本身的,而 Kotlin 的 lateinit 或多或少 “只是 ”在你可以自己编写的代码中进行去语法化。
根据我的理解,稳定值要么尚未计算,要么恒定不变。换句话说,一旦计算出来就是常量,因此 JIT 可以将其视为常量。
这是一个亟需的想法,但……这种方法太笨拙了。只有 Java 才会在实际语言功能中使用 “orElseGet ”这样的词。而且我个人认为,功能的使用与语义上的笨拙程度之间存在着反比关系……除非完全出于必要,否则考虑使用一个不优雅的功能就像是一个错误。
应该是
JDK 隐藏了确保只创建一次值的细节,基本上就像 classholder 成语一样,但在引擎盖下。我肯定他们不这么做是有原因的,但……这肯定是语言需要做到的。
顺便提一下,我一直很欣赏 Python PEPs,它们列出了所有关于某个问题的明显抱怨,并有条不紊地解释了为什么每个抱怨都被认为是行不通的。而 JEP 似乎没有达到这种坦率。
在 Java 中,常量被声明为 static final。
如果想要一个懒初始化常量,就需要一个稳定的值
如果希望常量的字段也是常量,则必须声明 Complex 为记录。
– StableValue 是关于何时以及如何懒散地初始化一个字段的定义,具有很强的 “精确一次 ”语义。这就是我们之前使用安全双重检查锁定所要做的事情,但更加方便。使用记录并不能解决这个问题,不过你当然可以把记录放在 StableValue 中。
此外,仅仅为了保存一个 Foo 而定义一个记录 FooHolder(Foo foo),要比直接说 StableValue<Foo> fooHolder = StableValue.of();要麻烦得多。
– 值类没有标识,这意味着它们不能使用同步方法,也没有对象监视器。虽然可以在 StableValue 中存储值对象,但在 StableValue 中存储身份对象也有很多用例,例如 JEP 中的日志记录器示例:我们可以很容易地想象一个虚构的日志记录器拥有一个同步方法来保持日志的排序。
我不会说这些都是完全正交的问题,但它们是具有不同目的的不同概念。
记录可以通过反射进行更改,因此不参与 JIT 阶段的常量折叠,因为这可能会破坏某些序列化库和其他令人讨厌的脏代码。它将在解释器模式下工作,并最终在 JIT 之后出现问题。不好
@Stable “注解(目前只是内部注解)和 ”StableValue<>”注解(将来用于用户代码)告诉 JIT,程序员保证(以生命发誓!)在整个代码库中不会在这些值上做手脚,JIT 可以在这些值初始化后立即对其进行恒定折叠。
我刚刚尝试使用反射更改记录实例的一个字段,但即使使用 setAccessible(true)也无法实现。
哎呀,我的错。我记得 Aleksey Shipilëv 曾解释过为什么 JIT 不对 `final static` 字段进行恒定折叠,并认为后来引入的记录类也是如此。
这意味着,“StableValue<>”可以用于简单类(“final ”字段仍然不是恒定折叠的),而且还支持延迟初始化。
是的,Java 团队在引入新的记录功能时明确禁止更改其最终字段。他们的意图是最终在所有 Java 字段中强制执行相同的不变式,除非 JVM 通过一个明确的退出标志。如果您不依赖于任何使用反射来修改最终字段的框架,那么您现在就可以使用一个标志来指示 JVM “信任 ”并对 `final` 进行优化。
我很惊讶字符串哈希值在 JVM 中没有被随机化,至少文章是这么暗示的。如今,这是一种相当常见的基于哈希碰撞的 DDoS 攻击缓解方法。难道 Java 不这样做是为了保持向后兼容性,因为太多现有代码依赖于特定的散列算法?
文档中指定了散列算法:
根据海勒姆定律,现在已经无法改变了。
https://docs.oracle.com/javase/8/docs/api/java/lang/String.h…
Java hashCode 合同是为了优化散列计算的性能,而不是为了抗碰撞搜索。它的唯一目的是在集合中使用。在需要加密属性的情况下不能使用它。
所以,这里的问题是,你认为 “加密属性 ”只是 “加密哈希值”,比如那些用 Merkle-Damgård 结构构建的哈希值(SHA-512/256 是你今天可能会合理选择的一种)。
但实际上,在制作哈希表的更快单向函数中包含一些加密属性是非常可取的。请阅读 SipHash,了解其中的原因。
因为 Java 没有(正如其他人所讨论的那样,现在也不能)选择其他方式,否则所提供的哈希表结构就必须通过碰撞来抵御破坏,而如果攻击者无法碰撞你所使用的哈希表,这就没有必要了。
Java 哈希码只有 4 个字节,总会发生碰撞
问题的关键在于,由于哈希值是已知的、可预测的,攻击者如果可以选择将哪些键添加到映射中,就可以选择它们,从而使每个键都发生碰撞,导致 DoS。
我认为 Java 必须添加其他层来避免这种情况,而不是使用抗碰撞哈希方案。
添加到映射中的 > 可以选择它们,因此每个键都会碰撞,从而导致 DoS
应用程序的创建者需要预见到这种威胁模式,他们可以为此做好准备(例如,对密钥加盐)。
但是,给每个用户都加一个抗碰撞但会降低性能的哈希值是不合理的,因为不是每个用户都需要它。
外部库中有专门针对这类情况的集合,在需要时可以使用(并愿意为其性能付出代价)。
我想这并不是 ivan_gammel 的本意,但他提到了最初的 CCC/tomcat 问题,我想这就很清楚地说明了问题:你有过 HTTP 头值的哈希表吗?我希望它使用过这些外部库中的一个,否则这就成了对任何拥有外部库的应用程序进行 DoS 的载体。
受用户影响/控制的输入比我们预想的要多得多,我认为更明智的做法是,默认情况下映射是安全的,当特定数据结构成为瓶颈时,可以使用高性能的外部映射。
>我认为这一点非常清楚:你有过 HTTP 头信息值的哈希表吗?
默认情况下,头的数量被限制在一个相对较小的数值(10k),在哈希碰撞的最坏情况下,由于哈希表的树状实现,查找时间相对较快。
> 由于 HashMap 采用树状结构,因此查找时间相对较短。
确切地说,这种数据结构必须提供相应的对策,以应对这种错误选择所带来的后果。
这项工作的快速数据结构在这里不需要树形,而是开放寻址,但这意味着如果攻击者可以任意碰撞输入,它们就无法应对,所以,太糟糕了。
根据哈希算法的定义,无论采用哪种哈希算法,都会发生哈希碰撞。这并不是一种应对糟糕选择的机制,而是一种优雅的设计,可以为可比键提供更好的性能。巧合的是,它还能减轻 DoS 攻击。
所有封闭式寻址策略都意味着分配器的负担更重。在这种情况下,每棵树都需要单独生长。但使用开放式寻址,我们可以避免这种情况。
假设我们要存储 10,000 个键->值对。瑞士表(一种流行的开放寻址哈希表设计)需要 16384 个连续的数据对和元数据†的空间。因此,它可以完成一次分配,但由于采用了封闭寻址,而且在插入过程中需要付费让树生长,因此无法知道哪些树会生长,哪些树未使用。
你说得对,瑞士表会出现碰撞,但攻击者无法选择碰撞,所以碰撞很少发生。每次碰撞都会让我们损失一个搜索步骤,但由于 Java 设计每次查找都要进行一次指针追逐(以找到树),这实际上与瑞士表发生一次碰撞的代价(一次内存取回)相同,但瑞士表在大多数情况下每次查找的碰撞次数都少于 1.0。
确保使用的存储空间不超过 87.5%,然后四舍五入到 2 的幂次,因为这意味着每个查找步骤的工作量更少。11429 的空间足够了,但 16384 是下一个最大的 2 的幂。
> 但通过开放式寻址,我们可以避免这种情况。
在风险管理中,有几种不同的方法:接受、减轻、转移、避免。你为什么认为这种设计必须避免某些风险?像 Java 这样的标准库的开发者通常会根据业界的反馈来做决定,我记得 Oracle 团队确实有很多遥测数据,而且他们都是优秀的工程师。你有什么见解可以证明他们的方法是错误的吗?
> 据我所知,甲骨文团队确实有很多遥测数据,而且他们都是优秀的工程师。你有什么见解能证明他们的方法是错误的吗?
时间之箭
Java 是 Sun(微系统公司)的语言,甲骨文公司在很久之后才收购了整个公司。因此,这里的一个问题是,你把这想象成是本世纪某个人做出的决定,但事实并非如此,这是 20 世纪 90 年代初的决定。
计算机科学是一门年轻的学科。20 世纪 90 年代初,开放寻址的哈希表确实存在,但使用封闭寻址来编写简单的链式哈希表却是很常见的,据我所知,Java 1.0 中提供的正是这种数据结构。这种数据结构如今已被认为是一个坏主意,我曾在 2011 年批评过 C++ 将完全相同的数据结构标准化(如 std::unordered_map)的选择。但那已经是二十年后的事了。
在某些时候(我认为是本世纪?),Java 工程师回过头来改进了哈希表,使用树而不是链,但他们受到哈希表的限制,因此无法在不造成重大破坏的情况下选择需要不同哈希策略的现代数据结构。
当我获得计算机科学学位时,“自省排序 ”是如此之新,我必须阅读最前沿的研究论文才能了解它。如今,如果你声称自己提供了一种标准库排序,但它至少比不上 Introsort,那就太傻了。
瑞士表(Swiss Table)是 2010 年代的产品,它已经足够新了,以至于在 Youtube 上有一段他们介绍这种数据结构的视频,这并不是因为他们特意这样做,而是因为他们正在发表演讲,显然现在所有的演讲都会被拍摄下来并上传到 Youtube 上。Hopskotch hashing(另一种流行的开放式寻址策略)是 2008 年的视频。
因此,问题并不在于 “这是当今最好的想法吗?”而只在于 “以他们 30 多年前的认知,这在当时是否合理?”是的,这在当时当然是合理的,但这并不神奇地意味着它是现在的最佳选择。
已编辑: 添加了一段关于后来选择使用树木的内容。
标准库的设计选择不仅受到算法性能的影响,还受到用例、现有合约等因素的影响。你没有回答我的问题。为什么你认为选择其他算法并打破现有哈希合约会更好?
> 你没有回答我的问题。为什么你认为选择其他算法和破坏现有哈希合约会更好?
但我认为 Java 不应该破坏所有这些东西。我只是在解释为什么–事实上–我们可能想要哈希算法的某些加密属性。既可以说 Java 在当时(20 世纪 90 年代)做出了合理的取舍,也可以说今天不应该照搬这些选择。
Java 选择了很多事后看来并不是好主意的东西,而且在很多情况下,在 Java 发明之初真的很难猜到这一点–然而今天,你不应该选择 Java 所做的事情。最大的例子:16 位字符。Java 被发明时,UCS-2 可能会出现,因此 16 位字符是合理的。几年后,UTF-2 显然不可能实现,所以你要选择 UTF-8 还是 UTF-16,这很简单,选 UTF-8。而那些已经购买了 16 位字符的人,比如微软和 Sun 的 Java 团队,就只能把 UTF-16 作为一个诱饵。
我想我在这里听不懂你在说什么。哈希的定义只是将任意集合映射为大整数。加密哈希增加了计算复杂性要求,因此很难发现碰撞(即设计速度慢–对于需要快速哈希的集合来说是个糟糕的选择)。不稳定哈希值是指不能保证在程序的两个不同执行过程中持续存在的哈希值(但对于同一执行过程中的相同对象,它显然必须是相同的)。这两种碰撞解决技术都不需要加密哈希值,甚至不需要不稳定哈希值。收集只需要哈希值在典型数据集上具有足够的随机性,就能获得最佳性能。我甚至怀疑不稳定哈希值能否减轻哈希值泛洪攻击,或者能否以某种方式确定黑盒的哈希值参数。对参数数量的限制和高负载率/高碰撞时的效率对安全性更为重要。
许多编程语言不使用开放式寻址,而是更倾向于链式寻址(Java、C#、Golang、C++ 中的 std::unordered_map),以获得简单性和灵活性。这需要考虑一定的取舍,并不是简单地选择绝对已知的最佳算法。我认为 Java 的选择并没有错。值得注意的是,我们总是有可能引入一种特定的集合,并采用某种现代算法来替代 HashMap。但这种可能性并没有出现,尤其是因为它并不被认为那么重要。我相信,在开放寻址能带来实质性好处的情况下,开发人员会有意识地选择并使用合适的数据结构。
关于字符串,自 Java 9(很久以前发布)以来,如果字符串只包含拉丁-1 字符,就可以存储在字节数组中。
请参见 https://openjdk.org/jeps/254
不,加密哈希函数的设计并不慢。这是一个令人讨厌的误解,导致 PHP 中出现了 MD5(password)[不是 20 世纪 90 年代 Unix 系统中使用的 PHK MD5 crypt()算法,而是字面上的 MD5(password),这是一个坏主意,不要这样做]。
给定 N 和 H(N)后,我没有办法让 M 使 H(M) == H(N),但 M != N,这比蛮力尝试 M 的所有可能值要快。目前,我们能做到这一点的所有算法都明显比 FNV-1 慢,而 FNV-1 曾经是散列表最可能的散列算法。
单向密码哈希算法(其中一些基于加密哈希算法)在设计上就很慢。您不应该使用密码,但在 HN 上,这似乎是个迷,因此,假设您坚持使用密码,这些算法就是您所需要的。争论使用哪种算法(“哦,不,PBKDF2 不占内存,等等等等”)也是 HN 的一个无底洞,所以,好吧,就选你认为唯一正确的算法吧。
但是,我们确实希望哈希表中使用的哈希值具有一些加密特性,而你的直觉是正确的,即仅仅使用一个随机因子并寄希望于它不会起作用。你需要一个设计正确的函数,这就是 SipHash。你需要的是某种带有密钥 K 的键控函数,如果我知道 N 和 H(K,N),但不知道 K,那么我就无法猜出一个 M,使得 H(K,M) = H(K,N),但 M != N,除非用蛮力。
> 许多编程语言都不使用开放寻址,而是使用链式寻址(Java、C#、Golang、C++ 中的 std::unordered_map),以简化程序。
这在 Golang 中是正确的,但如今 Go 中的 map 是经过修改的瑞士表,性能当然有所提高。我们已经看到 Java 为何会陷入困境。众所周知,C++ 的 std::unordered_map 性能很差,C++ 程序员有很多替代方案(当然也包括 Swiss Tables),例如 Boost 提供的非常不错的现代产品。
实际上,我对 C# 哈希表的内部结构一点也不清楚,它是一个复杂的、充满条件定义元素的庞然大物,因为它既被 CLR 内部重用,又作为一种类型提供给我们用户。它不是任何一种传统的设计,似乎有某种程度的 “这里没有发明”(Not Invented Here)(这在微软很常见)–只是在我们前进的过程中编造出来的。它在每个键->值对中存储预先计算的哈希值,跟踪碰撞的次数,我不知道,我不会这么做,如果没有确凿的数据说明为什么这么做是好的,就不要复制它。
没有理由因为一个特殊的用例而给集合中使用的哈希添加额外的属性和相关的复杂性。为了能够执行散列泛洪,服务器必须在散列映射中积累任意的用户输入,而这无论如何都是有问题的设计。即使这样做有意义,什么样的函数才能作为 32 位散列使用呢?你以 SipHash 为例:无论如何,这都是个糟糕的例子,因为这个函数需要秘钥–这意味着 Java 必须在幕后进行密钥管理(只针对字符串? 针对数字子类?这有什么意义?这种情况非常罕见,对于易受攻击应用程序的开发人员来说,使用具有所需散列属性的封装器会更加容易。
不过,这种情况并不罕见。在集合中添加外部字符串作为键是很常见的。这正是该攻击最初有效的原因,也是如今许多语言进行哈希随机化的原因。例如,.NET 就是这样做的。
散列映射中的无约束用户输入足够大,足以产生可观察到的效果,与其他所有情况相比,这难道不是一个奇特的用例?
另一种选择是永远不要从用户输入中提取散列映射的键值。这也是一种选择,但这似乎更难实现(在每个程序中?
来自 CCC 的原始报告展示了对 HTTP 服务器的攻击,该攻击带有大量存在哈希碰撞的请求参数。通过验证和限制用户输入(例如 Tomcat 7.0.23 默认将参数数量限制为 10000 个,还有其他改进),该漏洞得到了修复。无论如何都不能对用户输入进行无限制的收集,否则就有可能发生基于内存的 DoS 攻击。因此,任何处理用户输入的程序都必须考虑到这种情况。最好的防御边界是输入的解析器,不仅仅是 HTTP 参数和头,还有 JSON 和 XML 反序列化器,无论如何都必须对它们进行加固,以抵御各种类型的攻击。应用程序代码中理论上可能存在漏洞的许多情况对于执行攻击来说并不实用,例如在用户验证或速率限制过滤器之后处理输入时。
即使在集合中,不稳定的哈希值也是可取的 — 以避免由攻击者控制的哈希值碰撞造成的拒绝服务攻击。
例如,2012 年的 Python:https://nvd.nist.gov/vuln/detail/cve-2012-1150。
或者,哈希表的实现应能抵御碰撞,在这种情况下会退回到另一种数据结构(如下所述,在有足够多占用者的桶中使用树而不是列表)。
Java 中存在 CVE-2012-5373,但没有修复,因为它不是一个值得关注的风险。
如果攻击者可以控制字符串内容,并有选择性地选择字符串,那么哈希表就不再是哈希表,而会变成一个链表。
很明显,这不是在通用集合或 String::hashCode 中使用它的理由。如果您的应用程序容易受到此类攻击,只需使用键和专用集合的包装器即可(您可能还想限制其最大大小)。
这听起来很像那些说 “那就别写有缓冲区溢出 bug 的代码 ”的 C++ 开发人员。
之所以说这种事情应该是默认的,是因为期望普通代码编写者达到这种理解水平是不合理的,但大多数软件都是由后来者编写的。这就是为什么 PL 和框架设计在相当长的一段时间内一直在向默认安全的方向发展–因为经验证明,没有其他方法可以奏效。
你误解并夸大了这种特殊风险,并认为只有一种方法可以处理它。首先,它涉及到未检查的用户输入:仅仅修复哈希代码是不够的,无论采用哪种哈希算法,整个 HashMap 都不适合存储它。哈希可能没问题,但 HashMap 没有大小限制,因此可能存在与耗尽服务器内存的 DoS 攻击有关的漏洞。开发人员必须尽早验证输入。即使是愚蠢的输入。
其次,这种风险一经发现就在 Java 中得到了可靠的缓解。哈希碰撞可能存在,但这并不意味着它们可以被利用。JDK 的 CVE 没有得到修复,因为它已经在 Tomcat 等其他地方得到了解决,在这些地方可以进行有意义的验证。
语境很重要。
然而,几乎每个人都开始使用哈希随机化技术。为什么?
漏洞已被报告,并以合理的方式得到解决。为什么其他平台在这里很重要?你认为他们更聪明吗?
Java 使用树形而非链表来处理碰撞项,因此搜索性能的下降更为平缓(例如,O(N) vs O(logN))。
更准确地说,Java 最初使用链接列表来处理 bin 中的节点。如果 bin 内的条目数超过 TREEIFY_THRESHOLD(即 8),那么该特定 bin 将转换为 RB 树。
在这里的实施说明注释中有详细说明:https://github.com/openjdk/jdk/blob/56468c42bef8524e53a929dc…
这取决于您使用的 Map / Set 实现。有多种,每种都有不同的有趣属性。
不,这会破坏等价和散列的语义,因为等价的两个对象应该具有相同的散列代码。因此,对象的哈希代码必须是确定的。这反过来又是集合、哈希表等的一个重要属性。这一点不太可能改变,因为这会破坏大量的东西。
对于需要安全的东西,你可能应该使用专门的库和标准应用程序接口等。至于其他东西,这几乎是个无关紧要的问题,不值得为此撕毁合同。这在实践中并不是什么大问题,只要选择你想做的事情就能轻松解决。
选择在散列代码层面解决这一问题的语言,其散列代码在特定程序执行过程中仍具有确定性。它们在不同执行之间并不确定,参见
https://docs.python.org/3/reference/datamodel.html#object.__…
没有什么能阻止 jvm 缓存哈希值,即使每个进程调用的哈希值都是唯一的。
不是的,而且很不幸的是,空字符串的哈希值为 0,所以每次都要重新计算,尽管计算空字符串的哈希值可能很快。
看来字符串键入并不像以前那样是一种糟糕的反模式了:)
这将是一项非常有影响力的工作;我很期待看到它。也许 String::hashCode 哪怕只有 1% 的改进,也会对全球碳足迹产生影响。
这么多年过去了,我们仍然能看到性能的提升,这真是酷毙了!我对示例中的一些细节感到困惑。比如,在字符串 hashset 查找这样的简单示例中,我们能看到类似的 8 倍改进吗?在这里,MethodHandle 或不可变映射是否有什么特别之处来突出这种改进?
> 计算字符串 “malloc ”的哈希代码(始终为 -1081483544)
有道理。非常酷。
> 探测不可变映射(即计算内部数组索引,该索引对于 malloc 散列代码始终是相同的)
这要怎么做?“计算 “似乎不受新属性的影响。除非它是稳定的记忆化,但我不太明白它在这里记忆化什么:它已经是一个哈希映射了。
> 检索相关的 MethodHandle(始终位于上述计算索引中)
这有什么变化吗?一旦确定了索引,返回哈希映射中的值一直都是零开销,不是吗?
> 解决实际的本地调用(始终是本地 malloc() 调用)
以前也是 “lazyinit ”吗?如果是,那就说得通了,不过如果能在文章中解释一下就更好了。
> 这将如何工作?“计算 “似乎不受新属性的影响。
索引是根据散列代码和数组大小计算出来的。既然哈希代码可以被视为常量,而数组的大小也已经是常量,那么索引就可以在编译时计算出来。JVM 基本上可以内联所有涉及创建和探测映射的方法,从而完全消除映射。
桶是根据哈希代码和数组大小计算出来的,但不一定是索引。如果没有数据桶碰撞,那么索引===数据桶,这样就能解决问题。但如果有数据桶碰撞,索引就会与数据桶不同。因此,你仍然需要计算 IIUC。而且无法将结果 memoize,因为 memoize 需要一个与原始 hashmap 特性完全相同的 hashmap。
我想,在映射的底层数组上添加 @Stable 属性可以消除一个重定向:在可变映射中,底层数组可能会被调整大小,因此其指针并不稳定。如果使用注解的不可变映射,指针就可以保持稳定(不过我不知道这是否能与 GC 扫描等一起工作)。但这似乎是个小问题?我看不到 “假装地图不存在 ”的方法。
为什么一个关于编程的网站不能用 ASCII 码编写代码块?我指的是引号和其他不受欢迎的符号
Java 一直支持非 ASCII 源代码。很多语言都支持。
但它不支持使用花哨的 unicode 引号作为字符串分隔符。
有没有人做过/分享过一个最近的基准测试,比较不同 Java 运行时的 JNI 调用延迟?我正在探索将我的字符串库引入 JVM 生态系统的想法,但在过去,JNI 的开销让这一想法变得不切实际。
Java 已经用 Project Panama FFM 取代了 JNI,根据您的使用情况,它的性能可能会比过去的 JNI 好很多。向量应用程序接口(Vector API)还处于孵化阶段,边缘还有点粗糙,因此 SIMD 可能会比较棘手。
你能分享一下你的 “字符串库 ”的链接吗?我很好奇它能做什么 Java 字符串不能做的事情。
在这一点上,它并没有提供多少新奇的功能,但它应该比大多数(或许是所有)编程语言的标准库更快。
https://github.com/ashvardanian/StringZilla
这个例子完全没有说服力。为什么要将这些调用存储在映射中,而不只是一个变量?
即使映射因某种原因而至关重要,为什么不让映射接受一个简单的值(如 unint64),并要求调用者在查找函数指针前将字符串转换为槽。这样,代码读者就能清楚地看到交换字符串的代价。
我很难找到这样做能优化好代码的用例。我能想到很多糟糕的代码用例,但我们真的是在优化糟糕的代码吗?
> 我很难找到这样做能优化好代码的用例。我能想到很多糟糕的代码用例,但我们真的是在优化糟糕的代码吗?
在现代网络编程中,最常见的此类用法是存储和检索 HTTP 头信息、解析查询参数或反序列化 POST 体的映射。每个网络应用(可以说是大多数应用)都会利用这一点。
> 存储和检索 HTTP 标头映射。
我没有这方面的分析数据,所以这纯属理论推测。HTTP 头信息是动态数据,必须在运行时读取,而你却把它塞进了请求处理内部的堆分配数据结构中。这有点像在你的字符上做一个小小的 xor 计算。
我认为这不会对 HTTP 处理程序产生任何有意义的影响,因为它们在编写之初就没有考虑到性能问题。
优化器的全部意义不就是把 “坏代码 ”转换成 “好代码 ”吗?
你提出的解决方案是让用户手动实现哈希表,但如果有一个好的优化器,用户就可以专注于编写没有错误或逻辑错误的清晰代码,然后让机器将其转化为高效代码。
我认为这是一种相当近视的观点–这种情况可能不会像现在这样出现,但在内联一些东西并进行其他优化后,可能会很容易出现在完全正常的普通代码中。
>但我们真的是在优化糟糕的代码吗?
是的
所以字符串在编译时不会得到哈希代码?
起初我以为这篇文章描述的是类似于 Ruby 符号的东西
> 所以字符串不会在编译时获得哈希代码?
只有编译时已知的字符串才有可能在编译时散列?
但文章说的是运行程序中的字符串。性能改进可能适用于常量字符串,但它们是在运行时创建的。
有点遗憾的是,与用户代码(JEP 502)相当的是,每个 “稳定 ”字段都需要额外的对象。懒惰初始化通常是为了避免预先创建对象,但采用这种新模式后,无论如何你都必须创建一个对象。
嗯,不会。JVM 会让封装对象消失。StableValue 的设计驱动因素之一是性能。
我的意思是,开发人员必须创建 StableValue 字段,但其访问已被优化。
在 JIT 编译后,是的(大概),但对于解释字节代码,我认为仍会分配一个常规对象。
这个问题在 JEP 中已经解决了:
> 此外,稳定值与 Java 运行时之间存在机械共鸣。在引擎盖下,稳定值的内容存储在一个非最终字段中,该字段用 JDK 内部的 @Stable 注解注释。该注解是 JDK 底层代码的常见特征。它断言,即使字段是非最终字段,JVM 也可以相信字段的值在字段初始和唯一一次更新后不会改变。这样,只要引用稳定值的字段是最终字段,JVM 就可以将稳定值的内容视为常量。因此,JVM 可以对通过多级稳定值访问不可变数据的代码(如 Application.orders().getLogger())执行常量折叠优化。> 因此,开发人员不再需要在灵活的初始化和最高性能之间做出选择。
> 在引擎盖下,稳定值的内容存储在一个使用 JDK 内部 @Stable 注解注释的非最终字段中。
这是说 StableValue 实例将有一个这样注释的非最终字段,而不是说没有分配 StableValue 实例。请注意,用户代码级字段是最终的,所以这里指的并不是这个字段。事实上,正是这种描述让我认为 StableValue 对象即使在 JIT 之后也可能存在。
关于这一点,一般的想法是,如果你仍然在解释型代码中,那么你所处的环境就不值得担心额外分配对象的代价。
对我来说,这一切听起来都很难理解。这是否只适用于 Map.of?它也适用于 map.put 吗?
对普通 Java 服务的性能提升有多大?
是否有特定类型的应用程序会从中获益良多?
这是否会让 string.intern() 更有价值?字符串缓存?
> 这是否只适用于 Map.of?它也适用于 map.put 吗?
这样会更快,但不会快得惊人。与不可变映射相结合,这意味着 JVM 可以直接用它的值替换你的键,就像映射根本不存在一样。因为键的哈希代码不会改变,而映射也不会改变。
> 这是否使 string.intern() 更有价值?
不,String.intern() 的作用与此不同,它的作用是为你节省内存–如果你知道一个字符串(例如 XML 文档中的属性名)被使用了数十亿次,并从一个流中解析出来,但你知道你只想要它的一个副本,而不是数十亿个副本)。这样做的缺点是会将字符串放入 PermGen,这意味着如果开始对普通字符串进行交互,内存很快就会用完。
如何直接用其值替换你的密钥?如果有桶碰撞怎么办?不可变映射会一直扩展到没有为止吗?此外,如果出现哈希键碰撞怎么办?我认为需要有某种底层机制来处理这些问题。我不知道 “类似于地图的替换”(replace-like-the-map-isn’t-there)是如何工作的。甚至不知道如何使用“@Stable ”来影响它。希望能有更深入的了解。
> 如何直接用键值替换键值?
就像你写这样的 C 代码一样:
C 编译器 “只知道”,在你写 x[2] 的地方,它可以用 42 代替。因为你用 “const ”表示这些值永远不会改变。它甚至可以用 52 替换 addten(2),甚至不调用 addten(),也不做加法。
Java 基于值的类也是如此:https://docs.oracle.com/en/java/javase/17/docs/api/java.base…
但它比 C 语言更神奇一些,因为要运行一些代码来初始化值,一旦初始化完成,还可以进行更多轮的代码编译或优化,JVM 可以利用知道这些对象是普通值的优势,参与常量折叠、常量传播、消除死代码等工作。有了 @Stable,JVM 就能知道,如果一个函数被调用过一次且没有返回 0,它就能将其 memoise。
> 如果有水桶碰撞怎么办?不可变映射会一直扩展到没有为止吗?此外,如果存在哈希键碰撞呢?
我不知道具体细节,但在构建不可变映射之前,你不可能拥有一个不可变映射,如果键或值有问题,它可以通过抛出运行时异常来拒绝构建一个不可变映射。
不可变映射做出了很多承诺 — https://docs.oracle.com/en/java/javase/17/docs/api/java.base… — 但在大多数情况下,它们只是做出语义承诺的普通 HashMaps。例如,对于 x = Map.of(1, “hello”,2, “world”),JVM 知道的足够多,它可以用 “hello ”替换 x.get(1),用 “world ”替换 x.get(2),而无需多次调用 _any_ 映射的内部函数。
在此之前,字符串作为键是行不通的,因为 JVM 并未将 String.hash 字段视为稳定字段。现在它可以了,而且可以对所有步骤进行恒定折叠,这意味着您也可以让 y = Map.of(“hello”,1,“world”,2),JVM 可以用 1 替换 y.get(“hello”)
JVM 如何知道映射是不可变的?
但内部字符串也可以永远重复使用其散列码。
Map.of() 承诺返回不可变的映射,而 new HashMap<>() 却不能。
https://docs.oracle.com/en/java/javase/17/docs/api/java.base…
它是如何告诉 JVM 的?它使用了内部注解 @jdk.internal.ValueBased
https://github.com/openjdk/jdk/blob/jdk-17-ga/src/java.base/…
> 这是否使 string.intern() 更有价值?
这可能取决于使用情况,不过我很难想到这样的使用情况。如果你要动态创建大量不同的集合,而这些集合中又有相同字符串的不同实例,那么也许可以吧?但这样一来,在所有字符串上调用 `.intern` 的开销可能会超过调用 `.hash` 的开销。事实上,既然 `.hash` 的速度更快了,那么从表面上看,`.intern` 的价值也就更低了。我想是的。
尽管 HN 的指导原则是保留原标题且不得以任何方式进行编辑,但如果标题为 “Java Strings Just Got Faster in JDK 25”,则会减少误导性。
Kotlin 和 Scala 能否从中受益?
它们应该受益。这是 JVM 在 JDK 核心类上的一项功能。
这意味着它们有自己的 String 实现?如果发现是这样的话,我不会感到震惊,但也会感到惊讶。
Java 字符串享有大量优化,包括 SSE4.2 和 AVX 内核。实现自己的字节[]封装器(字符串就是封装器)可能很有用,但这并不能取代内置的字符串。
简而言之:Java 中的通用字符串替代品将是一个极其糟糕的想法。
为什么?因为这意味着他们没有自己的字符串实现。他们免费获得了 Java JDK 的字符串实现(以及 JVM 对其进行的优化)。如果他们有自己的字符串实现,那么在公开之前,他们就无法使用这种优化。
哈!抱歉,我把这句话误读成了 “他们不应该”。不知道为什么。
如果你在 JVM 上使用 Kotlin/Scala,是的。你也可以在本机(ios、win32、linux 等)、wasm 或 javascript 上使用 Kotlin。Scala 至少有一个 js 编译器,或许还有其他一些。在这些其他平台上不会有任何改进。
很奇怪为什么要专门写一整篇关于 String 的文章–这似乎只是基于 @Stable 注解所做的工作,以及它在运行时可能对类产生的诸多影响。
我可以理解选择 @Stable 作为示例的原因:它强调了该附加注解甚至为(某些)现有代码提供了 “免费 ”升级,并立即展示了一个具体的用例。但没错,他们本可以在此基础上进一步概括。
如果你指的是标题,那么字符串是编程中的一种通用数据类型,因此提高字符串性能的说法比 “这个你以前从未听说过的注解让某些特定代码变得更快 ”更能吸引点击,尤其是在吸引非 Java 程序员的注意时。
@Stable 是对最终关键字的概括,还是有更多我忽略的地方?
final 是一个访问修饰符。它控制着谁可以更改某个值(在本例中,任何人都不能更改)。和其他访问修饰符一样,你可以使用反射来移除或更改这些修饰符。它是开发者要求系统维护的一道安全屏障。
稳定注解是一种优化机制:是开发人员向编译器做出的承诺。开发者有责任维护它。
我们将 hashCode() 函数 memo 化了,这样的总结对吗?
不,String.hashCode() 已经被 memo 化了。因此,在第一次调用 hashCode() 之后,以后的调用只是从字符串对象的哈希字段中获取数据。
这种优化是为了避免调用该方法,因为 jvm 知道返回的值是相同的。
不仅如此。通过在首次调用后保证常量,他们可以折叠常量,基本上避免了运行时的映射查找,而是在编译时进行。实际上就是完全避免了映射。
我曾在 TXR Lisp 中尝试在字符串中使用哈希代码,但几天后就放弃了:
> 这一改进将有利于任何以字符串为键、通过常量字符串查找值(任意类型的 V)的不可变 Map<String, V>。
等等,什么?但是,这本身就是常量可折叠的,无需推理字符串哈希代码;我们根本不需要它们。
我们来看看表达式 [h “a”]: 在哈希表 h 中查找键 “a”,其中 h 是一个哈希字面对象,我们把它写成 #H(()(“a””b))。它包含键 “a”,并将其映射到 “b”:
代码是什么样子的?
一条指令:只需从静态数据寄存器 d0 返回 “b”。哈希表就完全消失了。
键甚至不一定是字符串,那只是个幌子。
> 等等,什么?我们根本不需要字符串哈希码。
他们的目标并不是改进哈希表中的键查找,那或多或少只是一个例子。他们的目标是改进使用懒初始化的变量的整体优化,而 String 的哈希码使用了懒初始化。
标准库可以使用 @Stable 注解向编译器发出这样的信号,这是非常聪明的做法。我想知道有多少标准库错过了这样的优化。
malloc()不是系统调用吗?
不,mmap 是系统调用;内存分配器倾向于不使用系统调用(通常根本不使用),因为反对实例化是非常常见的;此外,它还必须是并发的什么的。
突发奇想:
能不能在计算哈希代码时加一,从而解决空字符串哈希值为零的问题?
但这样一来,散列代码为 HASH_MAX 的字符串就会换行为 0。
可以,但这样会破坏向后兼容性。