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 程序中的对象通常较小。作为 Project Lilliput 的一部分进行的实验表明,许多工作负载的平均对象大小为 256 到 512 位(32 到 64 字节)。这意味着仅凭对象头就可以获取超过 20% 的实时数据。因此,即使对象头大小有一点改进,也可以显著减少占用空间、数据局部性并降低 GC 压力。Project 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)
类字永远不会被覆盖,这意味着对象的类型信息始终可用,因此无需执行额外步骤来检查类型或调用方法。最重要的是,运行时中需要该类型信息的部分不必与锁定、散列和 GC 子系统协作,因为这些子系统可以更改标记字。
紧凑的对象头
对于紧凑的对象头,我们通过将压缩形式的类指针纳入标记字中,消除了标记字和类字之间的划分:
Header (compact):
64 42 11 7 3 0
[CCCCCCCCCCCCCCCCCCCCCCHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHHVVVVAAAASTT]
(Compressed Class Pointer) (Hash Code) /(GC Age)^(Tag)
(Valhalla-reserved bits) (Self Forwarded Tag)
锁定操作不再用标记指针覆盖标记字,从而保留压缩类指针。GC 转发操作变得更加复杂,以便保留对压缩类指针的直接访问,需要一个新的标记位,如下所述。哈希码的大小不变。我们保留了四个位以供Valhalla 项目将来使用。
压缩类指针
当今的压缩类指针将 64 位指针编码为 32 位。默认情况下启用它们,但可以通过 禁用-XX:-UseCompressedClassPointers
。但是,禁用它们的唯一原因是应用程序加载了超过四百万个类;我们还没有看到这样的应用程序。
紧凑对象头需要启用压缩类指针,而且通过改变压缩类指针编码将压缩类指针的大小从 32 位减少到 22 位。
锁定
HotSpot JVM 的对象锁定子系统有两个级别。
-
当锁定对象的监视器无争用、未调用任何线程控制方法(
wait()
、notify()
等)且未使用 JNI 锁定时,将使用_轻量级锁定_01
。在这种情况下,HotSpot 会以原子方式将对象标头中的标记位从(解锁)翻转为00
(轻量级锁定)。无需额外的数据结构,也不使用其他标头位。 -
当锁定对象的监视器存在争用、使用了线程控制方法或轻量级锁定不够充分时,将使用监视器锁定。为了指示此状态,HotSpot 会以原子方式将对象标头中的标记位从_(_
01
unlocked) 或00
(lightweight-locked) 翻转为10
(monitor-locked)。监视器锁定会创建一个新的数据结构来表示对象的监视器,但与轻量级锁定一样,它不使用任何其他标头位。
HotSpot 还支持旧版_堆栈锁定_机制。轻量级锁定的这一精神前身通过将对象头复制到线程的堆栈并用指向头副本的指针覆盖对象头,将锁定的对象与锁定线程关联起来。这对于紧凑对象头来说是个问题,因为它会覆盖对象头,从而丢失关键的类型信息。因此,紧凑对象头与旧版锁定不兼容。如果 JVM 配置为同时使用旧版锁定和紧凑对象头运行,则紧凑对象头将被禁用。
GC 转发
重新定位对象的垃圾收集器分两个步骤进行:首先,它们复制一个对象并记录其新旧副本之间的映射(即_转发_),然后它们使用此映射来更新整个堆或特定代中对旧副本的引用。
在当前的 HotSpot GC 中,只有 ZGC 使用单独的转发表来记录转发。所有其他 GC 都通过使用新副本的位置覆盖旧副本的标头来记录转发信息。有两种不同的场景涉及标头。
-
_复制阶段_将对象复制到空白处。指向每个新副本的转发指针存储在旧副本的头部。原始对象头部保留在新副本中。从旧副本读取对象头部的代码会跟随转发指针指向新副本。
如果将对象复制到新位置失败,GC 会安装一个指向对象本身的转发指针,从而使对象_自我转发_。使用紧凑的对象标头,这将覆盖类型信息。为了解决这个问题,我们通过设置对象标头的第三位来指示对象是自我转发的,而不是通过覆盖整个标头。
-
_滑动阶段_通过将对象向下滑动到同一空间内的较低地址来重新定位对象。这通常在堆内存耗尽且没有足够的空间来复制对象时执行。当发生这种情况时,将尽最后一搏,使用滑动收集进行完整收集,该收集分为四个阶段:
-
标记——确定存活对象的集合。
-
计算地址— 遍历所有活动对象并计算它们的新位置,即它们将被一个接一个地放置在哪里。将这些位置作为转发记录在对象头中。
-
更新引用——遍历所有活动对象并更新所有对象引用以指向新位置。
-
复制——实际上将所有活动对象复制到其新位置。
步骤 2 会破坏原始标头。这对于当前实现也是一个问题:如果标头是_有趣的_,即它具有已安装的身份哈希码、锁定信息等,那么我们需要保留它。当前的 GC 通过将这些标头存储在侧表中并在 GC 之后恢复它们来实现这一点。这种方法效果很好,因为通常只有少数对象具有有趣的标头。使用紧凑的对象标头,每个对象都带有一个有趣的标头,因为现在该标头包含关键的类信息。存储大量保留的标头将消耗大量本机堆。
为了解决这个问题,我们使用了一种简单的转发指针编码,它可以在对象头的低 42 位中寻址高达 8TB 的堆。当使用除 ZGC 之外的收集器时,紧凑对象头目前与更大的堆不兼容。如果 JVM 配置为使用大于 8TB 的堆并且不使用 ZGC,则紧凑对象头将被禁用。
-
GC 步行
垃圾收集器经常通过线性扫描对象来遍历堆。这需要确定每个对象的大小,而这又需要访问每个对象的类指针。
当类指针在标头中编码时,需要进行一些简单的算术来解码它。与 GC 遍历所涉及的内存访问成本相比,这样做的成本很低。这里不需要额外的实现工作,因为 GC 已经通过通用 VM 接口访问类指针。
替代方案
-
继续维护 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 滑动转发机制都会产生性能成本。只要此功能仍处于实验阶段且默认关闭,此风险就不会影响产品。
-
遗留代码中的性能问题— 重构遗留代码路径可能会以意想不到的方式影响性能,这种风险很小。我们通过尽量减少对遗留代码路径的更改并表明主要工作负载的性能不会受到实质性影响来降低此风险。
-
压缩类指针支持— x64 上的 JVMCI 不支持压缩类指针。我们通过在启用 JVMCI 时禁用紧凑对象头来降低当前风险。长期风险是 JVMCI 中永远不会实现紧凑头,这将永远阻止删除旧头实现。我们认为这种风险发生的概率很小,因为其他 JIT 编译器支持紧凑对象头,而无需进行侵入性更改。
-
压缩类指针编码— 如上所述,压缩类指针的当前实现仅限于大约 400 万个类。目前,用户可以通过禁用压缩类指针来解决此限制,但如果我们删除旧式标头实现,则将不再可能。我们通过提供紧凑的对象标头作为实验性功能来减轻当前的风险;从长远来看,我们打算努力实现更高效的压缩类指针编码方案。
-
更改低级接口— 一些直接操作对象头的组件(尤其是作为 JVMCI 的主要用户的 Graal 编译器)将必须实现新的头布局。我们通过识别这些组件并在使用这些组件时禁用该功能来降低当前的风险。在该功能结束实验状态之前,这些组件将需要升级。
-
软项目失败— 与旧实现相比,该功能存在不可调和的功能倒退,例如限制可表示类的数量,这种风险很小。相关风险是,虽然该功能本身提供了显着的性能改进,但它具有显着的功能限制,这可能会导致永远保留新旧标头实现的争论。鉴于这项工作的目标是最终取代旧标头实现,我们认为这是一个软项目失败。我们通过仔细检查当前的限制、规划未来工作以消除这些限制以及在投入过多精力之前查看早期采用者报告以识别其他风险来降低这种风险。
-
严重的项目失败— 虽然可能性很小,但紧凑的对象头可能不会带来切实的实际改进,或者可实现的改进不足以抵消其额外的复杂性。我们通过将新代码路径设为实验性来降低这一小风险,从而为在必要时在未来版本中删除该功能留出余地。