JVM: 字节码执行引擎(2)-方法调用

方法调用不同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。

解析

解析调用一定是个静态的过程,在编译期间就完全确定,在类加载的解析阶段就会把涉及的符号引用转换为可确定的直接引用,不会延迟到运行期再去完成。

所有方法调用中的目标方法在Class文件中都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用。这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。也就是说,调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解析(Resolution)。

符合“编译期可知,运行期不可变”这个要求的方法,主要包括静态方法私有方法两大类(一共4类,下面会提到)。静态方法与类型直接关联,私有方法在外部不可访问,这两种方法的特点决定了它们不能通过继承或别的方式重写其他版本,因此适合在类加载阶段进行解析。

JVM提供了5条方法调用字节码指令:
invokestatic:调用静态方法。
invokespecial:调用实例构造器<init>()、私有方法和父类方法。
invokevirtual:调用所有的虚方法。
invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。
invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。
只要能被invokestaticinvokespecial指令调用的方法,都可以在解析阶段确定唯一的调用版本,符合这个条件的有:静态方法、私有方法、实例构造器、父类方法4类。(非虚方法

前4条调用指令,分派逻辑是固化在JVM内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。

非虚方法

在类加载的时候就会把方法调用的符号引用转换为该方法的直接引用,这些方法称为非虚方法
非虚方法除了使用invokestaticinvokespecial指令调用的方法之外还有一种,就是被final修饰的方法。虽然final方法是使用invokevirtual指令来调用的,但是由于它无法被覆盖,没有其他版本,所以也无须对方法接收者进行多态选择。

虚方法就是与之相反的方法。

分派

分派(Dispatch)调用则可能是静态的也可能是动态地,分派调用的过程会揭示多态性的一些最基本的体现,如“重载”和“重写”在Java虚拟机中是如何实现的。

静态分派(重载)

静态分派发生在编译阶段

静态分派在英文技术文档中称为“Method Overload Resolution”。静态分派的典型应用是重载

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
/**
* 重载overload
*/
public class MethodDispatchTest {
static class Human{}
static class Man extends Human{}
static class Woman extends Human{}
static void say(Human human){
System.out.println("Say, Human!");
}
static void say(Man man){
System.out.println("Say, Man!");
}
static void say(Woman woman){
System.out.println("Say, Woman!");
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
MethodDispatchTest.say(man);
MethodDispatchTest.say(woman);
}
}

程序输出结果

1
2
Say, Human!
Say, Human!

Human man = new Man();代码中的Human称为变量的静态类型(Static Type),或者称为外观类型(Apparent Type),后面的Man称为变量的实际类型(Actual Type)。

静态类型和实际类型的区别是:静态类型的变化在编译期是可知的,但是实际类型的变化却要在运行期才能确定。

1
2
3
4
5
6
7
// 动态类型变化
Human man = new Man();
man = new Woman();
// 静态类型变化
test.say((Man) man);
test.say((Woman) man)

编译器在重载时是通过参数的静态类型而不是实际类型作为判断依据的。

所有依赖静态类型来定位方法执行版本的分派动作称为静态分派

动态分派(重写)

动态分派发生在运行阶段
动态分派和多态性的另一个体现重写(Override)由很密切的关联。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/**
* 重写Override
*/
public class MethodDispatchTest {
static class Human{
void print(){
System.out.println("Print, Human!");
}
}
static class Man extends Human{
void print(){
System.out.println("Print, Man!");
}
}
static class Woman extends Human{
void print(){
System.out.println("Print, Woman!");
}
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
man.print();
woman.print();
}
}

程序输出结果:

1
2
Print, Man!
Print, Woman!

虚方法的调用是通过invokevirtual指令来实现的,在运行期需要确定方法接收者的实际类型
如果将上述重写的方法改为static修饰,从而变成非虚方法,那就会变成解析,输出的都是Print, Human!

动态类型语言支持

JDK 1.7中新增了一条字节码指令invokedynamic指令,这条指令时为了实现动态类型语言支持而进行的改进之一,也是为JDK 1.8可以顺序实现Lambda表达式做准备。

动态类型语言

动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期。例如:Clojure、Erlang、Groovy、JavaScript、Jython、Lisp、PHP、Python、Ruby等。
相对的,在编译期就进行类型检查过程的语言(如C/C++和Java等)就是最常用的静态类型语言

静态类型语言在编译期确定类型,最显著的好处是编译器可以提供严谨的类型检查,利于稳定性;动态类型语言在运行期确定类型,可以为开发人员提供更多的灵活性。

JDK 1.7与动态类型

java.lang.invoke包

这个包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这种方式以外,提供一种新的动态确定目标方法的机制,称为MethodHandle机制

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
public class MethodHandleTest {
static class ClassA{
public void println(String s){
System.out.println("ClassA: "+s);
}
}
public static void main(String[] args) throws Throwable {
Object obj = System.currentTimeMillis()%2 == 0?System.out:new ClassA();
// 无论obj最终是哪个实现类,都能正确调用println方法
getPrintlnMH(obj).invokeExact("Roger");
}
private static MethodHandle getPrintlnMH(Object receiver) throws NoSuchMethodException, IllegalAccessException {
// MethodType:代表方法类型,包含了方法的返回值和具体参数
MethodType methodType = MethodType.methodType(void.class, String.class);
// lookup()方法的作用是在指定的类中查找符合给定的方法名称、方法类型,并且符合调用权限的方法句柄。
// 调用虚方法,Java语言的规则是方法第一个参数是隐式的,也就是this指向的对象,代表该方法的接收者,通过bindTo()方法完成
return MethodHandles.lookup().findVirtual(receiver.getClass(), "println", methodType).bindTo(receiver);
}
}

实际上,方法getPrintlnMH()中模拟了invokevirtual指令的执行过程,只不过它的分派逻辑并非固化在Class文件的字节码上,而是通过一个具体方法来实现。

而这个方法本身的返回值(MethodHandle对象),可以视为对最终调用的一个“引用”。

MethodHandle与Reflection机制

  • Reflection和MethodHandle机制都是在模拟方法调用,但是Reflection是在模拟Java代码层次的方法调用,而MethodHandle是在模拟字节码层次的方法调用。
    MethodHandles.lookup()中的3个方法findStatic()findVirtual()findSpecial正式为了对应于invokestaticinvokevirtual&invokeinterfaceinvokespecial这几条字节码指令的执行权限校验行为。

  • Reflection中的java.lang.reflect.Method对象远比MethodHandle机制中的java.lang.invoke.MethodType对象所包含的信息多。
    前者包含了方法的签名、描述符以及方法属性表红各种属性的Java表示方式,还包含执行权限等的运行期信息。后者仅仅包含与执行该方法相关的信息。(Reflection是重量级的,MethodHandle是轻量级的

  • MethodHandle是对字节码的方法指令调用的模拟,理论上,虚拟机在这方面做的各种优化(如方法内联),在MethodHandle上也应当可以支持(目前还不完善)。通过反射去调用方法则没有这方面的优化。

Reflection API的设计目标是只针对Java语言服务的,而MethodHandle则设计成可服务于所有Java虚拟机之上的语言。

invokedynamic指令

JDK 1.7为了更好地支持动态类型语言,引入了第5条方法调用的字节码指令invokedynamic

在JDK 1.7 以及之前的版本Javac编译器暂时没有办法生成带有invokedynamic指令的字节码
但是在 JDK1.8,由于函数式编程的引入,Java的动态性大大增强,在Java函数式API调用中,编译器就能产生invokedynamic指令。

invokedynamic指令所面向的并非Java语言,而是其他Java虚拟机之上的动态类型语言。

在某种程度上,invokedynamic指令和MethodHandle机制的作用是一样的,都是为了解决前4条“invoke*”指令方法分派规则固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转移到用户代码中。(MethodHandle用上层Java代码和API来实现,另一个用字节码和Class中其他属性、常量来完成)

动态调用点(Dynamic Call Site):每一处含有invokedynamic指令的位置。

这条指令的第一个参数不再是代表方法的符号引用的CONSTANT_Methodref_info常量,而是变成JDK 1.7新加入的CONSTATN_InvokeDynamic_info常量。根据CONSTANT_InvokeDynamic_info常量中提供的信息,虚拟机可以找到并且执行引导方法,从而得到一个CallSite对象,最终调用要执行的目标方法。