文章

Java 值类详解:统一、托管、简洁

十年磨一剑,铸造统一、托管、简洁的 Java 值类体系

Java 值类详解:统一、托管、简洁

Java 值类详解

概述

对于关注 Java 发展的开发者来说,Project Valhalla 这个名字一定不陌生。 它是 OpenJDK 近年来最重要的项目之一,旨在为 Java 引入高性能的值类型以及特化泛型等功能。

在 Project Valhalla 开发了十一年后,Java 值类的设计终于稳定了下来,并且很有可能在 Java 27 中开始预览。

之所以值类会跳票这么久,主要原因是 Java 选择了一条与 C# 等传统语言截然不同的设计道路。 由于缺少参考,Java 一直在探索和试验各种设计方案,在 2023 年才确定了最终的设计方向并发展至今。

相比 C# 等语言的传统值类型设计,Java 值类的独特之处我觉得可以总结成三点:

  • 统一
  • 托管
  • 简洁

在这篇文章中,我将从这三点切入,一步步向大家详细介绍 Java 值类的用法、设计理念和实现细节。

Project Valhalla 是一个庞大的项目。除了即将预览的值类与值对象(JEP 401), 还包括了后续的不可空类型、特化泛型等多个相关 JEP。

本文将不局限于即将预览的值类功能,也会涵盖其他几个 JEP 的部分内容, 这些内容在 Java 27 中可能尚不可用,需要未来几个版本逐渐落地。

值类三大特点

统一性

首先我要介绍的是 Java 值类的第一个特点:统一性。

在此之前,我们先简单了解一下 Java 定义值类的语法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// 在 class 前加上 value 关键字来声明值类
public value class Point {
    private final int x;
    private final int y;

    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }

    public int x() {
        return this.x;
    }

    public int y() {
        return this.y;
    }
}

// 同理,在 record 前加上 value 关键字也可以声明值记录
public value record Point(int x, int y) {
}

在 Java 中定义值类非常简单,只需要在 classrecord 关键字前加上关键字 value 即可。 值类的实例我们称之为值对象(value object)。

value 是一个上下文关键字,只有出现在 classrecord 前面才有特殊意义。 使用 value 作为变量名、方法名或类名依然合法。

这个语法虽然看起来没什么特别的,但其实已经暗藏了玄机: Java 没有引入 struct 这样的关键字来代替 class,而是用 value 来修饰 classrecord

Java 之所以这样设计,是因为 Java 的 value class 和普通的 class 语义上是高度一致的。

首先,Java 的值类是引用类型,遵循引用语义。这是 Java 值类统一性最显著的体现。

作为引用类型,Java 的值类类型默认也是可以为 null 的,所以这样的代码是合法的:

1
Point point = null; // 合法

而且 JVM 可以以极其高效的手段来表示 null,并不需要把它实现成一个指针。

Project Valhalla 也会带来大家期待已久的不可空类型。 但这个特性不局限于值类,而是统一的适用于所有引用类型, 只要给类型后面加上 ! 就可以标注为不可空类型:

1
2
3
4
5
Point!     _ = new Point(0, 0);  // Point! 表示不可空的 Point 类型
String!    _ = "Hello World!";   // String 不是值类,但也可以标注为不可空类型
String[]!  _ = new String[10];   // 元素可空,但数组本身不可空
String![]  _ = null;             // 元素不可空,但数组本身可空
String![]! _ = new String![]{};  // 元素和数组都不可空

在赋值和传参时,值对象和普通对象语义也完全一致,都遵循引用语义,没有任何可观测的区别,不需要思考“传递值”和“传递引用”的区别:

1
2
3
4
// 不管 Point 是值类还是普通类,语义上完全没有区别
Point p1 = new Point(1, 2);
Point p2 = p1;
fun(p1);

在继承关系上,Java 值类也是 Object 的子类,所以这些代码也是完全合法的:

1
2
3
4
5
6
7
8
9
10
// 可以直接把 Point 对象赋值给 Object 变量,
// 语义上不需要“装箱”这个概念
Object obj = new Point(1, 2);

// 同样的,Object 变量也可以被强制转换回 Point 类型,
// 语义上也不需要“拆箱”这个概念
Point _ = (Point) obj;

// 数组协变也同样适用于值类型
Object[] _ = new Point[10];

