JVM常用概念之扁平化堆容器

扁平化堆容器是OpenJDK Valhalla 项目提出的,其主要目标为将值对象扁平化到其堆容器中,同时支持这些容器的所有指定行为,从而达到不影响原有功能的情况下,显著减少内存空间的占用(理想条件下可以减少24倍)。

1.前言

1.1.容器

Java 变量可以是堆对象内的字段,也可以是堆数组内的数组元素;这些是可能的堆变量。还有非堆变量:它们是线程堆栈上活动框架内的局部变量或堆栈元素。

容器只是 Java 语言或 Java VM 定义的变量的实现。

容器这个术语关注的是变量的底层、具体的实现特征,而不是其逻辑的、指定的行为。

所有 Java 和 VM 变量(除原始类型的变量外,如int)都指定为引用,并且必须按引用的方式运行。除非有任何特殊约定,否则它们将初始化为null,并以指针的形式读取和写入。如果将它们类型化为基于值的类(或 Valhalla 值),则即使在竞争条件下,它们的连续值也会表现出完全一致的行为。

尽管其逻辑行为使变量看起来好像只包含对一系列连续分配的对象(和/或 null)的单个引用,但 Valhalla 允许将变量底层的容器展平。尽管它的行为类似于指针,但它的格式类似于 int 变量。如果值有多个字段,它的行为就像几个变量(int、引用等);这些变量彼此靠近存储,并且都在展平的容器内。容器可能还具有表示空引用的逻辑存在的策略,即使容器在物理上根本没有引用。(在 Valhalla 之前,每个变量的逻辑特征及其类型或多或少直接决定了容器的物理结构。(也有例外,例如标量化优化,它会将堆对象重新组织到线程寄存器中。)这就是为什么 "容器"一词主要在 Valhalla 的设置中有用。)

我们不会过多谈论非堆容器的实现,因为它们由 JIT 代码或解释器管理。幸运的是,它们永远不会受到竞争条件的影响,因为这样的容器仅由创建它的线程使用。您可以将值的非堆容器视为多个机器寄存器和/或线程堆栈位置,通常彼此不相邻。

位于堆中意味着容器位于 Java 堆上的封闭存储块、对象或数组中。通常,存储块的标识是动态计算的。(静态字段是例外;它们的包含块是常量。)但块内的容器必须具有相对于块的可预测位置。它还必须具有可预测的静态定义格式,以便从在多个线程中运行的多个代码位置高效地访问值。与格式(对于任何给定的容器)相关联的是访问方法,用于将值对象(或 null)写入或读出(或可能是 CAS)堆容器的过程。

具体来说就是堆位置是动态计算的。除静态字段外,任何给定的访问都可能是对许多动态选择的封闭对象或数组之一的访问。相比之下,堆栈或局部变量是线程私有的,只能通过一个方法调用访问,并且位于堆栈框架内,激活后不再进行进一步的动态选择。

并且堆位置是静态格式化的。为了使访问方法平等地应用于许多动态选择的位置,所有这些位置必须具有通用布局,以便访问方法能够工作。这要求 VM 发明此类布局(与访问方法相结合)并将它们适当地分配给不同类型的容器。请注意,final、volatile 或 normal 值字段可能具有不同的布局和访问方法。此外,可空和非可空值容器可能具有不同的布局和访问方法。根据约定,每个不同的堆栈或局部变量都可以由特定于该变量的 JIT 代码直接在寄存器或控制堆栈位置中操作。局部变量需要布局(或访问方法)之类的东西的唯一时间是当它必须跨越方法调用边界(作为参数或返回值)并且方法调用未内联时。然后应用 VM 的调用序列规则。

那什么是访问方法?访问方法可以被认为是 VarHandle 方法的底层体现。它可以读取、写入,甚至 CAS 容器中的值。访问方法需要(作为参数)包含容器的存储块的基地址。如果容器是数组,则访问方法还需要索引来识别所有数组元素中的哪个容器。

