2.4.3 invokedynamic指令

在详细介绍了java.lang.invoke包中与方法句柄相关的API之后,现在要深入到Java虚拟机的指令层次。本节将要介绍的是JSR 292中引入的新的方法调用指令invokedynamic。这个新指令的引入,为Java虚拟机平台上动态语言的开发带来了福音。动态语言的实现者终于可以在灵活性、复杂性和性能这几个要素之间找到一个很好的平衡。利用invokedynamic指令,可以通过简单高效的方式实现灵活性很强的方法调用。

方法调用是Java虚拟机执行过程中最常见也是最重要的指令,控制着程序的具体执行流程。在Java 7之前,Java虚拟机中包含了4种方法调用指令,分别是invokestatic、invokespecial、invokevirtual和invokeinterface。这4种指令分别适用于不同的方法调用场景,都可以在Java语言的源代码中找到相应的产生模式。下面先介绍这4种方法调用指令。

1.普通方法调用指令

在本章的开始部分中提到了一个现象,那就是Java字节代码规范受Java语言的影响很深。因此虽然本节介绍的主体是Java字节代码规范中的方法调用指令,但是仍然免不了用Java语言的语法来做类比。代码清单2-66给出了Java程序中可能会出现的几种方法调用形式。

代码清单2-66 Java程序中的方法调用形式


public interface SampleInterface{

void sampleMethodInInterface();

}

public class Sample implements SampleInterface{

public void sampleMethodInInterface(){}

public void normalMethod(){}

public static void staticSampleMethod(){}

}

public class MethodInvokeTypes{

public void invoke(){

SampleInterface sample=new Sample();

sample.sampleMethodInInterface();

Sample newSample=new Sample();

newSample.normalMethod();

Sample.staticSampleMethod();

}

}


上面代码中包含一个接口SampleInterface,以及这个接口的实现类Sample。类Sample中除了实现SampleInterface接口所需要的sampleMethodInInterface方法之外,还包含一个一般的公开方法normalMethod和一个静态方法staticSampleMethod。类MethodInvokeTypes的invoke方法中既有通过构造方法创建新的对象,又有对接口中方法和类中的一般方法和静态方法的调用。这实际上就代表了Java语言中所提供的4种方法调用形式。而在Java字节代码规范中,同样有4种方法调用指令与这4种调用方式相对应。

为了探究这4种方法调用指令,需要查看包含Java字节代码的class文件的内容。在这里需要使用JDK中自带的javap工具来完成。比如代码清单2-66中的MethodInvokeTypes类编译出来的class文件,可以通过在类文件所在目录下使用javap-verbose MethodInvokeTypes命令来显示class文件的内容。一个class文件中包含的内容很多,javap工具也会输出很多内容。本书第8章会详细介绍Java字节代码的格式,这里只介绍与方法调用相关的指令。图2-1给出了在javap输出中与方法调用相关的部分。

2.4.3 invokedynamic指令 - 图1

图 2-1 通过javap工具查看方法调用相关的字节代码内容

按照Java源代码中的顺序来看,第一个方法调用指令是invokespecial,这个指令是用来调用类的构造方法、父类的方法(通过super)和私有方法的。这里的invokespecial指令调用的是Sample类的构造方法,对应源代码中的“new Sample()”。第二个方法调用指令是invokeinterface,这个指令是通过接口来调用方法的。这里的invokeinterface指令调用的是SampleInterface接口中的sampleMethodInInterface方法。第三个方法调用指令是invokevirtual,这个指令是用来调用类中的一般方法的。所调用的实际方法取决于调用接收者对象的运行时类型。这里的invokevirtual指令调用的是Sample类中的normalMethod方法。最后一个方法调用指令是invokestatic,这个指令用来调用类中的静态方法。这里调用的是Sample类的staticSampleMethod方法。