而且,虽然值类默认是 final 的,但也可以显式通过 abstract 关键字定义成抽象值类。 值类和普通类都可以继承抽象值类:

1
2
3
4
5
6
7
8
// 值类可以声明为抽象类 
public abstract value class Number { ... }

// 值类可以继承抽象值类
public value class Integer extends Number { ... }

// 普通类也可以继承抽象值类
public class BigInteger extends Number { ... }

Java 还进一步弥合了基本类型值和对象之间的差异,让 int 基本等价于 Integer!int 值可以看作是 Integer 类型的值对象,进一步让 Java 类型系统统一,实现了“一切皆对象”:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
// 现在 int 值也可以调用所有 Integer 上定义的方法了
long   _ = 100.longValue();

// int[] 和 Integer![] 可以互换使用
Integer![] _ = new int[10];
int[] _ = new Integer![10];

// int[] 现在也是 Object[] 的子类型了
Object[] _ = new int[10];

// 返回 int 的方法也可以覆盖返回 Object 或者 Integer 的方法
interface Option {
    String name();
    Object value();
}
interface IntOption extends Option {
    @Override
    int value();
}

托管性

Java 值类的第二个重要性质,我认为是托管性。

很多语言的值类型会暴露出大量和底层内存布局相关的细节,将控制权交给用户, 让用户手动控制值分配在哪里,控制什么时候发生复制、怎么进行复制等一系列细节。

这些语言通过让用户手动控制的方式来提高性能上限,但这和托管语言的设计理念是背道而驰的, 而且也会让语言变得更加复杂。

而 Java 的值类设计理念和这些语言完全相反。

作为一种托管语言,Java 追求的是让程序员将一切交给 JVM 管理, 而 JVM 应当自动把程序优化到和 C/C++ 一样或更高效。

Java 设计值类的出发点在于反思为什么 JVM 在对象内存布局方面失职了,思考到底是什么东西阻碍了 JVM 优化对象布局,导致 Java 对象的内存布局经常劣于 C/C++ 的 struct

通过思考这个问题,Java 的设计者们得出了结论:对象暴露给用户太多功能了,而就是因为这些功能才导致 JVM 难以对内存布局进行优化。

所以,Java 的值类不仅没有暴露更多细节和权限给用户让用户手动控制, 而是反过来,通过限制值类和值对象的功能来减少对 JVM 的约束, 让 JVM 能够自动地把值对象优化的比 C/C++ 的 struct 更加紧凑高效。

也因为如此,Java 的值类型拥有了第三个重要性质:简洁性。

简洁性

Java 的值类暴露给用户的概念是极其简洁的。

Java 不需要你去思考对象该分配在哪里,不需要思考你怎么管理和消除对象复制,它只需要你思考一件事:这个功能我用不用得上?

如果一个类,需要包含可变字段、需要在它的实例上使用 synchronized 等功能,那么它就应该是普通类; 如果不需要,那么它就应该是值类。就是这么简单。

此外,你还可以继续放弃一些功能,本文的下一节会详细说明这些可以被放弃的功能。

总之,你只需要知道,对象的功能和性质越少,JVM 就越能把它优化的更高效。 所以你只需要把自己用不上的功能都放弃掉就好了,剩下的让 JVM 来处理就行。

一切皆对象的统一设计也让用户不用思考引用语义和值语义之间的差异, 尤其是泛型代码也不需要一套新的概念来处理两种语义之间的差异, 不需要处理 refreadonly 等东西与泛型之间复杂的交互, 一切就像过去那样简单,怎么处理对象就怎么处理值对象就好了。

虽然缺乏让用户手动控制的能力会在一些时候限制程序的优化潜力, 但反过来,暴露过多控制能力也会限制编译器的优化能力。

Java 隐藏了底层实现细节后,编译器拥有了巨大优化潜力,在必要时能做出很多手动优化时很难实现的优化, 比如对对象布局进行熔化重组,从基本值和指针中窃取标识位, 甚至可以用一些技巧来用只有 32 位的字段来表示有 2^32+1 个状态的可空 Integer 等等, 这些都是 C/C++ 等语言难以自动实现的优化。

Java 的设计者对这些做了大量研究,未来的 JVM 会逐步实现更多优化, 本文后续章节也会介绍其中一部分优化。

