Skip to content
欢迎扫码关注公众号

JEP 450: Compact Object Headers (Experimental) | 紧凑对象头(实验性)

概述

减少 HotSpot JVM 中对象头的大小,在 64 位架构上从 96 到 128 位降低至 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% 的活跃数据。因此,即使是对象头大小的小幅改进也可能显著减少占用空间、改善数据局部性并减轻 GC 压力。早期采用 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 转发操作变得更加复杂,需要一个新的标签位,如下所述。哈希码的大小没有变化。我们为 Project Valhalla 未来使用预留了四个位。

压缩类指针

如今的压缩类指针将 64 位指针编码成 32 位。它们默认启用,但可以通过 -XX:-UseCompressedClassPointers 禁用。唯一可能需要禁用它们的原因是应用程序加载了超过约四百万个类;我们尚未见到这样的应用案例。

紧凑对象头要求启用压缩类指针,并且通过更改压缩类指针编码将其大小从 32 位减少到 22 位。

锁定

HotSpot JVM 的对象锁定子系统有两个层次。

  • 轻量级锁定 当锁定对象的监视器无争用、未调用线程控制方法(wait()notify() 等),并且未使用 JNI 锁定时使用。在这种情况下,HotSpot 原子地将对象头中的标签位从 01(未锁定)翻转为 00(轻量级锁定)。不需要额外的数据结构,也不使用其他头部位。

  • 监视器锁定 当锁定对象的监视器有争用、使用了线程控制方法或轻量级锁定不适用时使用。为了指示这种状态,HotSpot 原子地将对象头中的标签位从 01(未锁定)或 00(轻量级锁定)翻转为 10(监视器锁定)。监视器锁定创建一个新的数据结构来表示对象的监视器,但与轻量级锁定一样,不使用任何其他头部位。

HotSpot 还支持传统的 栈锁定 机制。作为轻量级锁定的精神前身,这种方法通过将对象头复制到线程的栈并用指向头副本的指针覆盖对象头来关联锁定对象和锁定线程。这对紧凑对象头来说是个问题,因为它会覆盖对象头,从而丢失关键类型信息。因此,紧凑对象头与传统锁定不兼容。如果 JVM 配置为同时运行传统锁定和紧凑对象头,则会禁用紧凑对象头。

GC 转发

执行对象重定位的垃圾收集器分为两个步骤进行:首先,它们复制一个对象并在其旧副本和新副本之间记录映射(即 转发),然后使用此映射更新整个堆或特定代中对旧副本的引用。

在当前的 HotSpot GCs 中,只有 ZGC 使用单独的转发表来记录转发信息。所有其他 GC 通过用新副本的位置覆盖旧副本的头部来记录转发信息。涉及头部的情况有两种不同的场景。

  • 复制阶段 将对象复制到空闲空间。每个新副本的转发指针存储在旧副本的头部。原始对象头被保留在新副本中。从旧副本读取对象头的代码会跟随转发指针到达新副本。

    如果将对象复制到新位置失败,GC 会在对象本身上安装一个转发指针,从而使对象成为 自转发。对于紧凑对象头,这会覆盖类型信息。为了解决这个问题,我们通过设置对象头部的第三位而不是覆盖整个头部来指示对象是自转发。

  • 滑动阶段 通过将对象向低地址滑动在同一空间内重新定位对象。通常在堆内存耗尽且没有足够的空间用于复制对象时执行。发生这种情况时,会做出最后的努力,尝试使用滑动收集来进行全收集,该过程分为四个阶段:

    1. 标记 —— 确定活动对象集。

    2. 计算地址 —— 遍历所有活动对象并计算它们的新位置,即它们一个接一个放置的地方。将这些位置作为转发信息记录在对象头中。

    3. 更新引用 —— 遍历所有活动对象并更新所有对象引用以指向新的位置。

    4. 复制 —— 实际上将所有活动对象复制到它们的新位置。

    第二步破坏了原始头部。这对当前实现也是一个问题:如果头部是 有趣的,也就是说它包含已安装的身份哈希码、锁定信息等,则我们需要保留它。当前的 GC 通过将这些头部存储在一个侧表中并在 GC 后恢复它们来解决这个问题。这通常效果很好,因为通常只有少数几个对象具有有趣的头部。对于紧凑对象头,每个对象都有一个有趣的头部,因为现在这个头部包含了关键的类信息。存储大量保留的头部会消耗相当数量的本地堆。

    为了解决这个问题,我们使用了一种简单的转发指针编码方式,可以在对象头的较低 42 位中寻址高达 8TB 的堆。当使用除 ZGC 之外的收集器时,紧凑对象头目前与大于 8TB 的堆不兼容。如果 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,用于测试新的锁定实现;以及
  • 某些真实世界的工作负载。

