Java 内联类初探
本文要点 Valhalla 项目正在开发内联类,使 Java 程序更好地适应现代硬件内联类使开发人员能够编写一些类型,其行为更像 Java 内置的基元类型内联类的实例没有对象标识,创造了许多优化的可能性内联类的出现重新引发了有关 Java 泛型和类型擦除的争论尽管内联类前途光明,但这个项目仍在推进中,尚未投入生产
在本文中我将介绍内联类。此功能是之前称为“值类型”的演变。对这一功能的探索和研究仍在进行,并且是 Valhalla 项目中的主要工作流,此前 InfoQ 和 Oracle 的 Java 杂志已经做过了相关报道。
为什么要开发内联类?内联类(inline classes)的目标是让 Java 程序更好地适应现代硬件。为了实现这一目标,需要重新审视 Java 平台的一个非常基础的组成部分,即 Java 数据值的模型。
从 Java 最早的版本开始直到今天为止,Java 只有两种类型的值:基本类型和对象引用。这个模型非常简单,开发人员很容易理解,但是会带来性能损失的代价。例如,处理对象数组时涉及不可避免的间接访问,这可能导致处理器缓存未命中。
许多关心性能的程序员都希望程序处理的数据能更有效地利用内存。更好的布局意味着更少的间接访问,进而减少缓存未命中并提升性能。
开发人员感兴趣的另一大领域是消除"每个数据组合都需要一个完整的对象标头"的开销——也就是"展平"数据的理念。
目前而言,Java 堆中的每个对象都有一个元数据标头以及实际的字段内容。在 Hotspot 中,此标头本质上是两个机器码——mark 和 klass。首先是 mark,其中包含特定于这个特定对象实例的元数据。
元数据的第二个机器码是 klass,它是指向元数据(存储在内存的 Metaspace 区域中)的指针,与同一类的其他所有实例共享。这个 klass 指针是理解运行时如何实现某些语言功能(例如虚拟方法查找)的关键所在。
但针对本文所讨论的内联类来说,mark 中包含的数据特别重要,因为它与 Java 对象的标识概念紧密相联。
内联类和对象标识回想一下,在 Java 中,两个对象实例并不会只因为它们的所有字段都有相同的值,就被认为是相等的。Java 使用==运算符来确定两个引用是否指向相同的内存位置,如果对象分别存储在内存中的不同位置,则它们不会被视为相同的。
注意:这个标识概念与锁定 Java 对象的能力相关。实际上 mark 是用来存储对象监视器(以及其他内容)的。
但对于内联类,我们希望组合具有实质上是基本类型的语义。在这种情况下,判断相等与否时唯一重要的是数据的位模式,而不是该模式在内存中出现的位置。
因此,移除对象标头后我们还移除了组合的唯一标识。这一更改释放了运行时,从而在布局、调用约定、编译和调度层面带来显著的优化。
注意:移除对象标头还对内联类的设计带来了其他影响。例如它们无法同步(因为它们既没有唯一标识,也没有存储监视器的位置)。
我们需要意识到,Valhalla 是一个贯穿语言和 VM 直达核心的项目。这意味着对于程序员来说,它可能看起来就像一个新的构造(inline class),但这个功能依赖的层有很多。
注意:内联类与即将发布的记录功能不同。Java 记录只是用减少的样板声明的常规类,并且具有一些标准化的,由编译器生成的方法。相比之下,内联类本质上是 JVM 中的一个新概念,它从根本上改变了 Java 的内存模型。
当前的内联类原型(称为 LW2)已经可以工作了,但仍处于非常非常早期的阶段。它的目标受众是高级开发人员、库作者和工具开发商。
使用 LW2 原型下面我们来深入研究一下内联类当前的 LW2 状态,看看用它可以做些什么事情。我会用一些底层技术(例如字节码和堆直方图)展示内联类的效果。未来的原型将添加更多用户可见和层次更高的事物,但是它们尚未完成,所以我现在只能在底层探索。
要获得支持 LW2 的 OpenJDK 构建,最简单的方法是在此处下载它——Linux、Windows 和 Mac 构建都可用。另外,经验丰富的开源开发人员可以从头开始构建自己的二进制文件。
原型下载并安装完成后,我们就可以用它来开发一些内联类。
要在 LW2 中创建内联类,请使用 inline 关键字标记类声明。
内联类的规则(目前的版本,其中一些规则可能会在将来的原型中放宽或更改):
接口、注释类型和枚举不能是内联类顶级、内部、嵌套和本地类可以是内联类内联类不可为空,需要有默认值内联类可以声明内部、嵌套和本地类型内联类是隐式 final 的,因此不能是 abstract 的内联类隐式扩展 java.lang.Object(例如枚举、注释和接口)内联类可以显式实现常规接口内联类的所有实例字段都是隐式 final 的内联类不能声明其自身类型的实例字段 javac 自动生成 hashCode()、equals()和 toString()javac 不允许对内联类使用 clone()、finalize()、wait()或 notify()
下面看一下我们的第一个内联类示例,看看像 Optional 这样的类型的实现作为内联类长什么样。为了少走弯路并简化演示,我们将编写一个包含基本值的可选类型的版本,类似于标准 JDK 类库中的 java.util.OptionalInt 类型:
public inline class OptionalInt {private boolean isPresent;private int v;
}
这里应该使用(当前)LW2 版本的 javac 编译。要查看新的内联类技术的效果,我们需要使用 javap 工具查看字节码,调用 javap 的方法如下:
$ javap -c -p infoq/OptionalInt.class
把 OptionalInt 类型拆开来看,我们就能在字节码中看到内联类的一些有趣特性:
public final value class infoq.OptionalInt {private final boolean isPresent;
private final int v;
这个类具有一个新的修饰符值,这个值是从较早的原型(当时该功能仍称为值类型)中遗留下来的。即使未在源代码中指定,这个类和所有实例字段也都已定型。接下来让我们看一下对象构造方法:
public static infoq.OptionalInt empty();Code:0: defaultvalue #1 // class infoq/OptionalInt3: areturn
public static infoq.OptionalInt of(int);Code:0: iload_01: invokestatic #11 // Method "<init>":(I)Qinfoq/OptionalInt;4: areturn
private static infoq.OptionalInt infoq.OptionalInt(int);Code:0: defaultvalue #1 // class infoq/OptionalInt3: astore_14: iload_05: aload_16: swap7: withfield #3 // Field v:I10: astore_111: iconst_112: aload_113: swap14: withfield #7 // Field isPresent:Z17: astore_118: aload_119: areturn
对于常规类,我们会看到一个已编译构造序列,就像下面这个简单工厂方法这样:
// Regular object classpublic static infoq.OptionalInt of(int);Code:0: new #5 // class infoq/OptionalInt3: dup4: iload_05: invokespecial #6 // Method "<init>":(I)V8: areturn
这两个字节码序列之间的区别很明显——内联类不使用新的操作码。相反,我们遇到了两个专门针对内联类的全新字节码——defaultvalue 和 withfield。
defaultvalue 用于创建新的值实例使用 withfield 代替 setfield
注意:这种设计带来的影响之一是,对于每个内联类,默认值的结果必须是该类型的一致且可用的值。
值得注意的是,withfield 的语义是将堆栈顶部的值实例替换为带有更新字段的修改过的值。这与 setfield(在堆栈上使用对象引用)略有不同,因为内联类始终是不可变的,不一定总是表示为引用。
最后再观察字节码,我们注意到在这个类的其他方法中有自动生成的 hashCode()和 equals()的实现,它们使用 invokedynamic 作为一种机制。
public final int hashCode();Code:0: aload_01: invokedynamic #46, 0 // InvokeDynamic #0:hashCode:(Qinfoq/OptionalInt;)I6: ireturn
public final boolean equals(java.lang.Object);Code:0: aload_01: aload_12: invokedynamic #50, 0 // InvokeDynamic #0:equals:(Qinfoq/OptionalInt;Ljava/lang/Object;)Z7: ireturn
在我们的例子中,我们显式提供了 toString()的重写,但是通常也会为内联类自动生成此这一方法。
public java.lang.String toString();Code:0: aload_01: getfield #7 // Field isPresent:Z4: ifeq 297: ldc #28 // String OptionalInt[%s]9: iconst_110: anewarray #30 // class java/lang/Object13: dup14: iconst_015: aload_016: getfield #3 // Field v:I19: invokestatic #32 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;22: aastore23: invokestatic #38 // Method java/lang/String.format:(Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/String;26: goto 3129: ldc #44 // String OptionalInt.empty31: areturn
为了驱动我们的内联类,让我们看一下 Main.java 中包含的一个小型驱动程序:
public static void main(String[] args) {int MAX = 100_000_000;OptionalInt[] opts = new OptionalInt[MAX];for (int i=0; i < MAX; i++) {opts[i] = OptionalInt.of(i);opts[++i] = OptionalInt.empty();}long total = 0;for (int i=0; i < MAX; i++) {OptionalInt oi = opts[i];total += oi.orElse(0);}try {Thread.sleep(60_000);} catch (Exception e) {e.printStackTrace();}
这里没有展示 Main 的字节码,因为它没有任何特别之处。实际上,如果 Main 使用 java.util.OptionalInt 替代我们的内联类版本,生成的代码也是一样的(除了包名以外)。
当然这样做的一部分原因是让内联类对主流 Java 程序员的影响尽量减小,并在不增加开发人员认知负担的前提下提供所有好处。
内联类的堆行为注意到编译值类的字节码的功能之后,我们现在可以执行 Main 并快速浏览一遍运行时行为,从堆的内容开始。
$ java infoq.Main
注意,程序末尾的线程延迟只是为了让我们有时间从进程中生成堆直方图。
为此,我们在单独的窗口中运行另一个工具:jmap -histo:live,它会生成如下结果:
num #instances #bytes class name (module)
1: 1 800000016 [Qinfoq.OptionalInt;2: 1687 97048 [B (java.base@14-internal)3: 543 70448 java.lang.Class (java.base@14-internal)4: 1619 51808 java.util.HashMap$Node (java.base@14-internal)5: 452 44600 [Ljava.lang.Object; (java.base@14-internal)6: 1603 38472 java.lang.String (java.base@14-internal)7: 9 33632 [C (java.base@14-internal)
这表明我们已经分配了一个单一的 infoq.OptionalInt 值数组,它大约占用了 8 亿空间(1 亿个元素,每个元素大小为 8)。
不出所料,我们的内联类没有独立的实例。
注意:熟悉 Java 类型描述符的内部语法的读者可能会注意到新的 Q 类型描述符,它用来表示内联类的值。
为了方便对比,我们来使用 java.util 中 OptionalInt 的版本代替内联类版本重新编译 Main。现在直方图看起来完全不一样了(Java 8 的输出):
num #instances #bytes class name (module)
1: 50000001 1200000024 java.util.OptionalInt2: 1 400000016 [Ljava.util.OptionalInt;3: 1719 98600 [B4: 540 65400 java.lang.Class5: 1634 52288 java.util.HashMap$Node6: 446 42840 [Ljava.lang.Object;7: 1636 39264 java.lang.String
现在,我们有一个单一数组,其中包含 1 亿个大小为 4 的元素,这些元素是对对象类型 java.util.OptionalInt 的引用。我们还有 5,000 万个 OptionalInt 实例,再加上一个空值实例,这样非内联类实例的总内存占用约为 1.6G。
这意味着在这种极端情况下,使用内联类可将内存开销减少约 50%。这就是"codes like a class,works like an int”这条原则的一个很好的实例。
使用 JMH 进行基准测试下面来看一个简单的 JMH 基准测试。这是为了从减少程序运行时间的角度,评估减少间接寻址和高速缓存未命中的效果。
可以在 OpenJDK 网站上找到有关设置和运行 JMH 基准的详细信息。
我们的基准测试将直接对比 OptionalInt 的内联实现和 JDK 中的版本。
import org.openjdk.jmh.annotations.*;import java.util.concurrent.TimeUnit;
@State(Scope.Thread)@BenchmarkMode(Mode.Throughput)@OutputTimeUnit(TimeUnit.SECONDS)public class MyBenchmark {
.OptionalInt[MAX];for (int i=0; i < MAX; i++) {opts[i] = infoq.OptionalInt.of(i);opts[++i] = infoq.OptionalInt.empty();}long total = 0;for (int i=0; i < MAX; i++) {infoq.OptionalInt oi = opts[i];total += oi.orElse(0);}
.util.OptionalInt[MAX];for (int i=0; i < MAX; i++) {opts[i] = java.util.OptionalInt.of(i);opts[++i] = java.util.OptionalInt.empty();}long total = 0;for (int i=0; i < MAX; i++) {java.util.OptionalInt oi = opts[i];total += oi.orElse(0);}
}
在最新的高配 MacBook Pro 上运行一次测试即可得到以下结果:
Benchmark Mode Cnt Score Error UnitsMyBenchmark.timeInlineOptionalInt thrpt 25 5.155 ± 0.057 ops/sMyBenchmark.timeJavaUtilOptionalInt thrpt 25 0.589 ± 0.029 ops/s
这表明在这种特定场景中内联类要快得多。但要记住不应该太过拔高这一示例的意义,这只是为了演示而举的例子。
正如 JMH 框架本身给出的警告所言:“不要因为你想看到什么样的结果,就假设数字会告诉你怎样的结果。”
例如,在这个例子中 infoq.OptionalInt 的测试版本大约分配了 50%——是因为分配的减少导致了性能提升?还是还有其他性能影响?只看这个基准测试并不能得出结论——它仅仅是一个数据点而已。
这个粗略的基准测试结果只能表明内联类在某些精心选择的场景下可能体现出显著的加速效,除此之外不应该特别看重这个结果或将其用于其他用途。
例如,LW2 原型仅支持解释模式和 C2(服务器)JIT 编译器。没有 C1(客户端)编译器,没有分层编译,也没有 Graal。此外,解释器尚未优化,因为重心都放在了 JIT 实现上。预期所有这些功能都将在 Java 的发行版本中提供,而如果没有它们,所有的性能数字都会是完全不可靠的。
实际上,就当前的 LW2 预览版来说,除了性能外还有很多工作要做。很多基础问题尚待解决,例如:
如何扩展泛型以支持对所有类型抽象,包括基元、值甚至 void 这些类型?内联类真正的继承层次结构应该是什么样的?类型擦除和向后兼容性的问题该怎么办?如何使现有库(特别是 JDK)保持兼容的同时进化以充分利用内联类?现有的 LW2 约束能够,或者应该放宽多少?
大多数问题仍未解决,但 LW2 试图提供在其中一个领域提供答案,就是为内联类设计一种原型机制,使其可以在通用类型中被用作类型参数(“有效负载”)。
内联类作为类型参数在当前的 LW2 原型中我们必须克服一个问题,那就是 Java 的泛型模型隐式地假定了值的可空性,而内联类是不可空的。
为了解决这个问题,LW2 使用了一种称为间接投影的技术。这就像为内联类设计的一种自动装箱形式,允许我们对于任何内联类型 Foo 编写 Foo?类型。
最终结果是,间接投影类型可以用作通用类型中的参数(而真正的内联类型则不能),如下所示:
public static void main(String[] args) {List<OptionalInt?> opts = new ArrayList<>();for (int i=0; i < 5; i++) {opts.add(OptionalInt.of(i));opts.add(OptionalInt.empty());opts.add(null);}int total = opts.stream().mapToInt(o -> {if (o == null) return 0;OptionalInt op = (OptionalInt)o;return op.orElse(0);}).reduce(0, (x, y) -> x + y);
内联类的实例始终可以强制转换为间接投影的实例,但反之则需进行空检查,如示例中的 lambda 正文所示。
注意:间接投影的使用仍处于实验阶段。内联类的最终版本可能使用完全不同的设计。
在内联类真正准备好成为 Java 语言中的功能之前仍有大量工作要做。像 LW2 这样的原型对于感兴趣的开发人员来说是很有趣的尝试,但应该牢记这些只是一种智力活动。当前版本中的任何内容都不一定是这个功能最终采用的形式。
评论