但在此之前,让我们先了解对象的哪些功能和性质阻碍了 JVM 优化内存布局, 值类又是怎么允许用户放弃它们的。

是什么阻碍了 JVM 优化内存布局?

对象身份

当你在把一个类声明为值类时,实质上你是放弃了这个类所有实例的对象身份(object identity)

在过去,Java 中每个对象都有一个独一无二的身份,这个身份让你能够区分两个对象实例。

比如对于这样一个 Point 类:

1
2
3
4
5
6
7
8
9
public final class Point {
    public int x; 
    public int y;
    
    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
}

我们可以用 new 来创建两个实例,它们的值都是 (1, 2),并将他们分别赋值给 p0p1 变量:

1
2
var p0 = new Point(1, 2);
var p1 = new Point(1, 2);

随后,我们创建了第三个变量 px,它一半可能性被赋值为 p0,另一半可能性被赋值为 p1

1
var px = Math.random() < 0.5 ? p0 : p1;

此时,我们能不能知道 px 指向的是 p0 还是 p1 呢?

答案当然是能,而且我们有不止一种办法能够判断:

  1. == 对于普通的对象比较的是对象地址,我们可以用 px == p0 来简单的判断 pxp0 指向的是不是同一个对象。

    1
    
    assert px == p0;
    
  2. Java 中有一个叫做 System.identityHashCode(Object) 的方法,它是 ObjecthashCode() 方法的默认实现。

    一个对象的 identityHashCode 是唯一且不变的,两个不同对象的 identityHashCode 大概率不同,所以我们也可以通过比较 p0pxidentityHashCode 来判断它们是不是同一个对象。

    1
    
    assert System.identityHashCode(px) == System.identityHashCode(p0);
    
  3. 我们刚刚定义的这个 Point 类的字段是可变的,所以我们还可以通过 p0 修改对象的字段,然后观察 px 指向的对象字段值有没有跟着变化, 要是它跟着变了,那说明 pxp0 指向的肯定就是同一个对象:

    1
    2
    
    p0.x = 100;
    assert px.x == 100;
    
  4. Java 普通对象支持通过 synchronized 关键字来加锁,所以我们也可以通过 p0 对对象加锁,然后通过 px 观察锁定状态来判断它们是不是同一对象:

    1
    2
    3
    
    synchronized (p0) {
        assert Thread.holdsLock(px);
    }
    

过去 JVM 无法自动优化对象内存布局,其中罪魁祸首就是这个对象身份。

为什么这么说呢?我们可以看看这个例子,假如我们有这样一个 MyClass 类:

1
public record MyClass(Point point) {}

理想情况下,我们应该把它的布局展开成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
public final class MyClass {
    // 把 Point 直接展开成两个 int 字段
    private final int point$x;
    private final int point$y;

    public MyClass(Point point) {
        this.point$x = point.x;
        this.point$y = point.y;
    }

    public Point point() {
        return new Point(this.point$x, this.point$y);
    }
}

但是,如果 JVM 真的这样做了,下面的代码就会出问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
var point = new Point(1, 2);
var myClass0 = new MyClass(point);
var myClass1 = new MyClass(point);

// 如果展开了 Point,那么 `point()` 方法每次都会返回一个新的 Point 实例,
// 所以下面的断言就会失败
assert myClass0.point() == myClass1.point();

// 同样的,两个 Point 实例的 identityHashCode 大概率会不同,导致断言失败
assert System.identityHashCode(myClass0.point()) == System.identityHashCode(myClass1.point());

// 通过 point 修改字段时,没有办法让两个 MyClass 实例的 point 字段都跟着变化,断言失败
point.x = 100;
assert myClass0.point().x == 100;
assert myClass1.point().x == 100;

// 通过 synchronized 关键字加锁时,无法让两个 MyClass 实例的 point 字段都跟着上锁,断言失败
synchronized (point) {
    assert Thread.holdsLock(myClass0.point());
    assert Thread.holdsLock(myClass1.point());
}

大多数时候,JVM 难以了解你是否会在未来某刻需要一个对象的身份,所以只能保守地维护每个对象的身份。

而实现对象身份最简单的方式,那就是在堆上给一个对象分配一块内存。 这样每个对象都有独一无二的地址,== 可以实现成地址比较,identityHashCode 和锁都可以放在这块内存里, 修改字段也自然能从所有引用观察到。