在这4个方法调用指令中,除了invokestatic之外,都需要一个调用的接收者。因为静态方法是在类中定义的,并不需要一个具体的对象实例作为接收者。其他3个调用指令的接收者就是方法调用表达式的“.”之前的对象。这些接收者有一个静态的类型,也就是在编译时刻确定的调用对象的类型。对于invokespecial和invokevirtual来说,这个静态类型就是接收者对象的类型;而对于invokeinterface来说,这个静态类型就是接口的类型。而在运行时刻,接收者会有一个动态类型。这个动态类型可能和编译时刻确定的静态类型一样,也可能不一样。这其中的原因是存在运行时的方法派发。如果这个动态类型和静态类型不一样,那么该动态类型肯定是静态类型的子类型,可能是静态类型所表示的Java类的子类或所表示的接口的实现类。在代码清单2-67中,hashCode方法的接收者的静态类型是Object类,但是其在运行时刻的动态类型是String类,所调用的是String类中定义的hashCode方法。

代码清单2-67 静态类型与动态类型的区别


String str="Hello World";

Object obj=str;

System.out.println(obj.hashCode());


在这里需要说明的是,这4种普通的方法调用指令只支持方法调用时的单派发(single dispatch),也就是说实际调用时对方法的选择只会根据调用的接收者不同而有所不同,不受其他因素的影响。这种单派发只对invokevirtual和invokeinterface两种指令有效。由于类继承和接口实现机制的存在,实际的方法调用接收者可能是声明时类型的子类是接口的实现类,取决于运行时刻的动态类型。而另外的invokestatic和invokespecial指令的调用接收者都是固定的,是无法在运行时改变的。如果按照方法句柄的方式,把方法的调用者也抽象成方法调用时的一个参数,就可以知道单派发方式实际上只根据方法调用时的第一个参数来进行方法的分配。有些编程语言需要支持多派发,也就是说在方法调用时会根据多个参数的值来选择要具体执行的方法。多派发在某些情况下会使代码编写起来更加简单。

2.invokedynamic指令简介

上一节说明了Java字节代码规范中的4种普通的方法调用指令。这4种方法调用指令的特点是在Java语言中有相应的语法形式与其对应,同时灵活性比较低。下面从典型的方法调用流程来说明灵活性不足体现在什么地方。

当Java虚拟机执行方法调用的时候,需要确定下面4个要素。

1)名称:要调用的方法的名称一般是由开发人员在源代码中指定的符号名称。这个名称同样会出现在编译之后的字节代码中。

2)链接:链接包含了要调用方法的类。这一步有可能会涉及类的加载。

3)选择:选择要调用的方法。在类中根据方法名称和参数选择要调用的方法。

4)适配:调用者和接收者对调用的方式达成一致,即对方法的类型声明达成共识。

确定了上面4个要素之后,Java虚拟机会把控制权转移到被调用的方法中,并把调用时的实际参数传递过去。

再结合图2-1给出的4种调用指令来看这4个要素是如何体现的:所有这4种调用指令中的方法名称都是直接在字节代码中固定下来的,从Java源代码中直接映射过来。而链接的过程也由Java虚拟机来统一处理。唯一可能变化的就是方法的选择和适配。对于invokestatic来说,方法的选择是固定的,总是调用声明了此静态方法的类中的方法。另外方法的声明也必须是完全匹配的。对于invokespecial来说,由于它所调用的方法只有当前类的对象才有权限调用,因此它的方法选择也是固定的。而对于invokevirtual和invokeinterface来说,由于类继承和接口实现的存在,它们的方法选择是不固定的,但是仅限于根据调用的接收者类型来进行选择,即上面提到的单派发机制。

比如代码清单2-67中的hashCode方法的调用,实际方法的选择取决于调用的接收者。如果接收者是一个Object类型的对象,那么调用的是Object类中的方法;如果接收者是一个String类型的对象,那么调用的是String类中的方法;如果另外一个类继承自Object类,但是没有覆写hashCode方法,那么调用的还是Object类中的方法。

从方法适配的角度来说,只有接收者一方能进行适配,而且只能缩小类型的范围。比如在调用一个类中的方法的时候,接收者可以换成该类的子类的对象,但是不能换成父类的对象。

