java 内联
重要要点
- Valhalla项目正在开发内联类,以提高Java程序对现代硬件的亲和力
- 内联类使开发人员能够编写行为更像Java内置基元类型的类型
- 内联类的实例不具有对象标识,这带来了许多优化机会
- 内联类的到来重新引发了有关Java泛型和类型擦除的争论
- 尽管很有希望,但这仍在进行中,尚未投入生产
在本文中,我将介绍内联类。 此功能是以前称为“值类型”的演变。 这个功能的探索和研究仍在进行中,并且是Valhalla项目中的主要工作流程,InfoQ和Oracle Java杂志已经对此进行了报道 。
为什么要内联类?
内联类的目标是提高Java程序对现代硬件的亲和力。 这将通过重新审视Java平台的一个非常基本的部分来实现,即Java数据值的模型。
从最初的Java版本到今天,Java仅有两种类型的值:基本类型和对象引用。 该模型非常简单,开发人员易于理解,但可以在性能上进行取舍。 例如,处理对象数组涉及不可避免的间接访问,这可能导致处理器高速缓存未命中。
许多关心性能的程序员都希望能够处理更有效利用内存的数据。 更好的布局意味着更少的间接访问,这意味着更少的缓存丢失和更高的性能。
另一个感兴趣的主要领域是消除为每个数据组合需要一个完整的对象标头的开销— 拼合数据。
就目前而言,Java堆中的每个对象都具有元数据头以及实际的字段内容。 在热点,这头实际上是两个机器字- 标记和克拉斯 。 首先是标记词,其中包含特定于此特定对象实例的元数据。
元数据的第二个单词称为klass单词,它是指向元数据(存储在内存的Metaspace区域中)的指针,该元数据与同一类的所有其他实例共享。 对于理解运行时如何实现某些语言功能(例如虚拟方法查找)的关键,该指针非常重要。
但是,对于内联类的讨论, 标记词中保存的数据特别重要,因为它与Java对象的标识概念固有地联系在一起。
内联类和对象标识
回想一下,在Java中,两个对象实例并不仅仅因为它们的所有字段都具有相同的值就被认为是相等的。 Java使用==运算符来确定两个引用是否指向相同的内存位置,如果对象分别存储在内存中,则它们不被视为相同。
注意:此身份概念与锁定Java对象的能力有关。 实际上, 标记词用于存储对象监视器(以及其他内容)。
但是,对于内联类,我们希望复合材料的语义本质上是原始类型的语义。 在那种情况下,唯一重要的是数据的位模式,而不是该模式在内存中出现的位置。
因此,通过删除对象标头,我们还删除了组合的唯一标识。 此更改释放了运行时,可以在布局,调用约定,编译和分配方面进行重大优化。
注意:删除还对内联类的设计有其他影响。 例如,它们无法同步(因为它们既没有唯一标识,也没有存储监视器的位置)。
内联类
注意:内联类与即将推出的记录功能不同。 Java记录只是一个常规类,使用简化的样例进行声明,并具有一些标准化的,由编译器生成的方法。 另一方面,内联类是JVM中一个根本上的新概念,它以根本方式改变了Java的内存模型。
当前的内联类原型(称为LW2)是可以运行的,但仍处于非常非常早期的阶段。 它的目标受众是高级开发人员,库作者和工具制造商。
使用LW2原型
让我们深入研究LW2当前状态下的内联类可以完成的一些示例。 我将能够使用低级技术(例如字节码和堆直方图)展示内联类的效果。 未来的原型将添加更多的用户可见的和更高层次的方面,但是它们尚未完成,因此我将不得不坚持低层次。
要获得支持LW2的OpenJDK构建,最简单的选择是从此处下载它-Linux,Windows和Mac构建可用。 另外,经验丰富的开源开发人员可以从头开始构建自己的二进制文件。
一旦下载并安装了原型,我们就可以使用它开发一些内联类。
要在LW2中创建内联类,请使用inline关键字标记类声明。
内联类的规则(目前,其中一些规则可能会在将来的原型中放宽或更改):
- 接口,注释类型,枚举不能是内联类
- 顶级内部,嵌套本地类可以是内联类
- 内联类不可为空,而是具有默认值
- 内联类可以声明内部,嵌套,本地类型
- 最终的,因此不能是抽象的
- java.lang.Object
- 内联类可以显式实现常规接口
- 内联类的所有实例字段都是隐式最终的
- 内联类不能声明自己类型的实例字段
- javac自动生成hashCode(),equals()和toString()
- Javac不允许对内联类使用clone(),finalize(),wait()或notify()
中的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 -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
- defaultvalue用于创建新的值实例
- 使用withfield代替setfield
注意:这种设计的后果之一是,对于每个内联类, 默认值的结果必须是该类型的一致且可用的值。
withfield的语义是用更新后的字段将修改后的值替换为堆栈顶部的值实例。 这与setfield
hashCode()和equals()的实现
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 <pid>
,它会产生如下结果:
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值数组,它大约占据了800M(每个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%。 这是短语“像类一样的代码,像整数一样工作”的含义的一个很好的例子。
使用JMH进行基准测试
让我们来看看一个简单的JMH基准测试。 这样做的目的是让我们看到从减少程序运行时间的角度来看,删除间接寻址和高速缓存未命中的效果。
有关如何设置和运行JMH基准的详细信息,请参见OpenJDK网站 。
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预览相比,不仅仅是性能还有很多工作要做。 基本问题仍然存在,例如:
- 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);
System.out.println("Total: "+ total);
}
内联类的实例始终可以强制转换为间接投影的实例,但反之,则需要进行空检查,如示例中的lambda正文所示。
注意:间接投影的使用仍处于实验阶段。 内联类的最终版本可能完全使用不同的设计。
在内联类准备好成为Java语言中的真正功能之前,仍有大量工作要做。 像LW2这样的原型对于感兴趣的开发人员来说是很有趣的尝试,但是应该始终记住,这些只是一种智力活动。 当前版本中的任何内容都无法保证该功能最终采用的最终形式。