而将一个值类声明为 value class 时,你就放弃了这个对象的身份。

我们把 Point 换成值类,再来看看我们是否能区分两个值对象:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
public value class Point {
    // 值类的所有字段都是隐式 final 的
    public int x; 
    public int y;
    
    public Point(int x, int y) {
        this.x = x;
        this.y = y;
    }
}

var p0 = new Point(1, 2);
var p1 = new Point(1, 2);

// 对于值类而言,== 会递归比较所有字段值,所以下面的断言成立
assert p0 == p1;

// 值类的 identityHashCode 也是基于字段值计算的,所以下面的断言也成立
assert System.identityHashCode(p0) == System.identityHashCode(p1);

// 值类的字段是隐式 final 的,所以下面的代码编译就报错了
// p0.x = 100; // 编译错误

// 值类不支持 synchronized 关键字,所以下面的代码编译也报错了
// synchronized (p0) { // 编译错误:requires a type with identity
//     assert Thread.holdsLock(p0);
// } 

// 可以把值类转换成 Object 类型来绕过编译时检测,但运行时会抛出异常
// synchronized ((Object) p0) { // Exception java.lang.IdentityException
//     assert Thread.holdsLock(p0);
// } 

可以看到,所有能把两次 new Point(1, 2) 出的对象区分开的手段都失效了。

所以,对于值对象来说,只要类型一样,并且所有字段的值都一样,那它们就是同一个对象,没有任何办法区分它们。

在这种情况下,JVM 可以轻易地把 MyClass 优化成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
public record MyClass(Point point) {}

// 优化后的布局
public final class MyClass {
    // 用来标记 Point 是否为 null 的字段
    private final byte nullChannel;

    // 把 Point 直接展开成两个 int 字段
    private final int point$x;
    private final int point$y;

    public MyClass(Point point) {
        if (point == null) {
            this.nullChannel = 0;
            this.point$x = 0;
            this.point$y = 0;
        } else {
            this.nullChannel = 1;
            this.point$x = point.x;
            this.point$y = point.y;
        }
    }

    public Point point() {
        return nullChannel != 0 
            ? new Point(this.point$x, this.point$y)
            : null;
    }
}

现在我们终于能让 JVM 优化对象的内存布局了。

但我们看到这里相比我们理想中的布局多了一个 nullChannel 字段, 而这个字段的作用是标识 point 字段是不是 null

如果我们不需要 point 可以为 null 的话,那么我们可以通过不可空类型来放弃掉字段的可空性。

可空性

在前面我们已经说过,Project Valhalla 也会带来不可空类型这个特性。

Java 的不可空类型类似于 Kotlin,它适用于所有引用类型,只需要用 ! 来标注。

对于 MyClass 来说,我们只需要将其标注为不可空类型,就可以让 JVM 放弃掉 nullChannel 字段:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public record MyClass(Point! point) {}

// 优化后的布局
public final class MyClass {
    // 不再需要 nullChannel 字段
    private final int point$x;
    private final int point$y;

    public MyClass(Point point) {
        this.point$x = point.x;
        this.point$y = point.y;
    }

    public Point point() {
        return new Point(this.point$x, this.point$y);
    }
}

不同于 C#,Java 中不可空的类型通常是没有默认值的。

对于值类,Java 也保证只要不用 Unsafe 黑魔法,对象就一定是通过调用构造器构造出来的, 不用担心 JVM 会绕过构造器凭空创建一个默认值对象, 更不用担心会像 C# 结构体默认值那样会给一个不可空类型的字段赋值为 null

因为没有默认值,所以 Java 编译器和 JVM 会强制你初始化所有不可空类型的字段和数组元素:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public class Holder {
    Point! point;
    
    public Holder() {
        // 编译器会要求你必须初始化 point 字段,否则会直接报错
        this.point = new Point(0, 0);
    }
}

// 编译错误,数组必须显式初始化每个元素
// var _ = new Point![10];

// 合法,显式初始化了每个元素
var _ = new Point![]{ new Point(0, 0), new Point(1, 1) };

// 合法,通过新语法(待定)更简单地填充数组
var _ = new Point![10] { i -> new Point(i, i) } ;

构造器可控性

有些类只是几个元素的简单聚合,构造器只是简单的初始化每个值,没有任何复杂逻辑。 它们对构造器是否可控没有需求,反而更希望有一个默认值。