Java 7中新引入invokedynamic指令的目的就是弥补已有的4个方法调用指令的不足,提供更加强大的灵活性。既可以方便Java虚拟机上动态语言的编译器开发人员,也适应于对方法调用灵活性要求较高的一般应用。

新指令invokedynamic在多个方面解放了方法调用,还是通过上面给出的方法调用4个要素来说明:

1)在方法的名称方面,不一定是符合Java命名规范的字符串,可以任意指定。方法的调用者和提供者也不需要在方法名称上达成一致。实际上,上一节介绍的方法句柄就已经把方法的名称剥离出去了。

2)提供了更加灵活的链接方式。一个方法调用所实际调用的方法可以在运行时再确定。这就相当于把链接操作推迟到了运行时,而不是必须在编译时就确定下来。对于一个已经链接好的方法调用,也可以重新进行链接,让它指向另外的方法。

3)在方法选择方面,不再是只能在方法调用的接收者上进行派发,而是可以考虑所有调用时的参数,即支持方法的多派发。

4)在调用之前,可以对参数进行各种不同的处理,包括类型转换、添加和删除参数、收集和分发可变长度参数等。在2.4.2节中已经介绍过这些变换操作了。

新的invokedynamic指令需要与2.4.2节中介绍的方法句柄结合起来使用。该指令的灵活性在很大程度上取决于方法句柄的灵活性。对于invokedynamic指令来说,在Java源代码中是没有直接的对应产生方式的。这也是invokedynamic指令的新颖之处。它是一个完全的Java字节代码规范中的指令。传统的Java编译器并不会帮开发人员生成invokedynamic指令。为了利用invokedynamic指令,需要开发人员自己来生成包含这个指令的Java字节代码。因为这个指令本来就是设计给动态语言的编译器使用的,所以这种限制也是合理的。对于一般的程序来说,如果希望使用这个指令,就需要使用操作Java字节代码的工具来完成。本书第8章会详细介绍如何对字节代码进行操作。这里不再详细介绍工具的用法,读者只需要理解最终生成的字节代码中所包含的内容就可以了。

在字节代码中每个出现的invokedynamic指令都成为一个动态调用点(dynamic call site)。每个动态调用点在初始化的时候,都处于未链接的状态。在这个时候,这个动态调用点并没有被指定要调用的实际方法。

当Java虚拟机要执行invokedynamic指令时,首先需要链接其对应的动态调用点。在链接的时候,Java虚拟机会先调用一个启动方法(bootstrap method)。这个启动方法的返回值是java.lang.invoke.CallSite类的对象。在通过启动方法得到了CallSite之后,通过这个CallSite对象的getTarget方法可以获取到实际要调用的目标方法句柄。有了方法句柄之后,对这个动态调用点的调用,实际上是代理给方法句柄来完成的。也就是说,对invokedynamic指令的调用实际上就等价于对方法句柄的调用,具体来说是被转换成对方法句柄的invoke方法的调用。

3.动态调用点

Java 7中提供了三种类型的动态调用点CallSite的实现,分别是java.lang.invoke.ConstantCallSite、java.lang.invoke.MutableCallSite和java.lang.invoke.VolatileCallSite。这些CallSite实现的不同之处在于所对应的目标方法句柄的特性不同。ConstantCallSite所表示的调用点绑定的是一个固定的方法句柄,一旦链接之后,就无法修改;MutableCallSite所表示的调用点则允许在运行时动态修改其目标方法句柄,即可以重新链接到新的方法句柄上;而VolatileCallSite的作用与MutableCallSite类似,不同的是它适用于多线程情况,用来保证对于目标方法句柄所做的修改能够被其他线程看到。这也是名称中volatile的含义所在,类似于Java中的volatile关键词的作用。

