JEP 450: 紧凑对象头(实验性)
概述
将 HotSpot JVM 中的对象头大小从 96 到 128 位减少到 64 位架构下的 64 位。这将减小堆大小,提高部署密度,并增加数据局部性。
目标
当启用此功能时
- 必须将目标 64 位平台(x64 和 AArch64)上的对象头大小减少到 64 位(8 字节),
- 应该在实际工作负载中减少对象大小和内存占用,
- 在目标 64 位平台上不应引入超过 5% 的吞吐量或延迟开销,并且仅在少数情况下,
- 不应在非目标 64 位平台上引入可测量的吞吐量或延迟开销。
当禁用此功能时
- 必须在所有平台上保留原始对象头布局和对象大小,并且
- 不应在任何平台上引入可测量的吞吐量或延迟开销。
此实验性功能将对实际应用产生广泛影响。代码可能存在效率低下、错误和未预料到的非错误行为。因此,此功能默认必须是禁用的,并且只有在用户明确请求时才能启用。我们打算在后续版本中默认启用它,并最终完全移除旧版对象头的代码。
非目标
这不是目标
- 在64位平台上将对象头大小减少到64位以下,
- 减少非目标64位平台上的对象头大小,
- 改变32位平台上的对象头大小,因为它们已经是64位了,或者
- 改变对象内容(即字段和数组元素)或数组元数据(即数组长度)的编码。
动机
存储在堆中的对象具有元数据,HotSpot JVM 将这些元数据存储在对象的头部。头部的大小是恒定的;它与对象类型、数组形状和内容无关。在 64 位 HotSpot JVM 中,对象头部占用 96 位(12 字节)到 128 位(16 字节)之间的空间,具体取决于 JVM 的配置方式。
Java 程序中的对象往往很小。作为 Lilliput 项目的一部分进行的实验 表明,许多工作负载的平均对象大小为 256 到 512 位(32 到 64 字节)。这意味着超过 20% 的活动数据可能仅由对象头占用。因此,即使对象头大小有小幅改进,也能显著减少内存占用、提高数据局部性并减少垃圾回收的压力。早期采用 Lilliput 项目的用户在实际应用程序中进行了尝试,证实活动数据通常减少了 10%–20%。
描述
紧凑对象头是一个实验性功能,因此默认是禁用的。可以通过 -XX:+UnlockExperimentalVMOptions -XX:+UseCompactObjectHeaders
来启用紧凑对象头。
当前对象头部
在 HotSpot JVM 中,对象头支持许多不同的特性:
- 垃圾回收 — 存储转发指针和跟踪对象年龄;
- 类型系统 — 识别对象的类,用于方法调用、反射、类型检查等;
- 锁 — 存储与关联的轻量级和重量级锁相关的信息;以及
- 哈希码 — 一旦计算出,存储对象的稳定身份哈希码。
当前的对象头布局分为标记字和类字。标记字在前,大小为机器地址的大小,包含:
Mark Word (normal):
64 39 8 3 0
[.......................HHHHHHHHHHHHHHHHHHHHHHHHHHHHHHH.AAAA.TT]
(Unused) (Hash Code) (GC Age)(Tag)
在某些情况下,标记字被覆盖为一个单独数据结构的带标签指针:
Mark Word (overwritten):
64 2 0
[ppppppppppppppppppppppppppppppppppppppppppppppppppppppppppppTT]
(Native Pointer) (Tag)
当这一步完成时,标签位描述了存储在头部的指针类型。如果需要,原始的标记字会被保存(移位)到该指针所指向的数据结构中,并且通过解引用指针访问原始头部的字段,即哈希码和年龄位。
类字位于标记字之后。它有两种形式,取决于是否启用了压缩类指针:
Class Word (uncompressed):
64 0
[cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc]
(Class Pointer)
Class Word (compressed):
32 0
[CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC]
(Compressed Class Pointer)
类字节永远不会被覆盖,这意味着对象的类型信息始终可用,因此不需要额外的步骤来检查类型或调用方法。最重要的是,需要该类型信息的运行时部分不必与锁定、哈希和垃圾回收子系统协作,这些子系统可能会改变标记字节。
紧凑的对象头
对于紧凑的对象头,我们通过将压缩形式的类指针纳入标记字中来消除标记字和类字之间的区分:
Header (compact):
64 42 11 7 3 0
[CCCCCCCCCCCCCCCCCCCCCCHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHVVVVAAAASTT]
(Compressed Class Pointer) (Hash Code) /(GC Age)^(Tag)
(Valhalla-reserved bits) (Self Forwarded Tag)
锁定操作不再用带标签的指针覆盖标记字,从而保留了压缩类指针。为了保持对压缩类指针的直接访问,GC 转发操作变得更加复杂,需要一个新的标签位,如下所述。哈希码的大小没有变化。我们为 Project Valhalla 预留了四位以供将来使用。
压缩类指针
今天的压缩类指针将 64 位指针编码为 32 位。它们默认是启用的,但可以通过 -XX:-UseCompressedClassPointers
禁用。然而,禁用它们的唯一原因是一个应用程序加载了超过大约四百万个类;我们还没有看到这样的应用程序。
紧凑对象头需要启用压缩类指针,并且通过改变压缩类指针的编码,将压缩类指针的大小从 32 位减少到 22 位。
锁定
HotSpot JVM 的对象锁子系统有两个级别。
-
轻量级锁 用于被锁定对象的监视器没有竞争、没有调用线程控制方法(
wait()
、notify()
等)且未使用 JNI 锁定的情况。在这种情况下,HotSpot 会将对象头中的标签位从01
(未锁定)原子地翻转为00
(轻量级锁定)。不需要额外的数据结构,也不会使用其他头部位。 -
监视器锁 用于被锁定对象的监视器有竞争、使用了线程控制方法或轻量级锁不足以解决问题的情况。为了表示这种状态,HotSpot 会将对象头中的标签位从
01
(未锁定)或00
(轻量级锁定)原子地翻转为10
(监视器锁定)。监视器锁会创建一个新的数据结构来表示对象的监视器,但与轻量级锁一样,不会使用任何其他头部位。
HotSpot 还支持传统的栈锁定机制。这种轻量级锁定的精神前身通过将对象头复制到线程的堆栈中,并用指向复制头的指针覆盖对象头,从而将锁定的对象与锁定线程关联起来。这对于紧凑的对象头来说是有问题的,因为它会覆盖对象头,从而丢失关键的类型信息。因此,紧凑的对象头与传统锁定不兼容。如果 JVM 被配置为同时使用传统锁定和紧凑的对象头,那么紧凑的对象头将被禁用。
GC 转发
移动对象的垃圾收集器分两步进行:首先,它们复制一个对象并记录其旧副本和新副本之间的映射(即转发),然后使用此映射更新整个堆或特定代中对旧副本的引用。
在当前的 HotSpot GC 中,只有 ZGC 使用单独的转发表来记录转发。所有其他的 GC 都通过覆盖旧副本的头部信息来记录新副本的位置。有两类不同的场景涉及到头部信息。
-
复制阶段将对象复制到一个空闲空间。每个新副本的转发指针存储在旧副本的头部。原始对象头在新副本中保留。从旧副本读取对象头的代码会跟随转发指针找到新副本。
如果将对象复制到其新位置失败,GCs 会安装一个指向对象本身的转发指针,从而使对象成为自转发。对于紧凑的对象头,这将覆盖类型信息。为了解决这个问题,我们通过设置对象头的第三位来表示对象是自转发的,而不是覆盖整个头部。
-
滑动阶段通过在同一空间内将对象滑动到更低的地址来重新定位对象。当堆内存耗尽且没有足够的空间用于复制对象时,通常会这样做。发生这种情况时,会尽最后的努力使用滑动收集来进行完全收集,该过程分为四个阶段:
-
标记 — 确定存活对象的集合。
-
计算地址 — 遍历所有存活对象并计算它们的新位置,即它们将按顺序放置的位置。将这些位置作为转发记录在对象头中。
-
更新引用 — 遍历所有存活对象并将所有对象引用更新为指向新位置。
-
复制 — 实际上将所有存活对象复制到它们的新位置。
第 2 步会破坏原始头部。这对于当前实现也是一个问题:如果头部是有趣的,也就是说,它有一个已安装的身份哈希码、锁定信息等,那么我们需要保留它。当前的 GC 通过将这些头部存储在一个侧表并在 GC 后恢复它们来实现这一点。这种方法效果很好,因为通常只有少数对象具有有趣的头部。对于紧凑的对象头,每个对象都带有有趣的头部,因为现在该头部包含关键的类信息。存储大量保留的头部会消耗大量的本地堆。
为了解决这个问题,我们使用了一种简单的编码方式,可以在对象头的低 42 位中寻址多达 8TB 的堆。当使用 ZGC 以外的收集器时,紧凑的对象头目前与更大的堆不兼容。如果 JVM 被配置为使用大于 8TB 的堆并且不使用 ZGC,则禁用紧凑的对象头。
-
GC walking
垃圾收集器经常通过线性扫描对象来遍历堆。这需要确定每个对象的大小,而这又需要访问每个对象的类指针。
当类指针在头部被编码时,需要一些简单的算术来解码。与垃圾回收遍历所涉及的内存访问成本相比,这样做所需的成本很低。这里不需要额外的实现工作,因为垃圾回收器已经通过一个通用的虚拟机接口访问类指针。
替代方案
-
继续维护 32 位平台 —— 对象头中的标记和类词的大小与机器指针相同,因此 32 位平台上的头已经是 64 位。然而,维护 32 位端口的难度,加上行业从 32 位环境的迁移,使得这一选择从长远来看不切实际。
-
实现 32 位对象头 —— 经过更多的努力,我们可以实现 32 位头。这可能涉及按需为身份哈希码实现侧存储。这是我们的最终目标,但初步探索表明,这将需要更多的工作。该提案记录了一个重要的里程碑,它带来了实质性的改进,我们可以在进一步向 32 位头迈进的过程中以较低的风险交付这些改进。
测试
更改 Java 堆对象的头部布局会涉及许多 HotSpot JVM 子系统:运行时、所有垃圾收集器、所有即时编译器、解释器、可维护性代理以及所有支持平台的特定架构代码。如此大规模的更改需要进行大规模的测试。
紧凑对象头将通过以下方式测试:
- 1–4 级测试,以及可能由供应商提供的更多测试级别;
- SPECjvm、SPECjbb、DaCapo 和 Renaissance 测试套件,用于测试正确性和性能;
- JCStress,用于测试新的锁实现;和
- 一些实际的工作负载。
所有这些测试都将在该功能开启和关闭的情况下执行,使用多种 GC 和 JIT 编译器的组合,并在多个硬件目标上进行。
我们还将提供一组新的测试,用于测量各种对象的大小,例如,普通对象、原始类型数组、引用数组及其头部。
这项实验性功能交付后,性能和正确性的最终测试将是实际工作负载。
风险和假设
-
未来运行时功能需要对象头位 — 该提案没有在头部留下备用位供未来可能需要这些位的功能使用。我们通过与其他主要 JDK 项目(如 Project Valhalla)讨论对象头需求来组织性地降低这种风险。我们在技术上假设,如果未来运行时功能需要,可以进一步缩小身份哈希码和压缩类指针以腾出位。
-
功能代码中的实现错误 — 对于此类侵入性的功能,通常的风险是实现中的错误。虽然大多数测试可能会立即显示头部布局中的问题,但新的锁定和 GC 转发协议的细微之处可能只会在很少的情况下暴露错误。我们通过由组件负责人进行仔细审查并启用该功能运行许多测试来降低这种风险。只要该功能保持实验状态且默认关闭,就不会影响产品。
-
遗留代码中的实现错误 — 我们尽量避免更改遗留代码路径,但某些重构必然会影响共享代码。这即使在功能禁用时也会带来错误的风险。除了仔细审查和测试外,我们还通过防御性编程并尽量避免修改共享代码路径来降低这种风险,即使这意味着功能代码路径需要更多的工作。
-
功能代码中的性能问题 — 当启用功能时,紧凑对象头更复杂的协议可能会引入性能问题。我们通过运行主要基准测试并了解该功能对其性能的影响来降低这种风险。间接访问类指针、使用替代堆栈锁定方案以及采用替代 GC 滑动转发机制都会产生性能成本。只要该功能保持实验状态且默认关闭,就不会影响产品。
-
遗留代码中的性能问题 — 重构遗留代码路径存在轻微的风险,即会以意想不到的方式影响性能。我们通过最小化对遗留代码路径的更改,并表明主要工作负载的性能没有受到实质性影响来降低这种风险。
-
压缩类指针支持 — JVMCI 在 x64 上不支持压缩类指针。我们通过在启用 JVMCI 时禁用紧凑对象头来降低直接风险。长期风险是紧凑头永远不会在 JVMCI 中实现,这将永远阻碍移除遗留头实现。我们认为这种风险的概率较小,因为其他 JIT 编译器支持紧凑对象头而无需侵入性更改。
-
压缩类指针编码 — 如上所述,当前的压缩类指针实现仅限于大约四百万个类。目前,用户可以通过禁用压缩类指针来解决此限制,但如果移除了遗留头实现,则将不再可能这样做。我们通过提供紧凑对象头作为实验功能来降低直接风险;从长远来看,我们打算朝着更高效的压缩类指针编码方案努力。
-
更改低级接口 — 一些直接操作对象头的组件,特别是作为 JVMCI 主要用户的 Graal 编译器,将必须实现新的头部布局。我们通过识别这些组件并在使用这些组件时禁用该功能来降低当前风险。在该功能从实验状态毕业之前,需要升级这些组件。
-
软项目失败 — 存在一个小风险,即与遗留实现相比,该功能具有不可调和的功能回归,例如限制可表示的类数量。相关的风险是,尽管该功能本身提供了显著的性能改进,但也带来了显著的功能限制,这可能导致永远保留新旧头实现的论点。鉴于这项工作的目标是最终取代遗留头实现,我们认为这是一种软项目失败。我们通过仔细检查当前的限制、规划未来的工作以消除这些限制,并寻求早期采用者的报告来识别在投入过多精力之前发现其他风险来降低这种风险。
-
硬项目失败 — 尽管可能性很小,但事实证明紧凑对象头并没有带来切实的实际改进,或者可实现的改进并不足以证明其额外复杂性的合理性。我们通过将新代码路径作为实验性功能来降低这种小风险,从而为将来必要时移除该功能保留一条路径。