所有这些测试都将在启用和禁用该功能的情况下执行,并结合多种垃圾收集器和 JIT 编译器组合,在多个硬件目标上进行。

我们还将提供一组新的测试,测量各种对象(例如普通对象、基本类型数组、引用数组及其头部)的大小。

一旦此实验性功能发布,其性能和正确性的终极测试将是真实世界的工作负载。

风险和假设

  • 未来运行时特性需要对象头位 —— 此提案未在头部为未来可能需要此类位的特性留下备用位。我们通过与其他主要 JDK 项目(如 Project Valhalla)讨论对象头需求,在组织层面上缓解了这一风险。我们还假设,如果未来运行时特性需要这些位,则可以通过进一步缩小身份哈希码和压缩类指针来释放位,从而在技术层面缓解风险。

  • 功能代码中的实现错误 —— 对于这样的侵入性特性,通常的风险是实现中的错误。虽然大多数测试可能会立即暴露头部布局的问题,但新锁定和 GC 转发协议中的细微差别可能导致仅偶尔暴露的错误。我们将通过组件所有者的仔细审查和在启用该功能的情况下运行大量测试来缓解这一风险。只要该功能保持实验状态且默认关闭,这种风险就不会影响产品。

  • 遗留代码中的实现错误 —— 我们尽量避免更改遗留代码路径,但某些重构必然会影响共享代码。即使在功能被禁用的情况下,这也暴露出出现错误的风险。除了仔细审查和测试外,我们还通过防御性编程和尝试避免修改共享代码路径来缓解这一风险,即使这意味着要在功能代码路径中做更多的工作。

  • 功能代码中的性能问题 —— 紧凑对象头更复杂的协议可能会在启用该功能时引入性能问题。我们将通过运行主要基准并理解该功能对其性能的影响来缓解这一风险。间接访问类指针、使用替代栈锁定方案以及采用替代 GC 滑动转发机制都有性能成本。只要该功能保持实验状态且默认关闭,这种风险就不会影响产品。

  • 遗留代码中的性能问题 —— 存在一个较小的风险,即重构遗留代码路径会以意想不到的方式影响性能。我们通过最小化对遗留代码路径的更改并展示主要工作负载的性能没有受到重大影响来缓解这一风险。

  • 压缩类指针支持 —— 在 x64 上,JVMCI 不支持压缩类指针。我们通过在启用 JVMCI 时禁用紧凑对象头来立即缓解这一风险。长期风险在于 JVMCI 中永远不会实现紧凑头部,这将永远阻碍移除遗留头部实现。由于其他 JIT 编译器支持紧凑对象头而无需侵入式更改,我们为此风险分配了一个较低的概率。

  • 压缩类指针编码 —— 如上所述,目前压缩类指针的实现限制在大约四百万个类。目前,用户可以通过禁用压缩类指针来解决这个限制,但如果删除遗留头部实现,那么这将不再可能。我们通过提供紧凑对象头作为一个实验性功能来缓解当前风险;长远来看,我们计划致力于更高效的压缩类指针编码方案。

  • 更改低级接口 —— 直接操作对象头的一些组件,特别是 Graal 编译器作为 JVMCI 的主要用户,将不得不实现新的头部布局。我们通过识别这些组件并在这些组件使用时禁用该功能来缓解当前风险。在该功能从实验状态毕业之前,这些组件需要升级。

  • 软项目失败 —— 存在一个较小的风险,即与遗留实现相比,该功能存在无法调和的功能退步,例如限制可表示的类数量。相关风险是,虽然该功能本身提供了显著的性能改进,但它也带来了显著的功能限制,这可能导致一种论点,即永远保留新的和遗留的头部实现。鉴于这项工作的目标是最终替换遗留头部实现,我们认为这是一种软项目失败。我们通过仔细检查当前限制、规划未来工作以消除它们,并在投入过多努力之前查看早期采用者报告以识别其他风险来缓解这一风险。

  • 硬项目失败 —— 尽管非常不可能,但紧凑对象头可能不会带来切实的现实世界改进,或者可实现的改进不足以证明其额外的复杂性。我们通过将新代码路径作为实验性功能加以限制,从而保持在未来版本中移除该功能的可能性,以此来缓解这一小风险。