最后让我们回顾在 Valhalla 之前,堆容器只有少数几种固定格式。如果容器的类型不是原语(字节、双精度等),它只是指向另一个堆对象的指针(当然,也可以是空值)。因此,每个 Valhalla 之前的堆容器都可以非常舒适地装入机器字中。这种轻松和舒适是以指针在多个堆块中移动为代价的。即使一个对象只有一个字节字段,将其放入另一个对象的容器中也是一件大事,可能需要 64 位用于容器在堆中保存指向它的指针,还需要另外 128 位用于分配堆块以包含该字节字段。Valhalla 可以简单地将单个"有效负载"字节放入容器中,而无需指针或块,从而将占用空间减少高达 24 倍(在这种极端情况下)。

1.2.一致性

Java 语言和 VM 都保证从变量读取始终保持一致,这是 Java 内存模型定义的特定意义,除非存在降低一致性的特殊规定(在 Valhalla 之前这是不可能的)。如果引用存储在变量中,则只能观察到与先前引用相关联的类字段组合。没有办法"凭空"创建一个引用,或者以某种方式混合其他两个引用的特征。即使多个线程争相读取变量中的值,变量也必须将每个连续的变量值与所有其他值分开。

在 Valhalla 中,当容器在物理上被扁平化时,事情会变得更加困难。Valhalla 值在逻辑上仍然是存储在变量中的引用。虚拟机字节码指令将每个连续的值作为单个自洽对象引用存储到变量中。但是,如果多字段值类在容器中被扁平化,则值的物理字段可能会通过单独的硬件指令分别写入容器的存储中。如果线程同时读取和写入容器,则某个线程可能会观察到一个值包含从不同逻辑值获得的不同物理字段值的混合,这些值是在不同的时刻写入的。这种现象称为逐字段撕裂。这是 Java 内存模型保证的一致性的一种特殊故障。

因此,一个非常合理的物理活动可能会产生一个不合理的逻辑结果,即一个"撕裂"的值类实例,凭空而来,没有人真正构建过,而且(大概)没有人愿意看到。我们确实希望声称扁平化容器是对基于值的类的优化,现在成为值类。但如果它通过逐个字段撕裂凭空创建新值,那么这种优化就会被破坏。

值得注意的是这里没有任何内容表明单个字段可能因竞争而损坏,无论是在 Valhalla 之前还是之后。值或任何其他对象内的单个字段值永远不会因竞争而撕裂。作为一个狭隘的例外,可变的 long 或 double 字段可能会显示字撕裂,但这对于值来说是不可能的,因为 long 或 double 将存储在值类的安全发布的最终字段中。同样,如果对象未安全发布,字段的预初始化值(空或零)也可能会在竞争下泄漏,这是一种看起来像字段撕裂的不一致。但对于值来说,这同样是不可能的,因为发布规则对它们更加严格。这些限制与当前对灵活构造函数主体的工作相结合;值必须始终在调用 super 之前初始化其字段,而不是之后。

完全一致性是指多字段值对象的所有字段必须作为一个单元进行读取和写入,即整个值对象。(也就是说,值对象的竞争不会比类似的基于值的类对象多。)完全一致性的反义词是字段松散一致性 ,或简称为松散一致性,这是 Valhalla 中新增的一个容器属性,它允许竞争条件混合和匹配来自不同逻辑写入的不同物理字段值。

在 Valhalla 之前,读取或写入类值对象([基于值的类])的唯一方法是加载或存储单字机器地址,即指向类值对象的不变实例的指针。(它也可以为空。)Java 内存模型 (JMM) 规定,不可能通过这种加载的指针读取不一致的字段值。因此,所有 Valhalla 之前的类值对象都作为一致单元进行读取和写入(通过物理指针)。在 Valhalla 之前始终存在完全一致性。

关于一致性可以详细阐述的还是有很多,正如上述所示,这就是为什么 Valhalla 之前的规范没有过多谈论一致性(我们在这里使用该术语的方式)。一个例外是长整型和双整型基元的字拆分,这是 Valhalla 中字段拆分的前身。字拆分的工作方式就像将长整型作为一个值类分成两个 32 位字段,然后对其进行逐字段拆分一样。