对于这种类,Java 允许你声明一个隐式构造器:

1
2
3
4
public value record Point(int x, int y) {
    // 用 implicit 关键字声明隐式构造器
    public implicit Point();
}

在声明了隐式构造器后,Point! 也将拥有默认值:

1
2
3
4
5
6
7
8
9
10
11
// 合法,因为现在 Point! 也有默认值
Point![] points = new Point![10]; 

// Point 的默认值是 (0, 0)
assert points[0] == new Point(0, 0);

class Holder {
    // 现在不需要显式初始化 point 字段了
    // point 字段会被初始化为默认值 (0, 0)
    Point! point;
}

隐式构造器不允许有方法体,意味着你放弃了对它的控制权,由 JVM 来实现它。

JVM 提供的实现会等价于将该类的所有字段初始化为它们的默认值:

  • byte/short/int/long/float/double 字段初始化为 0
  • char 字段初始化为 '\u0000'
  • boolean 字段初始化为 false
  • 可空引用类型字段初始化为 null
  • 对于不可空引用类型字段,如果这个类型具有隐式构造器,那么就用隐式构造器初始化它,否则会直接编译错误

在语义上来说,没有任何值凭空诞生,这些默认值都是通过调用隐式构造器创建出来的。 只是 JVM 很有可能会把隐式构造器实现的极其高效,甚至没有任何额外开销。

一致性

除了上面提到的,值类关心的另一个性质是一致性(consistency)

我们可以看看这个例子:

1
2
3
4
5
6
7
8
9
10
11
public value record Range(long start, long end) {
    public Range {
        // 我们需要保证 start <= end
        if (start > end) {
            throw new IllegalArgumentException("start must be <= end");
        }
    }

    // 静态的字段,初始值为 [0, 0)
    public static Range! global = new Range(0, 0);
}

在这里例子中,我们定义了一个 Range 类,在其中包含一个静态的字段 global

此时,global 是很难被优化成两个静态的 long 字段的。为什么呢?我们看看这段代码:

1
2
3
4
5
// 我们在另一个线程中尝试将字段修改为 [114, 514)
Thread.startVirtualThread(() -> Range.global = new Range(114L, 514L));

// Range.global 要么是 [0, 0),要么是 [114, 514)
assert Range.global == new Range(0L, 0L) || Range.global == new Range(114L, 514L);

在这段代码里,我们尝试在另一个线程中将 global 字段修改为 [114, 514)

在这个过程中,JVM 需要保证这个字段的一致性:global 要么是 [0, 0),要么是 [114, 514)

这个看起来简单,但是如果 Range 被优化成两个静态的 long 字段的话,那么就可能出现这种情况:

1
2
3
4
5
6
7
8
9
10
// global 被优化成两个静态的 long 字段
static long global$start = 0L;
static long global$end = 0L;

Thread.startVirtualThread(() -> {
    // 写入 global 的过程变成了两步操作
    global$start = 114L;
    global$end = 514L;
});

global 的写入变成了两步操作。如果在这两次赋值的过程之间尝试读取 global 字段,那么就可能读取到 [114, 0) 这个值。

这是一个非常大的问题,因为 [114, 0) 并不是通过 new 构造出来的值,而是绕过了构造器凭空诞生的。

我们通常把这种现象叫做字段撕裂,而字段撕裂就会产生不一致的状态。

我们在构造器里做的校验对不一致状态的对象完全无效,这会导致出现一个无效的 Range,并可能在未来某处地方引发难以调试的错误。

幸运的是,Java 并不会让这种情况发生,它默认会保证所有值对象的一致性,保证它们都是通过调用构造器创建出来的。

JVM 保证值对象的一致性的具体手段有这些:

  • 对于 final 字段,无需特别保证一致性
  • 对于小于 64 位的字段,JVM 往往会用单条指令读写它们,零开销地保证一致性
  • JVM 可以在字段旁放置一个锁,通过加锁来保证读写的一致性
  • JVM 可以像处理普通对象一样,在堆上分配值对象,把字段实现成一个指针来保证一致性

前面两种手段可以说基本没有开销,但后两种手段都会带来不小的开销。

对于 Range 来说保证一致性很重要,不然就会出现无效的区间,所以我们提高性能的最佳手段就是尽量用 final 字段,避免可变字段。

