为了使JVM对动态语言更具吸引力,该平台的第七版已将invokedynamic引入了其指令集。 Java开发人员通常不会注意到此功能,因为它隐藏在Java字节码中。 简而言之,通过使用invokedynamic ,可以将方法调用的绑定延迟到第一次调用之前。 例如,该技术由Java语言用于实现lambda表达式,这些表达式仅在首次使用时才需要出现。 这样做, invokedynamic已发展成为一种基本的语言功能,我在先前的博客文章中对此进行了详细介绍 。 使用constantdynamic ,Java 11中引入了类似的机制,只是它延迟了常量值的创建。 这篇文章描述了此功能的目的和内部工作原理,并展示了如何使用Byte Buddy库生成利用此新指令的代码。
Java中的常量值是什么?
在Java 5之前,Java程序中的常量值只能是字符串或原始类型。 这些常量作为文字内置在语言中,甚至由javac编译器假定以减小类文件的大小。 例如,在以下代码片段中,从不实际读取only字段的值,而是在编译期间将其复制到其使用站点:
class ConstantSample {
final String field = “foo”;
void hello() {
System.out.print(field);
}
}
代替读取hello
方法中的字段,生成的字节码将包含对常量foo
的直接引用。 实际上,上述类将永远不会尝试读取该字段的值,可以通过使用Java反射对其进行更改来验证该字段的值,然后调用hello仍会打印foo
。
为了表示这样的常量值,任何Java类文件都包含一个常量池,可以将其视为一个表,该表写出存在于类范围内的任何常量值。 这意味着在方法中使用或用作字段值的常量,但也包含描述类的其他不可变信息,例如类的名称或被调用方法的名称及其声明的类型名称。 一旦在类的常量池中记录了一个值,就可以通过指向常量池中特定条目的偏移量来引用值。 这样做,在整个类中重复的值仅需要存储一次,因为偏移量当然可以多次引用。
因此,当在上述源代码中读取该字段时, javac发出一个字节代码,该字节代码引用常量池中foo值的偏移量,而不是发出对该字段的读取指令。 当该字段被声明为final时,可以做到这一点,其中javac会忽略反射值更改的边缘情况。 通过发出读取常量的指令,与读取字段的指令相比, javac还节省了一些字节。 这就是使这种优化有利可图的原因,特别是因为字符串和数字值在任何Java类中都相当普遍。 较小的类文件可帮助Java运行时更快地加载类,而显式的常量性概念可帮助JVM的JIT和AOT编译器应用进一步的优化。
所描述的针对相同常数的偏移量的重用还隐含重用值的标识。 由于用单个实例表示相等的字符串值,因此以下语句在Java中将声明为true:
assert “foo” == “foo”;
在幕后,foo的两个值都指向定义类的常量池中的相同常量池偏移量。 此外,JVM甚至可以通过遍历在常量池中找到的字符串来跨类对常量字符串进行重复数据删除。
恒定池存储的局限性
类文件的常量池中值的这种表格表示形式非常适合简单值,例如字符串和数字基元。 但是同时,当javac没有发现恒定的值时,它可能会带来非直观的后果。 例如,在以下类中, hello
方法中未将唯一字段的值视为常量:
class NoConstantSample {
final String field = “foo”.toString();
void hello() {
System.out.print(field);
}
}
尽管toString
方法对于字符串而言是微不足道的,但是这种情况对于不评估Java方法的javac仍然未知。 因此,编译器不能再将恒定池值作为print语句的输入。 相反,它必须发出该字段的字段读取指令,如前所述,该指令需要其他字节。 这次,如果使用反射更改了字段的值,则调用hello
也将打印更新的值。
当然,这个例子是人为的。 但不难想象,在实践中如何将经典方法限制为Java中的常量。 例如,想象一个定义为Math.max(CONST_A, CONST_B)
的整数值。 当然,两个编译时常数的最大值本身就是常数。 但是,由于javac无法评估Java方法,因此派生的值不会被发现为常量,而只能在运行时进行计算。
在类文件的常量池中声明常量值的另一个问题是它对简单值的限制。 字符串和数值的表示当然很简单,但是比传统方法更复杂的Java对象需要更大的灵活性。 为了支持其他常量,Java类文件格式已经在Java 5中添加了类文字常量,其中诸如String.class
类的值将不再被编译为对Class.forName("java.lang.String")
的调用,而是一个常量。包含类引用的池条目。 Java 7发行版还在类文件规范中添加了新的常量池类型,以允许MethodType
和MethodHandle
实例的常量表示。
与字符串,类和原始值相反,Java编程语言不提供用于创建这些常量的文字。 而是,在javac需要一种有效的表示方式的情况下,添加了此类常量的可能性,以更好地支持invokedynamic指令。 本质上,lambda表达式由lambda的表达式类型签名MethodType
及其对其实现的引用MethodHandle
。 如果必须为每个对lambda表达式的调用将这两个值都创建为显式,非恒定参数,则使用此类表达式的性能开销肯定会超过其收益。
尽管此解决方案减轻了一些中间的麻烦,但它暗示着对Java的未来不满意,无法添加更多常量类型。 常量池条目的类型由单个字节编码,这严重限制了类文件中可能的常量类型的总数。 另一个麻烦是,对类文件格式的更改要求对处理类文件的任何工具进行级联调整,这使得需要一种更通用的表示常量值的方法。 通过引入constantdynamic ,最终在即将发布的Java 11中,Java虚拟机将支持这种机制。
引入动态常数
动态常量不是通过处理文字表达式来创建的,而是通过调用产生该常量值作为结果的所谓的引导方法来创建的。 这与通过在运行时调用引导程序方法绑定绑定方法调用站点的invokedynamic指令非常相似,在运行时,将返回指向动态绑定调用站点的目标实现的指针。 作为主要区别,自举常量是不可变的,而动态绑定的方法调用可以在以后重定向到另一个实现。
本质上,引导程序只不过是Java方法,这些方法对其签名有一些要求。 作为第一个参数,任何引导方法都将接收由JVM自动提供的MethodHandles.Lookup
实例。 通过此类查找,可以使用类的特定实例表示的类的特权进行访问。 例如,当从任何类调用MethodHandles.lookup()
时,对调用者敏感的方法将返回一个实例,例如,该实例允许读取调用类的私有字段,而对于从另一个内部创建的查找实例而言,这是不可能的类。 在使用bootstrap方法的情况下,查找表示在创建时定义动态常数的类,而不是在声明boostrap方法的类。 这样做,引导程序方法可以访问相同的信息,就好像该常量是在常量定义类本身内部创建的一样。 bootstrap方法作为第二个参数接收常量的名称,作为第三个参数,它接收常量的预期类型。 引导程序方法必须是静态的,或者是构造函数,其中构造的值表示常量。
在许多情况下,实现自举方法不需要这三个参数,但是它们的存在允许实现更通用的自举机制,以便于允许重用自举方法来创建多个常量。 如果需要,在声明引导方法时也可以省略最后两个参数。 但是,需要将MethodHandles.Lookup
类型声明为第一个参数。 这样做是为了在将来可能允许在第一个参数用作标记类型的情况下进一步调用模式。 这是与invokedynamic的另一个区别,它允许省略第一个参数。
有了这些知识,我们现在可以表示两个常量的先前最大值,该常量先前已作为派生常量提到。 该值是通过以下引导方法微不足道地计算的:
public class Bootstrapper {
public static int bootstrap(MethodHandles.Lookup lookup, String name, Class type) {
return Math.max(CONST_A, CONST_B);
}
}
由于作为第一个参数的查找实例具有定义该常量的类的特权,因此即使使用引导程序方法通常看不到这些值,也可以通过使用此查找来获取CONST_A
和CONST_B
的值。 ,例如因为它们是私有的。 该类的javadoc详细说明了需要使用什么API来定位字段并读取其值。
为了创建动态常量,必须在类的常量池中引用引导程序方法作为动态常量类型的条目。 到目前为止,Java语言无法创建此类条目,据我所知,目前也没有其他语言在使用这种机制。 出于这个原因,我们将在本文后面探讨使用代码生成库Byte Buddy创建此类。 在暗示注释中有常量池值的Java伪代码中,动态常量及其引导方法将被称为:
class DynamicConstant {
// constant pool #1 = 10
// constant pool #2 = 20
// constant pool #3 = constantdyamic:Bootstrapper.bootstrap/maximum/int.class
final int CONST_A = [constant #1], CONST_B = [constant #2];
void hello() {
System.out.print([constant #3]);
}
}
首次执行hello
方法后,JVM将通过调用Bootstrapper.bootstrap
方法来解析指定的常量,该方法的最大值为常量名,而int.class
为创建的常量的请求类型。 在从bootstrap方法接收到结果之后,JVM将用该结果替换对该常量的任何引用,并且不再再次调用bootstrap方法。 如果在多个位置引用了动态常数,这也将是正确的。
避免自定义引导方法
在大多数情况下,创建动态常量不需要实现单独的引导方法。 为了涵盖大多数用例,JVM绑定的类java.lang.invoke.ConstantBootstraps
已经实现了几种通用的引导方法,可用于创建大多数常量。 作为核心,类的invoke
方法允许通过提供方法引用作为常量值的工厂来定义常量。 为了使这种通用方法有效,引导程序方法可以接收任意数量的附加参数,这些参数本身必须是恒定值。 然后,在描述动态常量的条目时,将这些参数作为对其他常量池条目的引用。
这样做,可以通过提供Math.max
方法的句柄以及CONST_A
和CONST_B
的两个常量值作为附加参数来计算上述最大值。 然后, ConstantBootstraps
中的invoke
方法的实现将使用这两个值来调用Math.max
并返回结果,其中bootstrap方法大致实现如下:
class ConstantBootstraps {
static Object invoke(MethodHandles.Lookup lookup, String name, Class type,
MethodHandle handle, Object[] arguments) throws Throwable {
return handle.invokeWithArguments(arguments);
}
}
当将其他参数提供给引导方法时,它们将按其顺序分配给每个其他方法参数。 为了允许更灵活的引导程序方法(例如上面的invoke方法),最后一个参数也可以是Object
数组类型,以接收任何多余的参数,在这种情况下为两个整数值。 如果引导程序方法不接受提供的参数,则JVM将不会调用引导程序方法,而是在失败的常量解析期间引发BootstrapMethodError
。
使用这种方法,使用ConstantBootstraps.invoke
的伪代码将不再需要单独的引导程序方法,而是类似于以下伪代码:
class AlternativeDynamicConstant {
// constant pool #1 = 10
// constant pool #2 = 20
// constant pool #3 = MethodHandle:Math.max(int,int)
// constant pool #4 = constantdyamic:ConstantBootstraps.invoke/maximum/int.class/#3,#1,#2
final int CONST_A = [constant #1], CONST_B = [constant #2];
void hello() {
System.out.print([constant #4]);
}
}
嵌套动态常数
如前所述,引导方法的参数必须是其他常量池条目。 由于动态常量存储在常量池中,因此可以嵌套动态常量,这使此功能更加灵活。 这带有直观的限制,即动态常量的初始化不得包含圆圈。 例如,如果解决了Qux
值,将从顶部到底部调用以下引导程序方法:
static Foo boostrapFoo(MethodHandles.Lookup lookup, String name, Class type) {
return new Foo();
}
static Bar boostrapBar(MethodHandles.Lookup lookup, String name, Class type, Foo foo) {
return new Bar(foo);
}
static Qux boostrapQux(MethodHandles.Lookup lookup, String name, Class type, Bar bar) {
return new Qux(bar);
}
当需要JVM解析Qux
的动态常量时,它将首先解析Bar
这将再次触发Foo
的先前初始化,因为每个值都取决于前一个。
当表达静态常量池条目类型(例如空引用)不支持的值时,也可能需要嵌套动态常量。 在Java 11之前,空值只能表示为字节码指令,而不能表示为常量池值,其中字节码均未暗示null
的类型。 为了克服此限制, java.lang.invoke.ConstantBootstraps
提供了几种便捷的方法,例如nullValue
,该方法允许将键入的null
值引导为动态常量。 然后可以将此null
值作为参数提供给另一个引导程序方法,该方法希望将null
作为参数。 同样,不可能在常量池中表示只能表示引用类型的原始类型文字,例如int.class
。 相反, javac int.class
例如int.class
转换为对静态Integer.TYPE
字段的读取,该字段在启动时通过对JVM的本地调用来解析其int.class
值。 同样, ConstantBootstraps
提供了primitiveType
引导程序方法,可以轻松地将这些值表示为动态常量。
为什么要关心常数值?
以上所有内容听起来都像是一种技术诀窍,除了静态字段已经提供的功能外,对Java平台的添加并不多。 但是,动态常数的潜力很大,但尚未开发。 作为最明显的用例,动态常量可用于正确实现惰性值。 惰性值通常用于仅在使用时按需表示昂贵的对象。 从今天开始,惰性值通常是通过使用所谓的双重检查锁定来实现的 ,这种模式例如由scalac编译器为其lazy
关键字实现:
class LazyValue {
volatile ExpensiveValue value;
void get() {
T value = this.value;
if (value == null) {
synchronized (this) {
value = this.value;
if (value == null) {
value = new ExpensiveValue();
}
}
}
return value;
}
}
尽管值一旦初始化就永远不会改变,但上述构造要求每次读取都需要一个易失性读取。 这意味着不必要的开销,可以通过将惰性值表示为只有在使用过时才引导的动态常量来避免这种开销。 尤其是在Java核心库中,这对于延迟许多从未使用过的值的初始化很有用,例如在Locale
类中,尽管大多数JVM仅使用运行中的机器标准语言,但Locale
类却为任何受支持的语言初始化了值。 通过避免初始化这些多余的值,JVM可以更快地启动,并避免将内存用于无效值。
另一个重要的用例是使用常量表达式优化编译器。 不难想象,为什么编译器比可变值更喜欢处理常量。 例如,如果编译器可以合并两个常量,则此合并的结果可以永久替换先前的值。 如果原始值可能随时间变化,那么这当然是不可能的。 尽管即时编译器可能仍认为可变值在运行时实际上是恒定的,但提前编译器则依赖于某些明确的恒定性概念。 通过确保引导程序方法无副作用,例如,将来的Java版本可以允许对其编译时进行评估,其中常量动态可以用作轻量级宏机制,以扩大使用Graal用Java编写的本机映像的范围。
我会使用此功能吗?
在Java 7中引入invokedynamic时,从Java语言的角度来看,这个新的字节码功能尚未使用。 但是,从Java 8开始,可以在大多数类文件中找到作为lambda表达式的实现的invokedynamic指令。 同样,Java 11尚未使用恒定动力学功能,但是可以预料将来会有所改变。
在最新的JVMLS中,已经讨论了几种用于公开常量动态的潜在API(这也可以通过API访问invokedynamic )。 这对于库作者来说尤其有用,它允许他们更好地解析关键执行路径,但也可以释放一些潜力来改善javac的常量检测,例如,扩大非捕获lambda表达式的范围,其中可以访问字段或变量如果在编译过程中发现常量,则通过读取常量代替。 最后,这种新机制为将来的语言增强提供了潜力,例如一个惰性关键字,它避免了替代JVM语言中当前等效项的开销。
常量动态功能对于经常需要使用其他信息来增强现有类的Java代理也很有用。 Java代理通常无法通过添加静态字段来更改类,因为这会干扰基于反射的框架,并且由于在重新定义已加载的类时大多数JVM禁止更改类格式。 但是,这两种限制都不适用于运行时期间添加的动态常量,在动态常量中,Java代理现在可以轻松地用附加信息标记类。
使用字节好友创建动态常量
尽管缺少对constantdynamic的语言支持,但是版本11的JVM已经完全能够处理包含动态常量的类文件。 使用字节代码生成库Byte Buddy,我们可以创建此类文件并将其加载到JVM的早期访问版本中 。
在Byte Buddy中,动态常量由JavaConstant.Dynamic
的实例表示。 为了方便起见,Byte Buddy为工厂提供了由java.lang.invoke.ConstantBoostraps
类声明的任何引导方法,例如前面讨论的invoke
方法。
举一个简单的例子,下面的代码创建Callable
的子类,并将call方法的返回值定义为示例类的动态常量。 为了引导常量,我们将Sample
的构造函数提供给上述的invoke
方法:
public class Sample {
public static void main(String[] args) throws Throwable {
Constructor<? extends Callable<?>> loaded = new ByteBuddy()
.subclass(Callable.class)
.method(ElementMatchers.named("call"))
.intercept(FixedValue.value(JavaConstant.Dynamic.ofInvocation(Sample.class.getConstructor())))
.make()
.load(Sample.class.getClassLoader())
.getLoaded()
.getConstructor();
Callable<?> first = loaded.newInstance(), second = loaded.newInstance();
System.out.println("Callable instances created");
System.out.println(first.call() == second.call());
}
public Sample() {
System.out.println("Sample instance created");
}
}
如果运行代码,请注意如何仅创建一个Sample
实例,如本文所述。 还要注意如何仅在首次调用call方法时以及在创建Callable
实例之后才懒惰地创建实例。
要运行上面的代码,当前必须使用-Dnet.bytebuddy.experimental=true
运行Byte Buddy才能解除对此功能的支持。 Java 11最终确定并准备发布时,情况将发生变化,其中Byte Buddy 1.9.0将是第一个开箱即用支持Java 11的版本。 同样,在处理动态常量时,最新的Byte Buddy版本中仍然存在一些粗糙的地方。 因此,最好从master分支构建Byte Buddy或使用JitPack 。 要查找有关Byte Buddy的更多信息,请访问bytebuddy.net 。
翻译自: https://www.javacodegeeks.com/2018/08/hands-on-java-constantdynamic.html