Oracle OpenJDK 团队已经发布了 Valhalla 项目LW2原型的早期访问(EA)构建版本(也称为“内联类”,以前称为“值类型”)。
该原型可以在这里下载,其目的是在未来几周内定期通过 Bug 修复和性能升级来更新二进制文件。
团队正在积极地寻找关于用户模型的反馈,并提醒说,该实现的许多主要方面还没有准备好接受检查。
LW2 的命名表明内联类特性的实现已经达到了“L-World”设计的第二个里程碑。
当前原型通过使内联类型尽可能地与现有对象和接口(即 L-Type 系统或“L-World”)相似,将内联类型合并到现有类型系统中。
该原型于 2019 年 7 月 5 日发布,取代了之前的 LW1 里程碑。它将 Valhalla 和内联类的初步实验提供给更多的开发人员,尽管它仍然是非常实验性的。
它还迈出了重新审视泛型的第一步——其语法允许将内联类的可空引用投影(projections)用作泛型类型的参数。
由于这是一个早期原型,有一些限制,包括:
仅在 x64 Linux、x64 Mac OS X 及 x64 Windows 上可用;
不支持包含间接类型的原子字段;
不支持 @Contended 内联类型字段;
只支持解释器和 C2,不支持 C1,没有分层编译,也没有 Graal;
内联类型没有不安全字段和数组访问器 API;
解释器还没有优化,其重点是 C2 JIT 优化。
InfoQ 就 LW2 的发布采访了 Dan Heidinga(IBM Eclipse OpenJ9 项目负责人)。
InfoQ:您认为 LW2 中最重要的新特性是什么?
Dan Heidinga:LW2 早期访问构建给我们带来了很多东西,其中最重要的是内联类型的用户模型。以前的原型是用 MethodHandles 来定义的,有很高的进入门槛。
使用以前的原型编写重要的代码太困难了,即使对于 MethodHandle 专家来说也是如此,这使得提供反馈非常困难。LW2 不同。它的目的是对用户友好,以便开发人员能够测试模型,了解内联类型如何工作,并向专家组提供反馈。
一个特别重要的特性是内联类型运算符“?”的引入,它允许用户显式地选择内联类型是要内联的候选类型,还是应该使用间接类型。
它还支持将内联类型与 Java 的集合库一起使用,这再次提高了开发人员使用原型的体验。只允许泛型使用“?”或者 LW2 中类型的可空版本,我们为将来的改进留有余地,比如内联类型的具体化泛型。
InfoQ:您能解释一下内联类与逃逸分析的关系吗?为什么这对性能很重要?
Heidinga:逃逸分析是一种 JIT 优化,它试图证明一个对象的生命周期完全包含在当前的编译单元中,不会“逃逸”到堆、另一个线程,甚至不会超出当前内联方法集的范围。
如果 JIT 能够证明对象没有逃逸,那么它就可以将对象分割成一组独立的字段。然后可以将它们放入寄存器中,以获得更好的优化机会,或者可以在栈而不是堆上分配对象。这两种方法都提供了更好的优化机会,并减少了垃圾收集的压力,因为对象永远不会结束在堆上。
虽然这听起来很棒,但并无保证。优化可能会失败,原因有很多——比如没有在方法中内联足够多的调用以确保对象真得没有逃逸,或者在给定的编译操作中可能无法运行。这可能是因为较低层的编译可能会跳过一些昂贵的优化以获得更快的代码编译速度。不仅如此,对代码的微小更改可能会导致对象逃逸,使优化无法成功,从而导致性能下降,而又没有明显的原因。
内联类是不可变的,没有标识。这使得这些类型成为逃逸分析的理想候选类型,因为 JIT 不再需要证明它们没有逃逸。它可以自由地将它们分割、放入寄存器并优化它们,但是它需要这样做,因为它总是可以在任何可能逃逸的位置重新构造内联类型。这里的关键是,由于内联类型没有标识,所以无法判断它们是否被重新创建。这消除了传统逃逸分析中的许多脆弱性。
在大多数程序中,有许多小类充当其他数据的封装器,这些小类将受益于这种有保证的逃逸分析。考虑下代码中封装 int、long 或 String 以赋予它们额外语义的位置。如果 JIT 能够栈分配所有这些实例不是很好吗?这是内联类型取得成功的部分原因。
InfoQ:内联类的实例是不可变的,所以您能解释一下为什么这会导致更新原子性问题吗?为什么我们不能使用乐观副本和比较与交换(CAS)来交换指针呢?
Heidinga:请记住,项目的口号是“让和类一样的代码像 int 一样工作”。当将一个基本的 int 类型写入局部变量、字段甚至数组时,我们不修改 int 类型。相反,我们覆盖了全部内容。LW2 的内联类就是以这种方式像基本类型一样工作。
虽然这在概念上是一个干净的模型,但是它重新引入了一个自从 64 位系统成为常态以来 Java 开发人员一直都可以忽略的问题:分裂(tearing),也就是非原子更新。
回顾早期 32 位 Java 实现处理 long 或 double 的方式有助于理解这个问题。CPU 保证其本机字大小的写操作是自动发生的。在一个 32 位系统上写 32 位是不会分裂的。但 long 是 64 位的,这意味着它不能在没有向硬件发送特别请求的情况下在 32 位系统上自动写入。
Java 语言规范在“17.7. Double 和 long 的非原子处理”这一节中承认了这一点:
就 Java 编程语言内存模型而言,对非易失性 long 或 double 值的一次写操作被视为两次单独的写操作:
一次 32 位。这可能导致这样一种情况:一个线程从一次写入中看到了这个 64 位数值的前 32 位,从另一次写入中看到后 32 位。
内联类会将分裂问题重新带回到用户需要关注的问题集里,因为内联类型的读写必须复制该类型的全部内容。
这里有一个例子来帮助理解为什么内联类的分裂对开发人员来说是一个新问题。
考虑内联类 Customer:
以及一个用于跟踪前三名客户的数组:
该数组由两个线程并发访问。第一个线程尝试将一个新客户写入数组:
同时,第二个线程则尝试读取数组:
如果读和写同时发生,则有可能读取到一个新老客户合并而成的客户,从而产生一个不可能的客户对象。这是一次数据竞争,但在此之前(大部分情况下)是良性的,因为运行时一直在数组中原子性地用一个指针替换另一个指针。
一旦内联类型大于处理器提供的最大原子更新(通常是字长的两倍,所以 64 位系统上是 128 位),那么分裂就成为一个潜在的问题——如果内联类型上有任何数据竞争的话。内联类型太大,CAS 无法成功,因为它需要更新整个内容,而不仅仅是指向它们的指针。由于该类型的所有内容都内联在容器中,所以没有方便更新的指针。
Valhalla 专家组仍在研究如何将内联类型标记为“原子类型”,这样它们就只能以不可分裂的方式写入,这将解决其中的一些问题,但是 LW2 目前还没有包含这个建议。
内联类型的设计建议,内联类型应该是很小的数据聚合(此处的关键字是“小”),分裂是提出这项建议的另一个原因。
InfoQ:您认为目前开发人员社区对内联类的最大误解体现在哪一方面?
Heidinga:我将给出两个答案。第一个常见的误解是内联类型是可变的,就像 C 的结构一样。虽然 IBM 的 PackedObjects 或 Azul 的 ObjectLayout 等早期建议支持可变类型,但 LW2 的内联类型严格来说是不可变的。这是 Java 新特性的总体趋势的一部分,它支持不变性,以便更容易编写正确的并发应用程序。
第二个常见的误解是内联类型让用户显式地控制对象的布局。内联类型允许用户要求 JVM 将数据直接内联到容器中(对象或数组),但不允许用户控制类型中字段的布局。布局算法仍然完全在 JVM 的控制之下,JVM 可能会对字段进行重新排序,从而对它们进行分组,从而提高垃圾收集的效率。
InfoQ:您还有其他什么想和我们的读者分享吗?
Heidinga:在 LW2 早期访问二进制文件和经过更新的 JVM 规范中有很多内容。请查看并给我们提供反馈。在设计中有许多打开的问题,我们期待你关于该设计在你的用例中表现如何的经验报告。
我们正在寻求反馈的一些领域包括==对于内联类型的行为、在 Object[]和内联类型数组之间引入的数组协方差,以及你在你的代码库中试验内联类型时的体验。
Valhalla 项目的 LW2 二进制文件现在已经发布,并且正在积极地寻求来自普通 Java 开发人员的反馈(在已经准备好的领域,例如用户模型)。
原文链接:
OpenJDK Project Valhalla Releases LW2 Prototype
评论