那么,如果不让值看起来不一致,Valhalla 之前的竞争还能做什么呢?有两点:不同的线程可以在不同的时间读取不同的指针值,并且值的出现顺序可能并不总是完全合理。此外,如果引用指向可变对象(而不是基于值的类),则对该对象字段的读取可能会变得彼此不一致,这仅仅是因为竞争线程可能会读取一个字段的"旧"值和另一个字段的"新"值,尽管某种方法正在努力同时一致地更新字段。

具体来说,想象一个堆中的复数值对象,由具有两个字段 {re=0,im=1} 的"旧"写入写入。现在想象一个存储 {re=2,im=3} 的"新"写入,以及读取变量的不同线程。该线程可以观察到什么?至少,我们不会期望"凭空而来"的结果:永远不会有 re=99,而只有 re=0 或 re=2,对于 im=1、im=3 也是如此。但在某些硬件上,如果不特别注意一致性,线程可能会读取旧字段值和新字段值的"撕裂"混合:{re=0,im=3} 或 {re=2,im=1}。请注​​意,撕裂的值 {re=0,im=3} 从未写入过,因此如果应用程序可以读取它,那就有些可疑了,而且撕裂的值可能会间歇性出现,这甚至更可疑。虽然对于复数来说这可能是可以接受的行为,但这种逐字段撕裂可能会破坏具有微妙内部不变量的值对象的完整性。 (例如,间隔对象可能要求其结束字段大于其起始字段,但撕裂会破坏这个不变量。)这就是为什么 Valhalla 中的值默认是完全一致的。

在硬件层面,现代 CPU 承诺在一条指令中加载或存储(甚至 CAS 操作)值时,将原子性保持在 64 位(对齐)以内,有时保持在 128 位(再次对齐)以内。这与我们的一致性概念有关:如果您将机器字视为由字节构成的值(好像每个字节都是一个值字段),则原子加载或存储将保持完全一致性:即使在最具挑战性的竞争条件下也不会出现"字节撕裂"。当读取和写入是原子时,它们不会在读取或写入字时拆分字,并且字的所有位和字节都会同时更改,无论哪个线程正在观察该字。但是,如果两个 32 位写入指令将两个字段存储到一个 64 位变量中,则无法保证原子性,并且可能会发生某种字节撕裂。

现代 CPU 并不保证任何多条写入指令序列的原子性。通常没有适用于多条写入指令的"锁定前缀",以使它们的内存效果成为全有或全无的事务。英特尔的 TSX 功能(据称可以做到这一点)显然没有成功。我们只能使用 64 位原子性,或者最多(并且有性能风险)128 位原子性。

在 Valhalla 原型中,实验性值类注释 @LooselyConsistentValue 拒绝完全一致性,允许 VM 使用更快的访问方法,但可能会导致字段撕裂。

1.3.什么是扁平化?

扁平化是对容器的任何重组,它用一组直接包含值数据的子字段替换单个指针,指向其他地方的某些值数据。

也就是说每个子字段都实现了值的字段。之所以称为子字段,是因为值本身位于某个较大对象的字段中。子字段是字段的字段。如果声明值包含值,则可以嵌套此关系。但在某种意义上,子字段相当于顶级包含对象的字段,并且它属于存储在对象中的扁平值这一事实是一种观点。

举例说明,示列代码如下:

java 复制代码
value record Complex(double re, double im) { }
class TwoValues {
  Complex a, b;
  // layout: 
  //   a: {double, double}
  //   b: {double, double}
  // null channels (explained elsewhere):
  //   a.NC: byte, b.NC: byte;
}

此示例对象的布局与具有四个双精度字段和两个字节字段的 Valhalla 前期对象的布局大致相同。但由于它嵌套了值,因此它包含两个容器,每个容器都被展平为两个子字段和一个合成空通道

由于多种原因,扁平化堆容器对 VM 实现者来说是一个挑战。光是记录在哪里使用哪种格式就已经够难的了。让加载和存储的速度至少与"经典"基于指针的加载和存储一样快是件棘手的事。尽量减少碎片损失(以免削弱 Valhalla 减少占用空间的承诺)也是一个挑战。多个线程对扁平表示的访问必须服从现有允许的竞争(最多没有新的竞争),这是一个非常微妙的挑战。

