Valhalla (2): 现状 The Road to Valhalla

通往 Valhalla 之路

翻译 https://openjdk.java.net/projects/valhalla/design-notes/state-of-valhalla/01-background

这是描述 Valhalla 现状(2021.12)的三篇文章中的第一篇,第二篇是语言模型,第三篇是 JVM 模型。

Valhalla 的口号是:像 class 一样编写,像 int 一样运行。

系列文章(未完待续)

背景 Background

Valhalla 项目始于 2014 年,其目标是为基于 JVM 的语言带来更灵活的扁平化的数据类型,以恢复编程模型与现代硬件性能特征之间的一致性。(某种程度上讲,它开始得更早,Java 的设计者希望在语言的初始版本中包含值类型)

Valhalla 预计会向平台添加三个核心功能:值对象(value objects)、原始类(primitive classes)和特化泛型(specialized generics)。在项目的初始阶段,我们主要关注在理解语言和 JVM 该如何发展以支持这些功能,以及对用户代码迁移兼容性的影响。

虽然可以逐步发布这些功能,但在实现之前,有必要对它们如何协同工作有一个端到端的了解。我们展示了一种可行的路径,通过值类型(value classes)和原始类(primitive classes)增强 Java 语言和虚拟机,迁移现有的基本类型和基于值的类来利用这些特性,并允许基本类型和泛型进行无缝的交互。这一系列的文档总结了该路径,并会分阶段交付。其中前三个 JEP 是 JEP draft: Value ObjectsJEP 401JEP 402。(如果你想跟最开始的版本比较,请参阅我们的 2014 年的原始文件

然而,Valhalla 项目不仅仅是关于它提供的功能,或者是性能的提升,它有更雄心勃勃的计划来统一基本类型和对象(真正做到一切皆对象)。

间接访问的代价 The Costs of Indirection

JVM 类型系统包括八种基本类型(intlong 等)、对象(具有 Identity 的异构聚合)和数组(具有 Identity 的同构聚合)。这组构建方式非常灵活,你可以对任何需要的数据结构进行建模。不能用基本类型表示的数据(例如复数、三维点、元组、定点数、字符串等)可以轻松地使用对象进行建模。但是,对象是在堆中分配的(除非 VM 可以证明它们的作用域足够窄且没有别名),需要一个对象头(通常是两个机器字长),并且必须通过内存间接引用。 例如,一个 XY 点对象数组具有以下内存布局:

在 1990 年代初期设计 Java 虚拟机时,内存获取数据的成本与计算操作(例如加法)的成本相当。随着当今 CPU 的多级内存缓存和指令级并行性,一次缓存 miss 的开销可能相当于 1000 次计算操作,相对成本的大幅增加了。 因此,JVM 偏好的指针布局(涉及小数据块之间的多次间接访问)不再是当今硬件上的理想选择。我们的目标是通过为 Java 开发人员提供更简单的途径来实现扁平(高效缓存)和密集(高效内存)的数据布局,而不会影响抽象或类型安全,从而使数据布局更适合当今硬件的性能模型。

我们想要的是有一个选项来获得更像这样的布局:

这种布局比以前的版本更扁平(没有间接访问)和更密集(没有对象头)。Valhalla 项目通过分离语义并允许用户控制,为我们提供了一种自然的方式来获得这种布局,而不必过分关注底层细节。

扁平化不仅与堆中的布局有关,我们还可以扁平化调用约定(calling convention),即 JVM 如何将值从一种方法传递到另一种方法(在堆栈上,或在寄存器中)。在没有特定的 JVM 优化的情况下,目前将一个 Point 传递给另一个方法时是通过传递指针,被调用者对该指针解引用以访问对象的状态。 我们想要一个更扁平的调用约定,其中可以通过按值传递 x 和 y 来传递 Point。在某些情况下,扁平化调用约定可以产生比堆扁平化布局更显着的性能改进。

非统一类型系统的代价 The Costs of a Split Type System

作为面向对象的语言,基本类型和对象的分离体现了一个重大的妥协:面向对象的语言希望从“一切都是对象”的前提出发。但是,一个 int 不是一个对象,它是一种特殊而神奇的东西(它的数组也是如此),并且这种不统一性对整个语言、库和运行时都产生了重要的影响。

1995 年做出的妥协是,用户可以定义的一切都是对象,但是还有八种额外的内置类型不是对象,并且无法定义新的这样的类型。这在当时肯定是被迫的,尚不知道如何摆脱“一切都是对象”的束缚,但仍然希望提供合理的计算性能。当时看起来还不错,尽管如此,我们已经能够完成伟大的事情,但这是对开发人员、库设计人员和用户来说,是一个持续的技术债。

当泛型在 2004 年出现时,它变得稍微好一点,但也带来了更多的问题。变“好”的是支持了自动装箱(尽管重载解析的复杂性也付出了巨大的代价),所以我们可以自由地在需要整数的地方使用 int,反之亦然。但是,这只是解决了表面问题,而不是深层的的分歧。我们必须意识到基本类型和引用类型之间的分歧越来越多,因为基本类型不能用作泛型的类型参数。同样的,这是一种务实的妥协,也是当时已知的唯一一种在没有大规模兼容性问题的情况下向 Java 添加泛型的方法,但技术债只会越来越多。

当 2014 年 lambda 出现时,情况再次变得更糟。 Lambda 大量构建在泛型上,因此泛型面临的许多后果都被 lambda 继承了。这影响了库的设计,java.util.function 在特化版本时遇到了组合爆炸的问题(IntPredicateIntToLongFunction),而不是能够参数化更通用的类型(Predicate<int>Function<int、long>)。泛型的目标是抽象表示的差异,但基本类型和引用类型之间的鸿沟越来越难以弥合。

装箱的代价 The Costs of Boxing

Java 八种内置的基本类型不是对象,但它们有包装类。当基本类型想要在对象世界中交互时,我们透明地将它们转换为对应的包装类,或者反过来。基本类型没有实现像 Comparable 那样实现接口,不过他们的包装类实现了。Object 是最顶级类型,但仅适用于类。因此基本类型不能直接参与泛型或动态类型库,例如反射(其中一切都表示为 ObjectObject[]),它们只能通过包装类来做到这一点。

通过装箱不一定是坏事,ArrayList<Integer> 的含义已经很清楚了,自动装箱让我们可以用一种语法上方便的方式来处理这些类型。但同时带来了很多问题。包装类有 Identity,而基本类型没有,包装无法完全弥补这一差距。每次我们从 Integer 转换为 int 时,Identity 都会丢失,每次我们从 int 转换为 Integer 时,都会创建一个新的(但是非预期的)Identity(这会阻止有价值的运行时优化)。虽然 int 可以装箱为 Integer,但 int[] 不会装箱为 Integer[]。并且基本类型与其对应的包装类型之间的关系完全是临时的。你需要记住上述这些差异。

开发者知道装箱不仅不合常规,而且还有一定的开销。如果没有足够的优化,装箱转换需要分配堆内存,并且使用装箱类作为字段需要间接访问。装箱类有与我们在上面看到的 Point 有相同的问题,只是有效载荷较小(只有一个字段)。

不断增加的成本 And the Costs Roll On

在库的级别,开发人员面临更困难的选择。最基本的库(集合和流)是库设计者必须进行权衡的主要例子。集合(collection)合理地选择了避免特化(Java 生态中有一些库,例如 trove 或 Eclipse Collections,它们走另一条路,这也很好),不过流(stream)走上了一条试图通过手动特化(intlongdouble)的路线,但 IntStream 的存在证明了库设计者经常不得不将自己绕进去。更糟糕的是,手动特化带来了更多的手动特化(IntStream 产生了 IntToLongFunctionPrimitiveIterator.OfInt),并且总是有人要求更多(“我的 CharStream 在哪里”)。手动特化几乎总是会引入不对称。最后,手动特化流类型的存在成为该库设计和实现的重大限制。

库设计者经常面临良好的内存行为和良好的抽象之间的错误选择。

用户有的时候也必须要面对基本类型和包装类之间的差异。几乎每个 Java 开发人员都编写临时的、手动实现的 ArrayList<int> 的等价物,因为 ArrayList<Integer> 不够(或被认为不够)适合这种情况。 而且这个手动实现很少与 List 有任何联系,这抑制了互操作性并进一步影响了任何想要使用它的 API。 良好的内存行为和良好的抽象之间的权衡对用户和库设计者来说一样艰难。

根本原因 The Root Cause

上面 Point[] 的不合理布局源自对象 Identity,所有对象实例都是唯一标识的。Identity 使可变性成为可能。为了改变对象的字段,我们必须知道我们要修改哪个对象。Identity 还支持布局多态性,其中子类与其超类共享一个公共布局前缀,从而允许通过超类引用安全地操作子类实例。即使对于避免可变性和布局多态性的类(包括大多数不可变的具体类),Identity 仍然可以通过各种 Identity 相关的操作来观察,包括对象相等(==)、同步、System::identityHashCode、弱引用等。

Identity 有效地要求一个对象恰好位于一个地方,如果我们想访问它,我们就去源头。这就是 Point[] 布局充满指针的原因,数组元素只是对实际对象的引用。并且 Identity 要求 VM 悲观地保留 Identity,以防有人最终可能执行 Identity 相关的操作,从而抑制许多有用的优化。在 90 年代初期,“一切都是对象”是一个很有吸引力的口头禅,Identity 的性能成本在当时似乎并不繁重,但随着时间的推移,这个成本越来越高。

Valhalla 的基本特征是某些类可能不需要他们的 Identity。一个没有 Identity 的对象是一个值对象,它的类是一个值类型。这些类放弃了一些灵活性,例如它们必须是不可变的,不能是布局多态的。但作为回报,它们会获得更好的性能。没有 Identity 允许 JVM 自由复制和重新编码这些对象,只保留它们的状态并需要更少的间接访问。

尽管对可变性和子类化有限制,但值类型可以使用类可用的大多数机制,方法、构造函数、字段、封装、接口、泛型、注解等。

此外,某些值类型可能会选择表示为原始类。 原始型不能为空,并且封装性不如引用类型。但它们的值是实例字段的值本身的序列,最大限度地提高了 JVM 实现扁平、密集的内存布局和优化调用约定的能力。这些所谓的原始类可以将类的表达能力与基本类型的运行时行为结合起来。Valhalla 的口号是:像 class 一样编写,像 int 一样运行。

Codes like a class, works like an int.

与基本类型(intdouble 等)不同,通过类声明的基本类型具有字段和方法,它们的值可以根据需要自由转换为值对象,而没有装箱的开销和临时性。它们的数组可以被视为对象数组。

每个级别的程序都会用到原始类和基本类。除了显而易见的将内置基本类型转化为真正的类,许多 API 抽象,例如数字、日期、游标和 Optional 之类的包装类,都可以自然地建模为值类或原始类。此外,许多数据结构可以在其实现中使用原始类来提高效率。同时语言编译器可以将它们用作内置数字类型、元组或多返回等功能的编译目标。

那么泛型呢 What about Generics?

Java 泛型的早期妥协之一是泛型类型变量只能用引用类型实例化,不能用基本类型实例化。这既不方便(当我们想用 List<int> 时我们不得不用 List<Integer>)成本又高(装箱有性能开销)。对于八种基本类型,我们学会了接受这种限制,但是如果我们可以编写自己的可扁平化的数据类型,例如上面的 Point,那么让 ArrayList<Point> 不受 Point 扁平化数组的支持似乎是不应该的,这是重点。

参数多态总是需要在代码占用、抽象和特化之间进行权衡,不同的语言选择了不同的权衡。一方面,C++ 为模板的每个实例化创建一个专门的类,不同的特化彼此之间没有类型系统的关系。这种异构翻译提供了高度的特化,但需要大量的代码空间占用以及抽象的损失,没有和 Java 中 List<?> 等效的类型(不能定义一个 vector<?> 或者 vector)。

另一方面,我们有 Java 当前的擦除实现,它为所有引用实例化生成一个类,并且不支持基本类型实例化。这种同构的翻译产生了高度的重用,因为所有(引用)实例化只有一个类和一个对象布局。它带有一个限制,即我们只能对具有公共运行时表示的类型进行处理,这在 Java 中是所有的引用类型。这种限制深深植根于 JVM 的设计中,对引用值和基本值的操作有不同的字节码。

虽然大多数开发人员对擦除有一定程度的厌恶,但这种方法有一个强大的优势,我们无法通过其他方式获得:逐步迁移兼容性。这是将类从非泛型兼容地演变为泛型的能力,而不会破坏现有的源代码或二进制类文件,并使调用者和子类具有立即、稍后或永不迁移的灵活性。向用户提供泛型,但以丢弃他们已有的代码为代价,在 2004 年将是一个糟糕的决定,当时 Java 已经拥有庞大而充满活力的安装基础。如果今天选择不兼容,将是一个更糟糕的决定。

泛型计划有两个阶段:通用泛型和特化泛型。在第一阶段,我们修复了阻止我们使用基本类型作为泛型类型参数的语言级别的限制,允许泛型覆盖所有类型,但仍然通过擦除实现。(这产生了一种更统一和更具表现力的语言,但还没有优化性能,尽管一些装箱成本已经被更轻量级的值对象转换所取代)在第二阶段,我们在 JVM 中启用布局和代码特化的泛型类和方法,同时保留了使泛型首次成功的所有重要的渐进迁移能力。

继续前进 Moving Forward

Valhalla 项目有非常远大的目标,其应用即深入又广泛,影响到类文件格式、JVM、Java 语言、库和用户模型。
(2014 年,James Gosling 将其描述为“六篇博士论文,交织在一起”)

我们打算将 Valhalla 项目的交付分为三个主要阶段:首先是 Identity-free 的值对象,然后是原始类迁移现有的基本类型通用泛型,最后是特化泛型。

Valhalla (2): 现状 The Road to Valhalla

https://www.alphalxy.com/2021/12/the-road-to-valhalla/

Author

Xinyu Liu

Posted on

2021-12-25

Updated on

2021-12-26


Comments