Valhalla (7): 解读 JEP 402 Unify the Basic Primitives with Objects
解读 JEP 402: Unify the Basic Primitives with Objects (Preview),统一基本类型和对象。
以直译为主,部分内容会意译,一些地方会增加自己的解读。一些概念解释见 深入理解 Valhalla (0): 序言。
系列文章(未完待续)
摘要 Summary
通过将基本值设计为原始类(见 JEP 401)的实例,统一基本类型(int
、double
等)和对象,继续用包装类的声明作为原始类的声明。作为改动的结果,Java 中所有的值都是对象了。该提案将会是一项预览特性。
目标 Goals
- 将 8 个包装类(
java.lang.Integer
、java.lang.Double
等)迁移为引用优先(Reference-favoring)的原始类。 - 在 Java 语言中,将基本值视为迁移后的包装类的实例。同时基本类型关键字(
int
、double
等)将作为对应原始值类型的别名,新的类型支持方法调用、原始引用转换、数组协变。 - 在 Java 虚拟机中,将基本数组类型视为对应原始对象的数组类型。
- 在核心反射 API 中,修改 8 个表示基本类型的 Class 对象(
int.class
、double.class
等)的行为,以便符合它们的类型定义。
这将是一项非常重大的改动,Java 真正变成了一切皆对象,以下代码都将变得合法:
1 | 1.equals(1); // 支持方法调用 |
非目标 Non-Goals
- 原始对象和原始类的功能在 JEP 401中介绍,而该 JEP 只考虑如何在 8 个基本类型上应用这些特性。
- 该 JEP 不涉及原始值类型(
int
、double
等)和 Java 泛型的交互。单独的 JEP 将满足原始值类型作为类型参数的需求,并最终优化这些参数化的性能。 - 该 JEP 不会提出任何新的数字基本类型,也没有为 Java 的一元和二元操作符提出任何新功能。·
上述第三点,目前在 Java 中不支持无符号数字,也不支持 128 位整数,但可以预期,未来可以基于原始对象的方式来支持。
动机 Motivation
Java 是面向对象的语言,但基本值(布尔、整数和浮点数)都不是对象。在创建语言时,这是一个明智的设计选择,因为每个对象都需要额外的内存开销和间接寻址。但也意味着基本值不支持对象的一些有用的特性,例如实例方法、子类型和后来的泛型。
作为一种解决方案,原来的标准库提供了包装类,每个类都存储了一个基本值,并作为对象呈现。Java 5 引入了隐式的装箱/拆箱,可以根据程序要求把基本类型的值转换成包装类的实例,反之亦然。
但包装类这样的解决方案是不完美的,它并没有完全屏蔽转换的影响(例如同一个值装箱两次,得到的对象可能并不 ==)。更重要的是,在许多应用程序中,将基本值包装在对象中有明显的运行时成本,开发人员必须权衡这些成本和更强大的表达能力。
JEP 401 中介绍了原始对象(Primitive Objects)的特性,将 Identity-free 的值设计为原始对象,能够消除了大部分的额外开销。作为结果,现在可以将基本值在所有的上下文中都当作第一等的对象。最后,我们可以宣称所有的值都是对象。
每个原始对象需要一个原始类,那么 int
的值应该是属于哪个类?大部分现有的代码,都会假设基本值作为对象,应该属于包装类。由于不再需要对基本值进行包装,最小的改动是将包装类改造为原始类,例如 int
值作为 java.lang.Integer
的实例,boolean
值作为 java.lang.Boolean
的实例等。
通过将基本类型修改为原始类,我们可以给它们增加实例方法,并且集成到子类型图中(class subtyping graph)。未来的 JEP 将追求原始值类型和 Java 泛型的交互。
描述 Description
以下描述是预览中的特性,需要在编译和运行时增加 --enable-preview
参数。
基本原始类 Basic Primitive Classes
8 个基本原始类如下
java.lang.Boolean
java.lang.Character
java.lang.Byte
java.lang.Short
java.lang.Integer
java.lang.Long
java.lang.Float
java.lang.Double
编译器和引导类加载器会通过特殊的方式定位类文件,如果开启了预览特性,会定位到修改之后的类。
修改之后的原始类,它们是引用优先(Reference-favoring)的,意味着 Integer
、Double
等类型名继续指它们的引用类型(原始引用类型)。
这些类的公开构造函数,在 JDK 16 中已经被 JEP 390 标记为计划移除(forRemoval
)。涉及到 Identity 和原始类构造函数编译方式不同,可能会产生微妙的二进制兼容性问题。为了避免该问题,修改后的类的构造函数是私有的。
Java 语言模型 Java Language Model
8 个基本类型关键字(boolean
、char
、byte
、short
、int
、long
、float
和 double
)将作为基本原始类和对应原始值类型的别名。.ref 语法被用来表示对应的引用类型。
因为它们是别名,因此每个原始类、原始值类型、原始引用类型都有两种方法表示,见如下表格:
原始类 Primitive Class | 值类型 Value Type | 引用类型 Reference Type |
---|---|---|
boolean / Boolean | boolean / Boolean.val | boolean.ref / Boolean |
char / Character | char / Character.val | char.ref / Character |
byte / Byte | byte / Byte.val | byte.ref / Byte |
short / Short | short / Short.val | short.ref / Short |
int / Integer | int / Integer.val | int.ref / Integer |
long / Long | long / Long.val | long.ref / Long |
float / Float | float / Float.val | float.ref / Float |
double / Double | double / Double.val | double.ref / Double |
代码风格上的问题,我们建议使用小写字母、基于关键字的方式。
原始类声明的限制在基本原始类上会有特例:基本原始类可以递归声明自身原始值类型的字段(例如 int
类可以包含 int
类型的字段)。
Java 支持基本类型之间的转换(例如 int
转 double
),这些行为不会发生变化。为清晰期间,我们现在称之为扩展数字转换(Widening Numeric Conversions)和收缩数字转换(Narrowing Numeric Conversions)。引用类型(例如 int.ref
和 double.ref
)之间是没有类似的转换的。
装箱/拆箱将会被原始类的原始引用转换(Primitive Reference Conversions)和原始值转换(Primitive Value Conversions)替代。支持的类型是一样的,但运行时的行为会更加高效。
Java 支持一系列的一元和二元操作符来操作基本值(例如 23 * 12
、!true
),这些操作的规则和行为不会变化。
因为基本值是对象,它们类定义里面也可以定义实例方法。23.compareTo(42)
这样的代码现在是合法的。(TODO:这是否会导致任何语法分析的问题?equals
和 compareTo
这样的行为是否有意义?)
和其他原始值类型一样,基本原始值类型的数组是协变的:int[]
可以视为 int.ref[]
、Number[]
等。
编译期和运行时 Compilation and Run Time
在 JVM 中,基本类型和原始类类型是不同的:类型 D
表示 64 位浮点数,占用栈上两个 slot,支持一套专有的操作码 (dload
、dstore
、dadd
、dcmpg
等)。类型 Qjava/lang/Double;
表示 Double
的原始对象,占用栈上一个 slot,支持对象的操作码 (aload
、astore
、invokevirtual
等)。
Java 编译器有义务根据需求适配两种类型,通过调用方法例如 Double.valueOf
和 Double.doubleValue
。编译后的字节码跟装箱/拆箱生成的字节码类似,但是运行时开销会大幅减少。
为了一致性,基本原始值类型出现在字段类型和方法签名的时候,永远编译成 JVM 中的基本类型(D
而不是 Qjava/lang/Double;
),除了基本原始类本身的定义(例如 Double.valueOf
返回类型 QDouble;
)。
编译器的适配,对于基本数组类型来说是不够的。例如通过 newarray
创建的类型为 [D
的数组,可以传递给期望参数为 [Ljava/lang/Double;
的方法。同时通过 anewarray
创建的类型为 [Qjava/lang/Double;
的数组,可以转换为 [D
。为了支持上述行为,JVM 将 [D
和 [Qjava/lang/Double;
视为兼容的类型。他们的值同时支持两套操作码(daload
和 aaload
、dastore
和 aastore
),无论数组是如何创建的。
(TODO:getClass().getComponentType()
将如何返回)
核心反射 Core Reflection
每一个基本原始类型,开发人员通常会遇到两个 Class 对象。以 Double
类为例,有:
double.class
(或等价的Double.val.class
)对应 JVM 的描述符类型D
。isPrimitive()
方法返回true
。当开启预览特性的时候,为了跟 Java 语言模型对齐,大多数其他行为类似于java.lang.Double
(例如isPrimitiveClass
、getMethods
、getSuperclass
等)。Double.class
(或等价的double.ref.class
)对应 JVM 的描述符类型Ljava/lang/Double;
。isPrimitive()
返回false
。跟表示原始引用类型的标准 Class 对象的行为是类似的。
基本原始对象 getClass()
方法返回第二种 Class 对象(例如 Double.class
、Integer.class
等)。对所有的原始对象来说,通过值类型((23.0).getClass()
)或者引用类型(((Double)23.0).getClass()
)返回的都是一样的。这跟传统的装箱行为是兼容的。
(23.0).getClass() == Double.class
还存在第三个 Class 对象,对应 JVM 的描述符类型 Qjava/lang/Double;
,但在实践中几乎不会使用,因为 Java 编译器几乎不会用该类型描述符名称。也没有类名表示这些对象。isPrimitive()
返回 false
。跟表示原始值类型的标准 Class 对象的行为是类似的。
也就是说原始类
Person.class
的描述符是QPoint;
,Person.ref.class
的描述符是LPoint;
,
按照相同的逻辑来说double.class
的描述符是Qjava/lang/Double;
,double.ref.class
的描述符是Ljava/lang/Double;
。
但 Java 编译器在遇到double.class
和Double.val.class
时为了兼容都会编译成D
,算是一种特殊的处理。
其他选择 Alternatives
语言可以保持不变(原始对象是一个有用的特性,但无需将基本值视为对象)。但是消灭基本类型和对象之间的隔阂会非常有用,特别是当 Java 的泛型支持原始对象时。
新类可以作为基本原始类(例如 java.lang.int
)引入,而将包装类作为遗留 API。 但是关于装箱行为的假设在某些代码中根深蒂固,而一组新的类会破坏这些程序。
JVM 可以遵循 Java 语言将其基本类型(I
、D
等)与其原始类类型(Qjava/lang/Integer;
、Qjava/lang/Double;
等)完全统一。但这将是一个代价高昂的改变,最终收益甚微。 例如,必须有一种方法来协调两个 slot 的 D
类型和一个 slot 的 Qjava/lang/Double;
类型,可能需要对类文件格式进行破坏性的版本更改。
风险和假设 Risks and Assumptions
删除包装类的构造函数,破坏了遗留 Java 程序的二进制兼容性。还有与迁移到原始类型相关的行为变化。JEP 390 以及一些预期的后续工作减轻了这些负担。但一些调用构造函数或者依赖装箱对象 Identity 的程序会发生错误。
由于新的基本原始类型将作为类类型,反射行为的变化可能导致某些程序出现问题。存在表示 Qjava/lang/Double;
类型的不同对象,很容易被忽略并可能让一些开发人员感到惊讶。
依赖 Dependencies
JEP 401 原始对象是必备的条件。
考虑到该功能,JEP 390 已经对原始类的候选者们可能产生的不兼容改动,向 javac 和 HotSpot 增加了警告。一些后续工作将在其他的 JEP 中进行。
我们期望修改 Java 中的泛型模型,使类型参数更加通用(可以被所有的类实例化,包括引用和值)。这会在单独的 JEP 中进行。
Valhalla (7): 解读 JEP 402 Unify the Basic Primitives with Objects