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 统一基本类型和对象
- 基本类型(
int
、boolean
等)的值将会被视为原始对象 - 包装类(
Integer
、Boolean
等)将会被视为原始类 - 基本类型的值是包装类的实例(例如
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 | primitive class Point implements Shape { |
原始类声明受到如下的限制(要区分实例字段/静态字段):
- 所有的实例字段都被隐式声明为
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 | System.out.println(p2.toString()); |
上面几个操作没什么区别
== 操作将比较两个原始对象的字段值,而不是对象的 Identity。基本类型字段会按位比较,其他的字段会递归通过 == 比较。
1 | assert new Point(1.0, -0.5) == p1; |
equals
、hashCode
和 toString
,包括 System.identityHashCode
在相等的行为定义上是一致的。
1 | Point p3 = p1.translate(0.0, 0.0); |
在原始对象上进行同步 synchronized
会抛异常。
1 | Object obj = p1; |
两个接口 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
)语句中的。
原始类型(int
、boolean
、double
等)被认识一种不同的类型,不受该 JEP 的影响。
原始值类型的默认值 Default Values of Primitive Value Types
每个原始值类型都有一个默认值,用于初始化字段和数组的元素。引用类型的字段默认值是 null
,其他类型字段的默认值是 0 或者 false
或者默认值。默认值是该类的默认实例(default 实例),所有字段值都是其类型的默认值。Point.default
表达式用来指代原始类 Point
的默认实例。
1 | assert new Point(0.0, 0.0) == Point.default; |
注意默认实例的创建,不需要调用任何构造函数、执行初始化赋值和初始化代码块。能访问该类,就可以访问默认实例(除了 Enforcing 实例验证,见下文)。原始类也不能自行定义默认实例,修改其字段的默认值。
引用类型 Reference Type
之前引用类型的变量保存对象的引用或者为 null
,但现在引用的对象可以是 Identity 对象或者原始对象。
原始引用类型用 类名.ref 来表示,变量可以保存该类对象的引用或者为 null
。原始引用类型是所有该类父类型的子类型。
1 | Point pi; // stores a Point object 原始值类型 |
在使用原始对象的时候,一般不需要显式指定原始引用类型(例如 Point.ref
),但这是对象模型非常重要的部分,Java 的开发者应该需要理解。
一个类的原始引用类型,和原始值类型拥有相同的成员,支持应用类型的常规操作。特别的,在运行时 == 和 Object
中的方法,在处理原始对象操作时是相同的,不管是当作值还是引用。
在任何 PrimitiveObject
实例上执行同步(synchronized
)操作会发生错误,包括原始引用类型。
跟装箱类似,原始值类型可以隐式转换引用类型,称为原始引用转换(primitive reference conversions)。但原始引用转换是非常轻量级的,不会产生新的 Identity。
1 | Point p1 = new Point(3.0, -2.1); |
跟拆箱类似,原始引用类型可以隐式转换为值类型,称为原始值转换(primitive value conversion)。如果引用为 null
,转换会抛出 NullPointerException
。
1 | Point p2 = prs[0]; // Convert Point.ref to Point |
在方法调用的时候,为了跟方法的参数类型定义匹配,也会发生隐式类型转换。
1 | p1.toString(); // Convert Point to Object |
一般情况下,在使用原始类型的时候,都可以简单的使用值类型。不过在下列场景中,引用类型会很有帮助
- 当需要子类型多态时(polymorphism),例如原始对象需要作为接口的实例
- 当需要空值
null
时,例如某些算法需要一个哨兵 - 当需要通过间接寻址的引用,打破原始类字段之间的循环引用(根据上文描述的声明的限制)
- 当使用引用有更高的性能时(见下文讨论)
目前 Java 的泛型只适用于引用类型,后续的 JEP 会改进泛型机制,可以同时适用于值类型。
重载解析和参数类型解析 Overload Resolution and Type Argument Inference
原始引用/值转换只能发生在宽松的、非严格的调用上下文,这跟装箱/拆箱的模式是一致的:不需要转换就可以适用的方法优先级高于需要转换的。
1 | void m(Point p, int i) { ... } |
参数类型推断在处理原始引用/值转换时,还是跟装箱/拆箱一致的。在需要推断的时候,原始值会被推断为引用类型。
1 | var list = List.of(new Point(1.0, 5.0)); |
(在未来的 JEP 中,将会被允许推断为值类型)
数组子类型 Array Subtyping
原始类实例的数组是协变的,即 Point[]
是 Point.ref[]
的子类型,Point.ref[]
又是 Object[]
的子类型。
目前的基本类型数组(
int[]
、double[]
等)是不变的(不支持协)
一个静态类型为 Object[]
运行时元素类型为 Point
的数组中,储存一个引用时,会执行数组存储检查(检查引用是指向 Point
类的实例),并且执行原始值转换(把引用转换为原始值)。
同样的,从静态类型为 Object[]
数组中读取元素,如果值为原始值,会发生原始引用转换。
1 | Object replace(Object[] objs, int i, Object val) { |
有点类似于 Integer[] 保存/读取 int 时的行为,用现有 Java 来说明的话
1 | Integer replace(Integer[] objs, int i, Integer val) { |
引用优先的原始类和迁移 Reference-Favoring Primitive Classes and Migration
有一些类是可以被声明为原始类的(不可变,也不需要 Identity),但使用者更希望跟之前一样使用引用类型,尤其是可以为空 null
。主要的场景是希望声明一个类是 Identity 类,但可以兼容的重构为原始类。(标准库中有很多类已经被设计为基于值的类,期望可以迁移成原始类。)
在这种情况下,一个类可以被声明为原始类,但使用特殊的名称(语法可能会变化)
1 | primitive class Time.val { |
这种语法的类型 Time.val 是原始值类型,同时 Time 表示对应的原始引用类型。
1 | Time[] trefs = new Time[]{ new Time(...) }; |
总结一下类名和类型的关系
原始类 类型 | 类名 | 值类型 | 引用类型 |
---|---|---|---|
标准 | 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