JDK 26 性能改进全面解析
1. 引言
JDK 26 包含多个已解决的问题和超过 1,000 项增强。其中很大一部分工作聚焦于 JDK 库、垃圾收集器、编译器和运行时的性能。
一些改进需要更改 API 或 JVM 选项。另一些改进则让现有应用在迁移到 JDK 26 后立即受益。这些变化共同致力于实现更快的启动、更高的吞吐量和更好的可伸缩性。
在本教程中,我们将探讨 JDK 26 中最重要的性能改进。我们还会了解它们如何影响应用代码和部署选择。
2. JDK 库改进
我们先从与应用库代码密切相关的变化开始。
2.1. 惰性常量
JEP 526 将惰性常量作为第二个预览功能引入。LazyConstant API 持有一个不可变值,该值仅在首次请求时计算。
在 JDK 26 之前,延迟创建昂贵对象通常需要可空字段、空值检查和同步。例如,我们可以仅在首次请求时初始化服务:
现在我们可以直接表达同样的意图:
首次调用 get() 会创建服务。之后的调用返回同一个值。初始化最多发生一次,并且在多个线程竞争访问时仍然安全。当惰性常量存储在 final 字段中时,JVM 可以以类似于 final 常量的方式优化重复访问。 这让我们能够延迟工作,而无需永久承担通常的同步成本。
由于这是一个预览 API,我们需要在编译时和运行时启用预览功能:
2.2. 字符串、记录和加密
JDK 26 减少了 MemorySegment.getString() 内部的中间分配和复制。当应用频繁将原生或堆外数据转换为 Java 字符串时,这一点很重要。早期基准测试显示,所有测试大小的延迟都有所降低,其中短字符串的改进最大。
记录生成的 hashCode() 方法现在获得了更好的类型分析。因此,大量使用记录的地图、集合、分组操作和去重操作可以在不修改源代码的情况下提升吞吐量。 该版本还优化了 AES、ML-DSA 和椭圆曲线 P-256 操作。这些变化改进了密钥设置、底层算术以及在受支持处理器上的硬件特定执行。
还有一些较小的改进。GZIPInputStream 更高效地读取单个压缩流,而 Method.equals() 在两个引用指向同一实例时会立即成功。后者可以帮助动态代理,因为在分派过程中方法比较会频繁发生。
3. 垃圾收集和启动改进
垃圾收集会自动运行,但并非没有代价。每当应用更改对象引用时,JVM 都会执行额外工作。它还会在启动期间准备堆结构、类和常用对象。
JDK 26 减少了这两类开销。它降低了 G1 引用跟踪的成本,将提前缓存扩展到每种垃圾收集器,并避免准备不必要的大型初始堆。
3.1. 降低 G1 同步开销
G1 将堆划分为多个区域。在收集期间,它可以回收选定的区域,而不是处理整个堆。
不同区域中的对象仍然可以相互引用。例如,一个区域中的 Order 对象可能引用另一个区域中的 Customer 对象。G1 必须记住这种连接,以免在订单仍然可达时回收客户。
G1 使用卡表跟踪这些变化。卡表将堆表示为称为卡的小区域集合。当应用代码更改对象引用时,写屏障会将相应的卡标记为脏。
这个写屏障会在每次相关引用更新时运行:
后台细化线程检查脏卡,并记录跨区域边界的引用。以前,应用线程和细化线程使用同一个卡表。它们需要额外的同步来避免相互干扰。这种同步使每次写屏障都更加昂贵。 对于频繁创建对象或更新字段的应用,例如缓存、内存数据存储和请求处理系统,这种成本变得很明显。
JEP 522 引入了第二个卡表。应用线程写入活动表,而细化线程处理另一个表。当活动表需要细化时,G1 会交换这两个表。
这两组线程现在可以独立完成大部分工作。 这减少了同步,并使对象引用更新更便宜。已发布的基准测试显示,在引用密集的工作负载中,吞吐量提高了 5%--15%。引用更新较少的工作负载则获得了最高约 5% 的提升。
第二个表需要额外的原生内存,大约相当于堆的 0.2%。也就是说,每 1 GB 堆空间约需 2 MB。已经在使用 G1 的应用无需更改代码或配置即可获得这一改进。 但是,结果取决于应用更新引用的频率。因此,我们应该使用具有代表性的工作负载来验证收益。
3.2. 适用于任何 GC 的 AOT 对象缓存
Java 应用在处理有用工作之前会执行多项操作。JVM 加载和链接类、验证字节码,并创建常用对象。基于框架的应用可能在每次启动时重复大量此类工作。
提前缓存将其中一些工作转移到更早的训练运行中。 在训练期间,JVM 记录应用使用的类和堆对象。之后的执行可以加载这些准备好的工件,而不是重新创建所有内容。
例如,缓存可能包含 Class 对象及其相关字符串和字节数组。重用这些对象可以减少启动时间以及达到峰值性能所需的时间。
困难在于,垃圾收集器并不总是以相同方式表示对象引用。为一种收集器准备的缓存对象可能包含另一种收集器无法直接使用的引用。这在以前将 AOT 对象缓存限制在兼容的垃圾收集器上。
JEP 516 为缓存对象添加了一种与收集器无关的表示形式。缓存不再以绑定到某一收集器的格式存储引用,而是可以以某种形式存储对象,由 JVM 在加载时进行转换。因此,JDK 26 可以使用两种方法。特定于 GC 的缓存可以直接映射到内存中,以实现快速热启动。与 GC 无关的缓存可以流式传输到堆中,并转换为所选收集器的对象格式。
AOT 对象缓存现在可用于每种垃圾收集器,包括 ZGC。 这允许应用将更快的启动和预热与 ZGC 的低暂停行为结合起来。
3.3. 更小的默认初始堆
JVM 还会在启动时准备一定量的初始堆内存。我们可以使用 -Xms 或 -XX:InitialHeapSize 显式设置此值。当两个选项都不存在时,JVM 会计算一个默认值。
早期版本将默认值设置为机器物理内存的 1.5625%。这大约是可使用 RAM 的六十四分之一。
因此,高内存服务器可能会获得大得惊人的初始堆。在具有 256 GB 内存的机器上,1.5625% 大约为 4 GB,还未考虑其他 JVM 大小调整规则。小型服务在启动期间可能完全不需要接近这个数量。准备更大的堆涉及额外的初始化和元数据工作。这可能会延迟启动,即使大部分初始空间从未使用。
JDK 26 现在在未配置显式大小时,使用 MinHeapSize 作为默认初始堆。JVM 以较小的堆启动,并在应用需要更多内存时扩展它。
这一变化减少了依赖默认堆设置的应用的不必要启动工作。 已经提供 -Xms 或 -XX:InitialHeapSize 的应用会保留其配置的行为。
4. 编译器和运行时改进
C2 编译器现在可以优化具有超长参数列表的方法。此类方法以前会停留在 C1 编译或解释执行的路径上。这一变化主要帮助生成代码和创建异常宽方法签名的框架。
C2 还拥有更好的 SuperWord 循环向量化成本模型。使用 SIMD 指令处理多个值可能比标量执行更快。然而,打包、混洗和组合向量也会消耗 CPU 时间。改进后的模型帮助 C2 仅在预期收益超过额外工作时才对循环进行向量化。 无需更改应用。
最后,虚拟线程在常见路径中等待类初始化时,可以从其载体线程上卸载。在 JDK 26 之前,这种等待可能会固定载体线程,使其无法运行其他虚拟线程。这一变化提高了类加载突发期间的可伸缩性,并降低了载体线程饥饿的风险。
5. 衡量升级
性能变化取决于分配模式、引用更新、启动行为、硬件和所选的垃圾收集器。因此,升级测试应在两个 JDK 版本上比较相同的工作负载、JVM 选项、堆限制和流量模式。
我们可以在具有代表性的运行期间记录 Java Flight Recorder 数据:
有用的比较包括启动时间、请求吞吐量、分配速率、GC 暂停分布和 CPU 消耗。对于 AOT 缓存,训练工作负载还应覆盖应用的正常启动路径。
最好的结果是在应用自身的服务级别指标上获得可重复的改进。 合成基准测试有助于隔离某个功能,但它不能替代端到端测量。
6. 结论
在本文中,我们探讨了 JDK 26 中的主要性能改进。我们了解了惰性常量、减少分配的库代码、G1 降低的同步开销、更广泛的 AOT 缓存、更智能的 C2 决策以及更好的虚拟线程行为。
这些优化大多几乎不需要或完全不需要更改源代码。通过使用类似生产的工作负载测试 JDK 26,我们可以确定哪些改进能为我们的应用带来有意义的收益。
文章 Performance Improvements in JDK 26 首次出现在 Baeldung。