Valhalla (6): 解读 JEP 401 Primitive Objects

解读 JEP 401 Primitive Objects (Preview) ,原始对象。

以直译为主,部分内容会意译,一些地方会增加自己的解读。一些概念解释见 深入理解 Valhalla (0): 序言

系列文章(未完待续)

摘要 Summary

增强 Java 对象模型,允许声明原始类(Primitive Class),原始类的实例称为原始对象(Primitive Object)。这样的对象没有 Identity,可以直接存储和传递,无需对象头和间接寻址。该提案将会是一项预览特性。

目标 Goals

该 JEP 会对 Java 语言和虚拟机进行重大的改动,包括:

  • 允许声明原始类
    • 原始类是 Identity-free 的(见概念解释)
    • 原始类的实例称为原始对象
    • 明确原始对象在比较、同步和其他依赖 Identity 的操作时的行为(Identity-related Behavior)
  • 提供无缝的转换机制,允许同时对原始值类型(Primitive Value Type)和原始引用类型(Primitive Reference Type)操作
    • 原始值类型不需要间接寻址就可以存储和传递(类似于 int
    • 原始引用类型(在处理原始对象时)允许多态(polymorphism)和空引用(null references)(类似于 Integer
    • 提供无缝的转换机制(类似于自动装箱/拆箱)

Codes like a class, works like an int

非目标 Non-Goals

该 JEP 只关注原始类和原始类型的声明,不包含其他 Java 语言的改进,但有一些特性会并行开发:

  • JEP 402 统一基本类型和对象
    • 基本类型(intboolean 等)的值将会被视为原始对象
    • 包装类(IntegerBoolean 等)将会被视为原始类
    • 基本类型的值是包装类的实例(例如 true instanceof Boolean
  • 一个独立的 JEP 将修改 Java 的泛型机制,允许原始值类型作为类型参数

除了上述 JEP,后续一个非常重要的改进是 JVM 需要根据不同原始值类型的内存布局(layouts)生成特化(specialize)的泛型类和字节码。

后续还可能增强现有的 API 以便更好的使用原始对象的特性,或者引入构建在原始对象之上的语言特性和 API。

动机 Motivation

Java 中包含两种类型的值,基本类型(例如数字和布尔值)和引用类型。

基本类型提供更高的性能,可以直接(没有对象头和指针)存储在变量、栈和 CPU 寄存器中。因此访问内存不需要额外的间接寻址,基本类型数组也会紧密且连续地存储在内存中。基本类型也不需要 GC,相关操作都直接在 CPU 内部完成。

引用类型提供更好的抽象,包括字段、方法、访问控制、实例验证、命名类型和子类型多态。同时也是有 Identity 的,支持字段修改、加锁等操作。

在某些领域,开发人员需要基本类型提供的高性能,但代价是必须放弃面向对象中一些有意义的抽象。这可能会导致一些 bug,例如错误的解释无类型的数字,或者错误的处理异构数据的数组(火星气候探测者号的失败极大程度地说明了此类 bug 的潜在成本)。

理想情况下,我们希望 JVM 在运行面向对象的代码时,也能够类似基本类型的性能。不幸的是,尽管很多对象并不需要,但对象的 Identity 是这类性能优化的主要阻碍。在不考虑 Identity 的情况下,JVM 就可以按照基本类型一样处理这些对象(直接存储在变量上和直接在 CPU 内操作)。

具体不需要 Identity 并且可以提升性能的例子:

  • 没有作为基本类型的数字,例如无符号数字、128 位整数和半精度浮点数。
  • 点(Points)、复数、颜色、向量和其他多维数字(矩阵)。
  • 带有单位的数字,例如大小、变化率、温度、货币等。
  • 日期和时间的抽象,包括大量 java.time 包中的类。
  • 元组(Tuples)、记录(record)、Map 中的 entries、数据库行和多返回值。
  • 不可变的 cursors、子数组、中间流(intermediate streams)和其他数据结构视图的抽象。

同样我们可以期待,随着更多对原始对象的操作的实践,新的编程范式和 API 设计也会不断发展。

描述 Description

以下描述是预览中的特性,需要在编译和运行时增加 --enable-preview 参数

原始对象和原始类 Primitive Objects and Classes

原始对象是一种没有 Identity 的类的实例。也就是说,原始对象没有固定的内存地址或者其他属性,能够在所有字段值相同的情况下区分不同的实例。原始对象字段值是不能修改的,也不能进行同步操作(synchronized)。在原始对象执行 == 操作是比较所有字段的值。实例为原始对象的类被称为原始类。

Identity 对象是有 identity 的类的实例或者数组,有传统 Java 中对象的行为,Identity 对象可以修改非 final 的字段值,并且可以关联同步监视器(执行 synchronized)。在 Identity 对象执行 == 操作是比较它们的 Identity(引用)。实例为 Identity 对象的类被称为 Identity 类。

原始类声明 Primitive Class Declarations

类可以通过 primitive 上下文关键字声明为原始类,这样的类会隐式声明为 final 的并且不能是 abstract 的。
除了原始类和抽象类之外的类就是 Identity 类。例如

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
primitive class Point implements Shape {
private double x;
private double y;

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

public double x() { return x; }
public double y() { return y; }

public Point translate(double dx, double dy) {
return new Point(x+dx, y+dy);
}

public boolean contains(Point p) {
return equals(p);
}
}

interface Shape {
boolean contains(Point p);
}

原始类声明受到如下的限制(要区分实例字段/静态字段):

  • 所有的实例字段都被隐式声明为 final 的,只能在构造函数、初始值、初始化代码块中被赋值一次
  • 所有的实例字段都不能是直接或间接依赖当前类的原始值类型(定义见后文)。换句话说,除了引用类型的字段,类的布局必须是扁平的(flat)、固定大小、没有循环依赖的
    • 简单来说,就是在编译的时候,需要能够计算出对象的内存布局
  • 不能直接或间接实现 IdentityObject 接口(见下文),这意味着父类(们)只能是 Object 或者无状态的抽象类
  • 构造函数不能调用 super 的构造函数。实例的创建不能执行任何父类的初始化代码。
  • 所有的实例方法都不能声明 synchronized
  • (可能)不能实现 Cloneable 接口或者声明 clone() 方法
  • (可能)不能声明 finalize() 方法
  • (可能)在所有字段都初始化之前是不能使用 this 的(除了通过 this 设置变量)

其他大多数的声明,原始类和 Identity 类的声明方式是一样的。可以实现多个接口、支持类型参数、内部类、重载构造函数、静态成员和所有的访问控制修饰符。

原始对象使用 Working with Primitive Objects

原始对象创建方式就是普通的实例创建表达式。

1
Point p1 = new Point(1.0, -0.5);

原始类实例字段和方法访问方式也是一样的。

1
Point p2 = p1.translate(p1.y(), 0.0);

原始类可以访问父类或接口中的方法,也可以覆盖它们。实例可以赋值给父类或接口类型。

1
2
3
System.out.println(p2.toString());
Shape s = p2;
assert !s.contains(p1);

上面几个操作没什么区别

== 操作将比较两个原始对象的字段值,而不是对象的 Identity。基本类型字段会按位比较,其他的字段会递归通过 == 比较。

1
2
assert new Point(1.0, -0.5) == p1;
assert p1.translate(0.0, 0.0) == p1;

equalshashCodetoString,包括 System.identityHashCode 在相等的行为定义上是一致的。

1
2
3
4
5
Point p3 = p1.translate(0.0, 0.0);
assert p1.equals(p3);
assert p1.hashCode() == p3.hashCode();
assert System.identityHashCode(p1) == System.identityHashCode(p3);
assert p1.toString().equals(p3.toString());

在原始对象上进行同步 synchronized 会抛异常。

1
2
3
Object obj = p1;
try { synchronized (obj) { assert false; } }
catch (RuntimeException e) { /* expected exception */ }

两个接口 The PrimitiveObject and IdentityObject Interfaces

新增了两个必要的接口

  • java.lang.PrimitiveObject
  • java.lang.IdentityObject

所有的原始类都隐式实现了 PrimitiveObject 接口,所有的 Identity 类都隐式实现了 IdentityObject 接口,包括之前 Java 生态中所有的类,数组仍然是 IdentityObject 的子类型。

这些接口通过如下三种方式帮助区分 Identity 对象和原始对象

  • instanceof IdentityObject / instanceof PrimitiveObject 可以判断一个对象是否有 Identity,使用 Class 的反射方法也是一样的
  • IdentityObject / PrimitiveObject 类型的变量可以分别保存有/没有 Identity 的对象
  • extends IdentityObject / extends PrimitiveObject 的类型变量上界可以要求类型变量分别有/没有 Identity

接口可以显式的继承 IdentityObject 或者 PrimitiveObject 以便要求所有的实现类有/没有 Identity,如果一个类最终显式、隐式或者通过继承,同时实现了两个接口,那么就会发生错误。默认情况下,一个接口是不继承任何一个的上述接口的,可以同时被两种具体的类实现。

类似的,抽象类也可以显式实现 IdentityObject 或者 PrimitiveObject ,但如果声明了字段、初始值、初始化代码块、非空构造函数或同步方法(synchronized),就相当于隐式实现了 IdentityObject 接口(可能会有警告信息)。否则一个抽象类不实现任何一个尚书接口,可以同时被两种具体的类继承。

java.lang.Object 不实现任何一个接口,相当于(可能会被显式指定)是一个抽象类,如上所述,具体的子类可以实现任何一个接口。在调用 new Object() 将会被重新解释为初始化一个 Identity 的 Object 的子类型(名字待定)。

如果最终采用这个方案,那么意味着 java.lang.Object 变成一个抽象类,但同时可以调用 new Object()
而且 new Object().getClass() != Object.class(可能)。
意味着会内置一个类 class NameTBD implements IdentityObject {}
new Object() 会被重新解释为 new NameTBD()
因为需要兼容,这个应该会发生在运行时。

原始值和引用 Primitive Values and References

原始对象可以作为原始值(Primitive Values)存储在变量中、进行直接访问、无需对象头和指针,这些值的类型称为原始值类型(Primitive Value Type)。

原始对象也可以像引用类型一样存储和操作,这些引用的类型称为原始引用类型(Primitive Reference Type)。

因此每一个原始类关联两个类型,原始值类型和原始引用类型。原始类的实例可以直接或者间接通过引用处理,这取决于使用的类型。

原始值类型 Primitive Value Types

原始类的类名表示这个类的原始值类型(可以声明 reference-favoring 类,这样类名表示引用类型,见下文讨论)。跟传统的类型不同,原始值类型不是对象的引用,而是对象本身。因此有两个重要的结果

  • 原始值类型的变量可以直接存储对象的字段值,不需要对象头或者指针
  • 原始值类型的变量不能为 null

Codes like a class, works like an int

原始值类型是单态(monomorphic)的,所有该类型的值都是同一个类的实例,并且有相同的布局。

如果 Point 是原始类,那么
Point point = ...;
Points points = [...];
不管两个变量怎么赋值,因为是单态的,所以一定有
point.getClass() == points[0].getClass() == Point.class
不知道通过反射构造 Object 对象会怎么样

原始类的创建表达式(new)的类型是原始值类型,类中的 this 表达式的类型也是原始值类型。
如上所示,原始值类型允许访问字段和方法,也支持通过 == 和 !== 操作来比较两个同类型的值。
原始值类型的表达式是不能用在同步(synchronized)语句中的。

原始类型(intbooleandouble 等)被认识一种不同的类型,不受该 JEP 的影响。

原始值类型的默认值 Default Values of Primitive Value Types

每个原始值类型都有一个默认值,用于初始化字段和数组的元素。引用类型的字段默认值是 null,其他类型字段的默认值是 0 或者 false 或者默认值。默认值是该类的默认实例(default 实例),所有字段值都是其类型的默认值。Point.default 表达式用来指代原始类 Point 的默认实例。

1
2
3
assert new Point(0.0, 0.0) == Point.default;
Point[] ps = new Point[100];
assert ps[33] == Point.default;

注意默认实例的创建,不需要调用任何构造函数、执行初始化赋值和初始化代码块。能访问该类,就可以访问默认实例(除了 Enforcing 实例验证,见下文)。原始类也不能自行定义默认实例,修改其字段的默认值。

引用类型 Reference Type

之前引用类型的变量保存对象的引用或者为 null,但现在引用的对象可以是 Identity 对象或者原始对象。

原始引用类型用 类名.ref 来表示,变量可以保存该类对象的引用或者为 null。原始引用类型是所有该类父类型的子类型。

1
2
3
Point pi; // stores a Point object 原始值类型
Point.ref pr; // stores a reference to a Point 原始引用类型
Shape s; // stores a reference to a Shape, which may be a Point 引用类型,可能是 Point

在使用原始对象的时候,一般不需要显式指定原始引用类型(例如 Point.ref),但这是对象模型非常重要的部分,Java 的开发者应该需要理解。

一个类的原始引用类型,和原始值类型拥有相同的成员,支持应用类型的常规操作。特别的,在运行时 == 和 Object 中的方法,在处理原始对象操作时是相同的,不管是当作值还是引用。

在任何 PrimitiveObject 实例上执行同步(synchronized)操作会发生错误,包括原始引用类型。

跟装箱类似,原始值类型可以隐式转换引用类型,称为原始引用转换(primitive reference conversions)。但原始引用转换是非常轻量级的,不会产生新的 Identity。

1
2
3
Point p1 = new Point(3.0, -2.1);
Point.ref[] prs = new Point.ref[1];
prs[0] = p1; // convert Point to Point.ref 隐式转换

跟拆箱类似,原始引用类型可以隐式转换为值类型,称为原始值转换(primitive value conversion)。如果引用为 null,转换会抛出 NullPointerException

1
2
3
Point p2 = prs[0]; // Convert Point.ref to Point
prs[0] = null;
p2 = prs[0]; // NullPointerException 异常

在方法调用的时候,为了跟方法的参数类型定义匹配,也会发生隐式类型转换。

1
2
3
p1.toString(); // Convert Point to Object
Shape s = p1;
s.contains(p1); // Convert Shape to Point

一般情况下,在使用原始类型的时候,都可以简单的使用值类型。不过在下列场景中,引用类型会很有帮助

  • 当需要子类型多态时(polymorphism),例如原始对象需要作为接口的实例
  • 当需要空值 null 时,例如某些算法需要一个哨兵
  • 当需要通过间接寻址的引用,打破原始类字段之间的循环引用(根据上文描述的声明的限制)
  • 当使用引用有更高的性能时(见下文讨论)

目前 Java 的泛型只适用于引用类型,后续的 JEP 会改进泛型机制,可以同时适用于值类型。

重载解析和参数类型解析 Overload Resolution and Type Argument Inference

原始引用/值转换只能发生在宽松的、非严格的调用上下文,这跟装箱/拆箱的模式是一致的:不需要转换就可以适用的方法优先级高于需要转换的。

1
2
3
4
5
6
7
void m(Point p, int i) { ... }
void m(Point.ref pr, Integer i) { ... }

void test(Point.ref pr, Integer i) {
m(pr, i); // prefers the second declaration 使用第二个方法
m(pr, 0); // ambiguous 两个方法都需要转换,因此是有歧义的
}

参数类型推断在处理原始引用/值转换时,还是跟装箱/拆箱一致的。在需要推断的时候,原始值会被推断为引用类型。

1
2
var list = List.of(new Point(1.0, 5.0));
// infers List<Point.ref> 默认推断为引用类型

(在未来的 JEP 中,将会被允许推断为值类型)

数组子类型 Array Subtyping

原始类实例的数组是协变的,即 Point[]Point.ref[] 的子类型,Point.ref[] 又是 Object[] 的子类型。

目前的基本类型数组(int[]double[] 等)是不变的(不支持协)

一个静态类型为 Object[] 运行时元素类型为 Point 的数组中,储存一个引用时,会执行数组存储检查(检查引用是指向 Point 类的实例),并且执行原始值转换(把引用转换为原始值)。
同样的,从静态类型为 Object[] 数组中读取元素,如果值为原始值,会发生原始引用转换。

1
2
3
4
5
6
7
8
9
Object replace(Object[] objs, int i, Object val) {
Object result = objs[i]; // may perform reference conversion 可能发生引用转换
objs[i] = val; // may perform value conversion 可能发生值转换
return result;
}

Point[] ps = new Point[]{ new Point(3.0, -2.1) };
replace(ps, 0, new Point(-2.1, 3.0));
replace(ps, 0, null); // NullPointerException from value conversion

有点类似于 Integer[] 保存/读取 int 时的行为,用现有 Java 来说明的话

1
2
3
4
5
6
7
8
9
10
Integer replace(Integer[] objs, int i, Integer val) {
Integer result = objs[i]; // 可能发生引用转换
objs[i] = val; // 可能发生值转换
return result;
}

int[] ps = new int[]{ 2, 3 };
// 注意目前版本不允许,基本类型数组是不变的,int[] 不是 Integer[] 的子类型
replace(ps, 0, Integer.valueOf(2));
replace(ps, 0, null); // NullPointerException from value conversion

引用优先的原始类和迁移 Reference-Favoring Primitive Classes and Migration

有一些类是可以被声明为原始类的(不可变,也不需要 Identity),但使用者更希望跟之前一样使用引用类型,尤其是可以为空 null。主要的场景是希望声明一个类是 Identity 类,但可以兼容的重构为原始类。(标准库中有很多类已经被设计为基于值的类,期望可以迁移成原始类。)
在这种情况下,一个类可以被声明为原始类,但使用特殊的名称(语法可能会变化)

1
2
3
primitive class Time.val {
...
}

这种语法的类型 Time.val 是原始值类型,同时 Time 表示对应的原始引用类型。

1
2
Time[] trefs = new Time[]{ new Time(...) };
Time.val t = trefs[0]; // primitive value conversion

总结一下类名和类型的关系

原始类 类型 类名 值类型 引用类型
标准 Foo Foo Foo.ref
引用优先 reference-favoring Bar Bar.val Bar

(有一个问题,是否允许在标准的值类型名后面冗余 .val ,或者 reference-favoring 的引用类型名后面冗余 .ref)
除了在根据类名解析类型之外,reference-favoring 原始类跟标准的原始类是完全一样的。

可以理解为,为了兼容之前的代码,引入了这个新的概念,例如之后 Integer 还是引用类型,可以为空。
否则迁移会变的非常麻烦。

为了能够把已有的 Identity 类迁移为原始类,开发者需要注意,即使已经重构成 reference-favoring,使用者仍然会能感知到一些差异

  • 调用了非公开构造方法的代码会导致链接错误(linkage error),需要重新编译(见后文讨论)
  • 以前认为 != 的两个实例,有可能会变成 == 相等的
  • 执行同步(synchronized)会失败

未完待续

Valhalla (6): 解读 JEP 401 Primitive Objects

https://www.alphalxy.com/2021/10/jep-401/

Author

Xinyu Liu

Posted on

2021-10-28

Updated on

2021-12-26


Comments