虽然CallSite一般同invokedynamic指令结合起来使用,但是在Java代码中也可以通过调用CallSite的dynamicInvoker方法来获取一个方法句柄。调用这个方法句柄就相当于执行invokedynamic指令。通过此方法可以预先对CallSite进行测试,以保证字节代码中的invokedynamic指令的行为是正确的,毕竟在生成的字节代码中进行调试是一件很麻烦的事情。下面介绍CallSite时会先通过dynamicInvoker方法在Java程序中直接试验CallSite的使用。

首先介绍ConstantCallSite的使用。ConstantCallSite要求在创建的时候就指定其链接到的目标方法句柄。每次该调用点被调用的时候,总是会执行对应的目标方法句柄。在代码清单2-68中,创建了一个ConstantCallSite并指定目标方法句柄为引用String类中的substring方法。

代码清单2-68 ConstantCallSite的使用示例


public void useConstantCallSite()throws Throwable{

MethodHandles.Lookup lookup=MethodHandles.lookup();

MethodType type=MethodType.methodType(String.class, int.class, int.class);

MethodHandle mh=lookup.findVirtual(String.class,"substring",type);

ConstantCallSite callSite=new ConstantCallSite(mh);

MethodHandle invoker=callSite.dynamicInvoker();

String result=(String)invoker.invoke("Hello",2,3);

}


接下来的MutableCallSite则允许对其所关联的目标方法句柄进行修改。修改操作是通过setTarget方法来完成的。在创建MutableCallSite的时候,既可以指定一个方法类型MethodType,又可以指定一个初始的方法句柄。如果像下面代码中那样指定方法类型,则通过setTarget设置的方法句柄都必须有同样的方法类型。如果创建时指定的是初始的方法句柄,则之后设置的其他方法句柄的类型也必须与初始的方法句柄相同。MutableCallSite对象中的目标方法句柄的类型总是固定的。下面的代码通过setTarget方法把目标方法句柄分别设置为Math类中的max和min方法,在调用MutableCallSite时可以得到不同的结果。

代码清单2-69 MutableCallSite的使用示例


public void useMutableCallSite()throws Throwable{

MethodType type=MethodType.methodType(int.class, int.class, int.class);

MutableCallSite callSite=new MutableCallSite(type);

MethodHandle invoker=callSite.dynamicInvoker();

MethodHandles.Lookup lookup=MethodHandles.lookup();

MethodHandle mhMax=lookup.findStatic(Math.class,"max",type);

MethodHandle mhMin=lookup.findStatic(Math.class,"min",type);

callSite.setTarget(mhMax);

int result=(int)invoker.invoke(3,5);//值为5

callSite.setTarget(mhMin);

result=(int)invoker.invoke(3,5);//值为3

}


需要考虑的是多线程情况下的可见性问题。有可能在一个线程中对MutableCallSite的目标方法句柄做了修改,而在另外一个线程中不能及时看到这个变化。对于这种情况,MutableCallSite提供了一个静态方法syncAll来强制要求各个线程中MutableCallSite的使用者立即获取最新的目标方法句柄。该方法接收一个MutableCallSite类型的数组作为参数。

如果一个目标方法句柄可变的调用点被设计为在多线程的情况下使用,可以直接使用VolatileCallSite,而不使用MutableCallSite。当使用VolatileCallSite的时候,每当目标方法句柄发生变化的时候,其他线程会自动看到这个变化。这与Java中volatile关键词的语义是一样的。这比使用MutableCallSite再加上syncAll方法要简单得多。除了这一点之外,VolatileCallSite的作用与MutableCallSite完全相同。

4.invokedynamic指令实战

下面将要介绍invokedynamic指令在Java字节代码中的具体使用方式。由于涉及字节代码的生成,这里使用了ASM工具[1]。暂时不会对ASM工具的使用做过多的介绍,在第8章中会进行详细介绍。首先需要提供invokedynamic指令所需的启动方法,如代码清单2-70所示。

代码清单2-70 invokedynamic指令的启动方法