但对于很多值类来说,我们并不需要总是保证一致性。对于它们,我们可以放弃完全一致性,让 JVM 有更多优化空间:

1
2
3
4
5
// 通过实现 LooselyConsistentValue 接口来放弃完全一致性
public value record Point(long x, long y) implements LooselyConsistentValue {
    // 隐式构造器是必要的。语义上来说,字段撕裂也是通过调用隐式构造器产生的
    public implicit Point();
}

实现了 LooselyConsistentValue 接口后,JVM 就不需要保证这个类型的值对象的完全一致性,所以就算是可变字段也能轻松优化布局了。

另外,实现该接口的值类必须声明隐式构造器,因为 Java 不允许凭空诞生对象,所以字段撕裂在语义上也是通过调用隐式构造器产生的。

值类型的内存布局

在了解了值类在 Java 语言层面上的设计后,我们再来看看 JVM 是如何实现值类的内存布局的。

无头对象

最简单的把值对象嵌入父对象的布局方式,就是把值对象实现成无头对象。

在过去,对于这样一个 Java 类:

1
public record Point(int x, int y) {}

它的内存布局通常是这样的:

1
2
3
4
5
6
7
8
struct Point {
    // 对象头,包含类型指针、哈希码、锁等信息
    ObjectHeader header;

    // 字段值
    int32_t x;
    int32_t y;
};

相比 C/C++ 结构体,Java 对象不仅需要存储字段值,还需要一个对象头来存储 GC 元数据、类型指针、锁状态等信息。

而所谓的无头对象,就是把这个对象头去掉,只保留字段部分,然后嵌入到父对象中。

比如对于这样一个类:

1
public record Line(Point! start, Point! end) {}

如果 Point 是值类,那么 JVM 可以把 Line 的对象布局实现成这样:

1
2
3
4
5
6
7
8
9
10
11
12
13
struct Line {
    ObjectHeader header;

    struct {
        int32_t x;
        int32_t y;
    } start;

    struct {
        int32_t x;
        int32_t y;
    } end;
};

这是最简单也是最常使用的布局方式,通常和 C/C++ 中等价的 struct 布局一致。

熔化重组布局

虽然无头对象实现起来简单,但很多时候并不是最优的内存布局。

考虑这样一个值类:

1
public value record ByteAndLong(byte b, long l) {}

它有一个 byte 字段和一个 long 字段,有效信息量是 9 字节。但是使用无头对象布局时,它的内存布局通常是这样的:

1
2
3
4
5
struct ByteAndLong_Body {
    int8_t  b;          // 1 字节
    int8_t padding[7];  // 7 字节
    int64_t l;          // 8 字节
}

可以看到,它实际需要 16 字节才能存储 9 字节的信息,浪费了将近一半的内存空间。

之所以这里存在 7 字节的 padding 空间,是因为 CPU 想要高效地访问内存中的数据的话,往往需要数据的地址满足特定的对齐要求。

对于一个 8 字节整数 long 来说,通常它需要让自己在内存中的地址能够被 8 整除。 JVM 需要在 ByteAndLong 结构体中插入 7 字节的填充才能保证 long 字段满足这个条件。

对于更复杂的类型,这种内存浪费会更加严重。比如对于这样一个类:

1
2
3
4
5
public value record Pack(
    boolean flag,
    ByteAndLong! f0, 
    ByteAndLong! f1
) {}

如果使用无头对象布局,那么它的内存布局应该是这样的:

1
2
3
4
5
6
struct Pack_Body {
    bool             flag;          // 1 字节
    int8_t           padding[7];    // 7 字节
    ByteAndLong_Body f0;            // 16 字节
    ByteAndLong_Body f1;            // 16 字节
};

一个 Pack 对象需要 40 字节的内存,但它实际有效内容只有 19 字节(1 + 9 + 9),浪费的空间超过了一半。

对于 C/C++ 等语言,这种问题很难自动解决,但对于 JVM 来说,它可以通过对对象布局进行熔化重组来提高内存利用效率。

JVM 可以选择将 Pack 中的 ByteAndLong 打散,然后重新排列字段顺序来减少填充:

1
2
3
4
5
6
7
8
struct Pack_Body {
    bool  flag;          // 1 字节
    int8_t  f0_b;        // 1 字节
    int8_t  f1_b;        // 1 字节
    int8_t  padding[5];  // 5 字节
    int64_t f0_l;        // 8 字节
    int64_t f1_l;        // 8 字节
};

