在本文中我将介绍内联类。此功能是之前称为“值类型”的演变。对这一功能的探索和研究仍在进行,并且是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的OpenJDK构建,最简单的方法是在此处下载它——Linux、Windows和Mac构建都可用。另外,经验丰富的开源开发人员可以从头开始构建自己的二进制文件。
原型下载并安装完成后,我们就可以用它来开发一些内联类。
要在LW2中创建内联类,请使用inline关键字标记类声明。
内联类的规则(目前的版本,其中一些规则可能会在将来的原型中放宽或更改):
下面看一下我们的第一个内联类示例,看看像Optional这样的类型的实现作为内联类长什么样。为了少走弯路并简化演示,我们将编写一个包含基本值的可选类型的版本,类似于标准JDK类库中的java.util.OptionalInt类型:
public inline class OptionalInt {
private boolean isPresent;
private int v;
private OptionalInt(int val) {
v = val;
isPresent = true;
}
public static OptionalInt empty() {
// New semantics for inline classes
return OptionalInt.default;
}
public static OptionalInt of(int val) {
return new OptionalInt(val);
}
public int getAsInt() {
if (!isPresent)
throw new NoSuchElementException("No value present");
return v;
}
public boolean isPresent() {
return isPresent;
}
public void ifPresent(IntConsumer consumer) {
if (isPresent)
consumer.accept(v);
}
public int orElse(int other) {
return isPresent ? v : other;
}
@Override
public String toString() {
return isPresent
? String.format("OptionalInt[%s]", v)
: "OptionalInt.empty";
}
}
这里应该使用(当前)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/OptionalInt
3: areturn
public static infoq.OptionalInt of(int);
Code:
0: iload_0
1: invokestatic #11 // Method "<init>":(I)Qinfoq/OptionalInt;
4: areturn
private static infoq.OptionalInt infoq.OptionalInt(int);
Code:
0: defaultvalue #1 // class infoq/OptionalInt
3: astore_1
4: iload_0
5: aload_1
6: swap
7: withfield #3 // Field v:I
10: astore_1
11: iconst_1
12: aload_1
13: swap
14: withfield #7 // Field isPresent:Z
17: astore_1
18: aload_1
19: areturn
对于常规类,我们会看到一个已编译构造序列,就像下面这个简单工厂方法这样:
// Regular object class
public static infoq.OptionalInt of(int);
Code:
0: new #5 // class infoq/OptionalInt
3: dup
4: iload_0
5: invokespecial #6 // Method "<init>":(I)V
8: areturn
这两个字节码序列之间的区别很明显——内联类不使用新的操作码。相反,我们遇到了两个专门针对内联类的全新字节码——defaultvalue和withfield。
注意:这种设计带来的影响之一是,对于每个内联类,默认值的结果必须是该类型的一致且可用的值。
值得注意的是,withfield的语义是将堆栈顶部的值实例替换为带有更新字段的修改过的值。这与setfield(在堆栈上使用对象引用)略有不同,因为内联类始终是不可变的,不一定总是表示为引用。
最后再观察字节码,我们注意到在这个类的其他方法中有自动生成的hashCode()和equals()的实现,它们使用invokedynamic作为一种机制。
public final int hashCode();
Code:
0: aload_0
1: invokedynamic #46, 0 // InvokeDynamic #0:hashCode:(Qinfoq/OptionalInt;)I
6: ireturn
public final boolean equals(java.lang.Object);
Code:
0: aload_0
1: aload_1
2: invokedynamic #50, 0 // InvokeDynamic #0:equals:(Qinfoq/OptionalInt;Ljava/lang/Object;)Z
7: ireturn
在我们的例子中,我们显式提供了toString()的重写,但是通常也会为内联类自动生成此这一方法。
public java.lang.String toString();
Code:
0: aload_0
1: getfield #7 // Field isPresent:Z
4: ifeq 29
7: ldc #28 // String OptionalInt[%s]
9: iconst_1
10: anewarray #30 // class java/lang/Object
13: dup
14: iconst_0
15: aload_0
16: getfield #3 // Field v:I
19: invokestatic #32 // Method java/lang/Integer.valueOf:(I)Ljava/lang/Integer;
22: aastore
23: invokestatic #38 // Method java/lang/String.format:(Ljava/lang/String;[Ljava/lang/Object;)Ljava/lang/String;
26: goto 31
29: ldc #44 // String OptionalInt.empty
31: 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();
}
System.out.println("Total: "+ total);
}
这里没有展示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.OptionalInt
2: 1 400000016 [Ljava.util.OptionalInt;
3: 1719 98600 [B
4: 540 65400 java.lang.Class
5: 1634 52288 java.util.HashMap$Node
6: 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基准测试。这是为了从减少程序运行时间的角度,评估减少间接寻址和高速缓存未命中的效果。
可以在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 {
@Benchmark
public long timeInlineOptionalInt() {
int MAX = 100_000_000;
infoq.OptionalInt[] opts = new infoq
.
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);
}
return total;
}
@Benchmark
public long timeJavaUtilOptionalInt() {
int MAX = 100_000_000;
java.util.OptionalInt[] opts = new java
.
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);
}
return total;
}
}
在最新的高配MacBook Pro上运行一次测试即可得到以下结果:
Benchmark Mode Cnt Score Error Units
MyBenchmark.timeInlineOptionalInt thrpt 25 5.155 ± 0.057 ops/s
MyBenchmark.timeJavaUtilOptionalInt thrpt 25 0.589 ± 0.029 ops/s
这表明在这种特定场景中内联类要快得多。但要记住不应该太过拔高这一示例的意义,这只是为了演示而举的例子。 正如JMH框架本身给出的警告所言:“不要因为你想看到什么样的结果,就假设数字会告诉你怎样的结果。”
例如,在这个例子中infoq.OptionalInt的测试版本大约分配了50%——是因为分配的减少导致了性能提升?还是还有其他性能影响?只看这个基准测试并不能得出结论——它仅仅是一个数据点而已。
这个粗略的基准测试结果只能表明内联类在某些精心选择的场景下可能体现出显著的加速效,除此之外不应该特别看重这个结果或将其用于其他用途。
例如,LW2原型仅支持解释模式和C2(服务器)JIT编译器。没有C1(客户端)编译器,没有分层编译,也没有Graal。此外,解释器尚未优化,因为重心都放在了JIT实现上。预期所有这些功能都将在Java的发行版本中提供,而如果没有它们,所有的性能数字都会是完全不可靠的。
实际上,就当前的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);
System.out.println("Total: "+ total);
}
内联类的实例始终可以强制转换为间接投影的实例,但反之则需进行空检查,如示例中的lambda正文所示。
注意:间接投影的使用仍处于实验阶段。内联类的最终版本可能使用完全不同的设计。
在内联类真正准备好成为Java语言中的功能之前仍有大量工作要做。像LW2这样的原型对于感兴趣的开发人员来说是很有趣的尝试,但应该牢记这些只是一种智力活动。当前版本中的任何内容都不一定是这个功能最终采用的形式。
Ben Evans是JVM性能优化公司jClarity的联合创始人。他是LJC(伦敦的JUG)的组织者和JCP执行委员会成员,帮助定义Java生态系统的标准。Ben是Java冠军、3次JavaOne Rockstar发言人;他是《全能Java开发人员》和《Java简介》《优化Java》新版的作者。他定期为Java平台、性能、体系结构、并发性、创业企业等相关主题发表演说。Ben有时可以提供演讲、教学、写作和咨询服务——请联系他获取详细信息。
原文链接:
领取专属 10元无门槛券
私享最新 技术干货