public class ToUpperCase{

public static CallSite bootstrap(Lookup lookup, String name, MethodType type, String value)throws Exception{

MethodHandle mh=lookup.findVirtual(String.class,"toUpperCase",MethodType.methodType(String.class)).bindTo(value);

return new ConstantCallSite(mh);

}

}


该启动方法是一个普通的Java类中的方法。该方法的类型声明可以是多种格式。返回值必须是CallSite,而参数则允许多种形式。在典型情况下,前面的3个参数分别是进行方法查找的MethodHandles.Lookup对象、方法的名称和方法的类型MethodType。这3个参数之后的其他参数都会被传递给CallSite对应的方法句柄。在上面的代码中,使用了一个ConstantCallSite,而该调用点所绑定的方法句柄引用的底层方法是String类中的toUpperCase方法。启动方法bootstrap接收一个额外的参数value。这个参数被预先绑定给方法句柄。因此当该方法句柄被调用的时候,不需要额外的参数,而返回结果是对参数value表示的字符串调用toUpperCase方法的结果。

有了启动方法之后,就需要在字节代码中生成invokedynamic指令。代码清单2-71给出的程序会产生一个新的Java类文件ToUpperCaseMain.class。通过java命令可以运行该类文件,输出结果是“HELLO”。

代码清单2-71 生成使用invokedynamic指令的字节代码


public class ToUpperCaseGenerator{

private static final MethodHandle BSM=

new MethodHandle(MH_INVOKESTATIC,

ToUpperCase.class.getName().replace('.','/'),

"bootstrap",

MethodType.methodType(

CallSite.class, Lookup.class, String.class, MethodType.class, String.class).toMethodDescriptorString());

public static void main(String[]args)throws IOException{

ClassWriter cw=new ClassWriter(ClassWriter.COMPUTE_FRAMES);

cw.visit(V1_7,ACC_PUBLIC|ACC_SUPER,"ToUpperCaseMain",null,"java/lang/Object",null);

MethodVisitor mv=cw.visitMethod(ACC_PUBLIC|ACC_STATIC,"main","([Ljava/lang/String;)V",null, null);

mv.visitCode();

mv.visitFieldInsn(GETSTATIC,"java/lang/System","out","Ljava/io/PrintStream;");

mv.visitInvokeDynamicInsn("toUpperCase","()Ljava/lang/String;",BSM,"Hello");

mv.visitMethodInsn(INVOKEVIRTUAL,"java/io/PrintStream","println","(Ljava/lang/String;)V");

mv.visitInsn(RETURN);

mv.visitMaxs(0,0);

mv.visitEnd();

cw.visitEnd();

Files.write(Paths.get("ToUpperCaseMain.class"),cw.toByteArray());

}

}


上面的代码中包含了大量使用ASM工具的代码,这里只需要关心的是“mv.visit InvokeDynamicInsn("toUpperCase","()Ljava/lang/String;",BSM,"Hello");”这行代码。这行代码是用来在字节代码中生成invokedynamic指令的。在调用的时候传入了方法的名称、方法句柄的类型、对应的启动方法和额外的参数“Hello”。在invokedynamic指令被执行的时候,会先调用对应的启动方法,即代码清单2-70中的bootstrap方法。bootstrap方法的返回值是一个ConstantCallSite的对象。接着从该ConstantCallSite对象中通过getTarget方法获取目标方法句柄,最后再调用此方法句柄。在调用visitInvokeDynamicInsn方法时提供了一个额外的参数“Hello”。这个参数会被传递给bootstrap方法的最后一个参数value,用来创建目标方法句柄。当目标方法句柄被调用的时候,返回的结果是把参数“Hello”转换成大写形式之后的值“HELLO”。

从上面这个简单的示例可以看出,invokedynamic指令是如何与方法句柄结合起来使用的。上面的示例只使用了最简单的ConstantCallSite。复杂的示例包括根据参数的值确定需要返回的CallSite对象,或是对已有的MutableCallSite对象的目标方法句柄进行修改等。

[1]ASM工具的官方网站是http://asm.ow2.org/。