在对对象布局进行熔化重组后,ByteAndLong 内部的对齐填充不再存在,它们的 byte 字段可以放到 flag 后的对齐填充中, 让整个 Pack 对象从 40 字节减少到 24 字节,内存利用率大幅提升。

这种优化对于 C/C++ 等语言来说很难自动实现,因为它们暴露了太多布局细节。 比如支持指向结构体成员的指针就很大程度上限制了结构体存在多种不同布局的可能性。

但 Java 隐藏了对象实际布局细节,尽可能减少了对 JVM 的限制,留给了 JVM 更大的发挥空间,这样才能实现更多自动优化。

交替分块数组

对于值对象数组,Java 调查过几种不同的内存布局方式。

除了最简单的将无头对象连续排列外,JVM 的另一个选择是将几个元素分为一组,将相同的字段放在一起。 这种实现方式叫做交替分块数组(alternating blocked array)

举个例子,对于上面的 ByteAndLong 类来说,JVM 可以这样实现 ByteAndLong![] 数组的内存布局:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 交替分块数组 (ABA) 的布局
struct ByteAndLong_ABA {
    ObjectHeader header;
    int32_t      length;

    // 每块 4 个元素,总共占 40 字节
    // 平均每个元素占 10 字节
    struct {
        int8_t  b[4];           //  4 bytes
        // int8_t  padding[4];  //  4 bytes
        int64_t l[4];           // 32 bytes
    } blocks[DIV_ROUND_UP(length, 4)];
};

在这样的实现中,相邻的四个 ByteAndLong 元素会被分为一块, 其中 byte 字段会被放在一起,long 字段也会被放在一起。 这样对齐填充从每个元素 7 字节减少到平均每个元素 1 字节, 对于大型数组来说内存占用会大幅减少。

此外,有些代码可能会频繁访问数组中所有元素的某个字段,比如这样:

1
2
3
4
long count = 0L;
for (ByteAndLong item : array) {
    count += item.l;
}

对于这类代码,使用交替分块数组也能提高缓存局部性,从而提高性能。

空通道

在说完值类的内存布局后,我们最后看看 Java 是怎么高效地表示值对象的 null 的。

Java 通过一种叫做空通道(null channel) 的技术来高效地表示值对象的 null

这个空通道,也可以叫做“空标记”、“空字节”,你可以理解成一个抽象的 isNotNull 布尔变量, 作用是标记一个值类类型变量是不是 null

它可以被实现为物理上真正的一个 boolean 字段,但它也可以被实现为利用变量中无效状态,或者利用对齐填充来实现。 JVM 会尽可能在不增加额外内存开销的情况下实现空通道。

利用字段中无效状态

很多类型的变量并不是内存中的所有状态都会被实际使用,JVM 可以利用这些字段中无效的状态来表示空通道。

一个典型的例子就是普通的引用类型的变量,它基本上会被实现成一个类似 C/C++ 中指针的东西。

但是,指针并不是所有值都指向一个有效的对象地址。 比如 Java 分配的对象从是从某个地址开始的,而接近 0 地址的一个范围内所有地址都永远不会分配给实际的对象。

所以,JVM 会保留地址值从 1N(N 是某个小整数)的一些地址值作为准空值(quasi-null), 并分别称呼它们为 quasinull0quasinull1quasinull2 等等。

利用这些准空值,JVM 就能地为一些值类实现 null

比如 Optional 类在未来将会成为这样的 value class

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
public value class Optional<T> {
    public static <T> Optional<T> empty() {
        return new Optional<>(null);
    }

    public static <T> Optional<T> of(T value) {
        return new Optional<>(Objects.requireNonNull(value));
    }

    private final T value;
    
    private Optional(T value) {
        this.value = value;
    }

    // ...
}

而 JVM 可以把 Optional<Optional<String>> 这样的类型优化成单个指针。

1
2
3
4
5
Optional<Optional<String>> opt;
opt = null;                                     // 底层值:null
opt = Optional.empty();                         // 底层值:quasinull0
opt = Optional.of(Optional.empty());            // 底层值:quasinull1
opt = Optional.of(Optional.of("Hello World!")); // 底层值:"Hello World!"