但是,当包含的值对象包含大约一个机器字的字段状态时,扁平化尤其棘手,并且容器承诺值的字段保持一致(即值以原子方式更新),并且容器允许空引用作为与值本身的任何实例不同的状态。

这听起来像是一系列不太可能发生的不幸事件,但实际上这很可能是常见的情况。假设 Long 被迁移为 Valhalla 值类型。(如果不是,假设用户定义一个与 Long 一样的值类型。无论如何,假设我们在一台具有 64 位原子内存操作的机器上。)任何 Long 字段或数组元素,如果要展平,都需要表示 Long.value 字段的所有 2^64 个值,该字段的类型为 long(请注意小写 l)。

可以想象空引用到底有多难?当值类的非空实例完全耗尽了我们希望使用的容器格式的表示能力时,空引用就会出现问题。我们使用 Java 类型 long 的运行示例,它有 2^64 个值,没有一个与空值相同。因此,一个 long 变量(如果不是可空的)可以很轻松地放入 64 位机器字中。可空的 long(大写 Long)就不那么合适了,因为它必须表示一个额外的可能值,与所有 2^64 个非空值不同。在这种情况下,我们需要找到一种方法来适应空值。

2.项目进度

截止2024年,经过多次迭代,Valhalla 草案设计(部分定义见JEP 401)允许容器(字段或数组元素)声明它们是否容忍 null,至少在它们被声明为值类时是这样。也就是说,所有值类都支持该类的可空和无空变量。值类还可以声明其实例是否容忍额外的逐字段撕裂竞争,这是对 VM 的让步,使布局值变得更容易"就像结构一样"。当然,每个值类也否认任何对象身份的要求,这是扁平化的先决条件。

空值限制字段使用字段注释进行声明,空值限制数组使用特殊工厂进行创建。容忍字段撕裂的类使用类注释进行声明。这些功能是临时的、私有的权宜之计,用于在 JDK 中进行实验。在更成熟的 Valhalla 版本中,它们可能会被用户可见的语言功能取代。具体来说,空值限制可以表示为一种称为"类型限制"的新型正式字段属性,它接受字段的声明类型,但随后指定禁止该类型允许的某些值(例如空值)的其他约束。

2.1 复数示例

松散一致(逐字段可撕裂)值类的一个简单示例是 Complex。在 C 等语言中,复数值通常会在多线程竞争下撕裂:竞争可能会混合旧的实数和新的虚数分量,反之亦然。这在数字代码中几乎从来都不是问题,但在数字代码中,防止撕裂的成本几乎总是不受欢迎的。因此,Java Complex 类很可能以一种容忍对其实数和复数分量进行"撕裂"更新的方式声明。

2.2.不容忍的示例

不能容忍松散一致性的值类的示例是内存引用对象(如 Panama FFM API 中的对象),它由基地址和访问该地址内存的安全大小组成。(大小用于强制性安全检查,将越界访问转换为 Java 异常,而不是内存故障。)将新的更大大小与旧基地址相结合的恶意混合可能允许 Java 代码使用该对象读取或写入越界内存地址。一个更简单的例子是具有两个界限的范围数据类型,其中上限必须始终超过下限;恶意混合可能会创建向后范围。

请注意,即使值类(例如Complex)声明它容忍松散一致性,该类的变量也可以使用 Java 关键字声明volatile。这将禁用该特定容器的撕裂,无论该类的容忍度如何。声明的变量会不惜volatile一切代价保持字段一致性,并确保对重新排序的额外约束。这些约束是并发控制的一个独特方面,在机器代码中通过使用隔离和禁用某些重新排序优化来体现。

与扁平化和可空性相关的联锁选项是 Valhalla 用户模型的一部分,如下图所示。

草案设计还允许将 final 字段排除在竞争条件之外,足以让它们即使在非 final 字段不容易展平的设置中也能展平。这要求 final 字段必须比 Valhalla 之前的设计"锁定"得更严格一些,使用严格 final 字段的概念(标记为 ACC_STRICT)。VM 可以证明严格字段永远不会被观察到发生变化,即使在构造封闭对象时也是如此。