因为 JVM 保留了 N 个 quasinull 值,所以 Optional 类型可以嵌套 N 层而不需要额外的内存开销, 超过 N 层后(现实中基本不可能发生),JVM 也能回退到堆上分配来实现。

此外,boolean 变量通常占据 1 字节,但实际上它只有两个有效状态:truefalse, 所以每个 boolean 变量中都存在 254 个无效状态可以被利用。

因此,JVM 也可以轻松地用 1 字节表示可空的 Boolean 变量:

1
2
3
4
Boolean flag;
flag = null;    // 底层值:0
flag = false;   // 底层值:1
flag = true;    // 底层值:2

对于本身可空的 Optional<Boolean> 这样的类型,JVM 也能轻松地用 1 字节表示:

1
2
3
4
Optional<Boolean> boolBox = null;     // 底层值: 0
boolBox = Optional.empty();           // 底层值: 1
boolBox = Optional.of(Boolean.FALSE); // 底层值: 2
boolBox = Optional.of(Boolean.TRUE);  // 底层值: 3

这样利用字段无效状态的能力不仅限于有单个成员的值类。 只要一个值类包含 boolean 或普通引用类型字段,JVM 就能利用这些字段中的无效状态来表示空通道:

1
2
3
4
5
public value record IntAndRef(int i, Object ref) {}

IntAndRef var = null;                   // 底层值: (0, null)
var = new IntAndRef(0, null);           // 底层值: (0, quasinull0)
var = new IntAndRef(42, new Object());  // 底层值:(42, Object@xxxxxxxx)

利用对齐填充

一些类可能不包含 boolean 或普通引用类型字段。 对于这些类,JVM 也会尝试利用对齐填充浪费的空间来实现空通道。

我们还是用这个 ByteAndLong 类来举例:

1
public value record ByteAndLong(byte b, long l) {}

我们知道,这样的类在 byte 字段和 long 字段之间通常会有 7 字节的对齐填充, JVM 就可以在这段被浪费的空间中放置一个独立的变量作为空通道:

1
2
3
4
5
6
struct ByteAndLong_Body_Nullable {
    int8_t  b;              // 1 字节
    int8_t  nullChannel;    // 1 字节,空通道
    int8_t  padding[6];     // 6 字节
    int64_t l;              // 8 字节
}
1
2
3
ByteAndLong var = null;              // 底层值: nullChannel = 0, b = 0, l = 0
var = new ByteAndLong((byte) 0, 0L); // 底层值: nullChannel = 1, b = 0, l = 0
var = new ByteAndLong((byte) 1, 2L); // 底层值: nullChannel = 1, b = 1, l = 2

在这种实现中,JVM 虽然增加了一个额外字段,但它只是利用了对齐填充中浪费的空间, 并不会增加额外的内存开销。

外部空通道

即便 JVM 已经尽力利用值对象中的无效状态和对齐填充来实现空通道, 但还是有一些类内部确实没有冗余状态可以被利用。 比较典型的例子是 Integer,其内部所有状态都是有意义的。

对于这种类,JVM 通常需要在变量外部增加额外的独立空通道:

1
public value record IntBox(Integer value) {}
1
2
3
4
5
struct IntBox_Body {
    int8_t  value_nullChannel;  // 1 字节,空通道
    int8_t  padding[3];         // 3 字节
    int32_t value;              // 4 字节
};

从整数中窃取空通道

Java 设计者们还研究了一些更激进的从整数中窃取空通道的技术。

比如对于一个 int/long 变量来说,绝大多数值都被取到的概率都极低, 所以 JVM 可以考虑随机选择一个整数 x,并通过位运算(比如用 x ^ 1 翻转最低位)再选择另一个整数 x', 让它们具有相同的二进制表示,这样就能窃取出一个状态来实现空通道。

对于恰好真的要用到 xx' 的情况,JVM 可以通过一张全局的表来区分它到底是 x 还是 x'。 这样在绝大多数情况下,JVM 都能用 32 位来存储具有 2^32+1 种状态的可空 Integer 变量。

结语

在这篇文章里我想分享的就这些了,之后我打算再写一篇文章来介绍一下 Project Valhalla 的发展历史, 让大家了解这十年里 Java 值类型的设计是怎么一步步演进到现在这个样子的。

希望大家喜欢这篇文章!

本文由作者按照 CC BY 4.0 进行授权