《深入理解Java虚拟机》——方法调用与基于栈的字节码解释执行引擎

时间:2022-04-20 10:45:01

方法调用:方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。其实关于方法的执行,接口(父类)与实现类(子类)这些方法如何调用从Java代码层面上大家都是比较清楚的,这里我们探讨一下更深一层的运行原理。

Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局的入口地址(相当于之前说的直接引用)。这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂起来,需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。

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

在Java语言中符合"编译期可知,运行期不可变"这个要求的方法,主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。

Java虚拟机中提供了5条方法调用字节码指令,分别如下:
invokestatic:调用静态方法。

invokespecial:调用实例构造器<init>方法、私有方法和父类方法。

invokevirtual:调用所有的虚方法。

invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。

invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的4条调用指令,分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。

这里需要需要说明一下,invokespecial指令相关的父类方法,只有在该子类中的方法中使用super.XXX()才会产生invokespecial指令,测试代码如下:

package com.general.class_structure;

public class TestMethod extends FMethod implements MethodInterface{

@Override
public void testMethod() {
// TODO Auto-generated method stub
System.out.println("interface method");

}
public static void testSMethod(){
System.out.println("static method");
}
private void testPrMethod(){
System.out.println("test private method");

}
public void testInvokeSpecial(){
testInvokeWithoutSuper();
System.out.println("super class generate invokespecial way");
super.superMethod();//这样才会产生invokespecial指令,这里必须显示使用super来调用superMethod方法才可以
}
public static void main(String[] args) {
testSMethod();
TestMethod testMethod=new TestMethod();
testMethod.testMethod();
testMethod.testPrMethod();
testMethod.testInvokeSpecial();
MethodInterface interMethod=testMethod;
interMethod.testMethod();
FMethod fMethod=testMethod;
fMethod.superMethod();

}
}
class FMethod{
public void superMethod(){
System.out.println("superMethod");
}
public void testInvokeWithoutSuper(){
System.out.println("super class test method");
}
}
interface MethodInterface{
public void testMethod();
}


相应的字节码文件,简略下这里只贴出main方法与testInvokeSpecial方法的字节码,如下:

...................................................
public static void main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1
0:invokestatic#48 // Method testSMethod:()V
3: new #1 // class com/general/class_structure/TestMethod
6: dup
7:invokespecial#50 // Method "<init>":()V
10: astore_1
11: aload_1
12: invokevirtual #51 // Method testMethod:()V
15: aload_1
16:invokespecial#53 // Method testPrMethod:()V
19: aload_1
20: invokevirtual #55 // Method testInvokeSpecial:()V
23: aload_1
24: astore_2
25: aload_2
26:invokeinterface #57, 1 // InterfaceMethod com/general/class_structure/MethodInterface.testMethod:()V
31: aload_1
32: astore_3
33: aload_3
34: invokevirtual #43 // Method com/general/class_structure/FMethod.superMethod:()V
37: return
LineNumberTable:
line 24: 0
line 25: 3
line 26: 11
line 27: 15
line 28: 19
line 29: 23
line 30: 25
line 31: 31
line 32: 33
line 34: 37
LocalVariableTable:
Start Length Slot Name Signature
0 38 0 args [Ljava/lang/String;
11 27 1 testMethod Lcom/general/class_structure/TestMethod;
25 13 2 interMethod Lcom/general/class_structure/MethodInterface;
33 5 3 fMethod Lcom/general/class_structure/FMethod;
...................................................................
public void testInvokeSpecial();
flags: ACC_PUBLIC
Code:
stack=2, locals=1, args_size=1
0: aload_0
1: invokevirtual #38 // Method testInvokeWithoutSuper:()V
4: getstatic #17 // Field java/lang/System.out:Ljava/io/PrintStream;
7: ldc #41 // String super class generate invokespecial way
9: invokevirtual #25 // Method java/io/PrintStream.println:(Ljava/lang/String;)V
12: aload_0
13:invokespecial #43 // Method com/general/class_structure/FMethod.superMethod:()V
16: return
LineNumberTable:
line 19: 0
line 20: 4
line 21: 12
line 22: 16
LocalVariableTable:
Start Length Slot Name Signature
0 17 0 this Lcom/general/class_structure/TestMethod;
.....................................................

public void superMethod();
flags: ACC_PUBLIC, ACC_BRIDGE, ACC_SYNTHETIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1:invokespecial#43 // Method com/general/class_structure/FMethod.superMethod:()V
4: return
LineNumberTable:
line 1: 0
LocalVariableTable:
Start Length Slot Name Signature

public void testInvokeWithoutSuper();
flags: ACC_PUBLIC, ACC_BRIDGE, ACC_SYNTHETIC
Code:
stack=1, locals=1, args_size=1
0: aload_0
1:invokespecial#64 // Method com/general/class_structure/FMethod.testInvokeWithoutSuper:()V
4: return
LineNumberTable:
line 1: 0
LocalVariableTable:
Start Length Slot Name Signature
...................................................................................


只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法4类,它们在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法可以称为非虚方法,与之相反,其他方法称为虚方法。

Java中的非虚方法除了使用invokestatic、invokespecial调用的方法之外还有一种,就是被final修饰的方法。虽然final方法是使用invokevirtual指令来调用的,但是由于它无法被覆盖,没有其他版本,所以也无须对方法接收者进行多态选择,又或者多态选择的结构肯定是唯一的。在Java语言规范中明确说明了final方法是一种非虚方法。

也就是说方法根据其唯一性,可以分为非虚方法和虚方法。

非虚方法:静态方法、私有方法、实例构造器、父类方法、final修饰的方法,它们在类加载的时候就会把符号引用解析为直接引用。

虚方法:除了非虚方法外其余方法都为虚方法,主要指哪些在编译期间无法确定方法调用版本或者运行期可变化的方法。


解析调用一定是个静态方法的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成,而分派调用则可能是静态的也可能是动态的,根据分派依据的宗量数可以分为单分派和多分派。这两类分派方式的两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派4种分派组合情况。

在了解分派之前,我们先看两个重要的概念:变量的静态类型与变量的实际类型。

 TestMethod testMethod=newTestMethod();-->invokespecial #51;astore_1;

MethodInterface interMethod=testMethod;-->aload_1;astore_2;

FMethod fMethod=testMethod;-->aload_1;astore_3;

结合上面代码,testMethod、interMethod、fMethod都是变量的静态类型,new TestMethod()是变量的实际类型。而且这3个变量的实际类型都同一个对象。

静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。

静态分派:

package com.general.class_structure;

/***
*
* 方法静态分派
*
*/
public class StaticDispatch {
static abstract class Human{

}
static class Man extends Human{

}
static class Woman extends Human{

}
public void sayHello(Human guy){
System.out.println("hello guy!");
}
public void sayHello(Man guy){
System.out.println("hello gentleman");
}
public void sayHello(Woman guy){
System.out.println("hello lady");
}
public static void main(String[] args) {
Human man=new Man();
Human woman=new Woman();
StaticDispatch sr=new StaticDispatch();
sr.sayHello(man);
sr.sayHello(woman);
//输出
//hello guy!
//hello guy!
}
}


如果在看到输出结果前,所想的输出与输出结果不一致,那么你需要记住:虚拟机(准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译期可知的,因此,在编译阶段,Javac编译器会根据参数的静态类型决定使用哪个重载版本。

我们来看证据,main方法的字节码:

public static void main(java.lang.String[]);
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=4, args_size=1
0: new #42 // class com/general/class_structure/StaticDispatch$Man
3: dup
4: invokespecial #44 // Method com/general/class_structure/StaticDispatch$Man."<init>":()V
7: astore_1
8: new #45 // class com/general/class_structure/StaticDispatch$Woman
11: dup
12: invokespecial #47 // Method com/general/class_structure/StaticDispatch$Woman."<init>":()V
15: astore_2
16: new #1 // class com/general/class_structure/StaticDispatch
19: dup
20: invokespecial #48 // Method "<init>":()V
23: astore_3
24: aload_3
25: aload_1
26: invokevirtual #49 // Method sayHello:(Lcom/general/class_structure/StaticDispatch$Human;)V
29: aload_3
30: aload_2
31: invokevirtual #49 // Method sayHello:(Lcom/general/class_structure/StaticDispatch$Human;)V
34: return
.......................................................................


所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。另外,编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是“唯一的”,往往只能确定一个“更加合适的”版本。这种模糊的结论在由0和1构成的计算机世界中算是比较"稀罕"的事情,产生这种模糊结论的主要原因是字面量不需要定义,所以字面量没有显式的静态类型,它的静态类型只能通过语音上的规则去理解和推断。在某些情况下会用到向上转型和类型自动转换。比如下面的例子:

package com.general.class_structure;

import java.io.Serializable;

public class Overload {
public static void sayHello(Object arg){
System.out.println("hello Object");
}
public static void sayHello(int arg){
System.out.println("hello int");
}
public static void sayHello(long arg){
System.out.println("hello long");
}
public static void sayHello(Character arg){
System.out.println("hello Character");
}
public static void sayHello(char arg){
System.out.println("hello char");

}
public static void sayHello(char... arg){
System.out.println("hello char....");
}
public static void sayHello(Serializable arg){
System.out.println("hello Serializable");
}
public static void main(String[] args) {
sayHello('a');
}

}


上面代码正常输出结果是:hello char.

注释sayHello(char arg)——>输出 hello int——>一次自动类型转换

注释sayHello(int arg)——>输出 hello long——>两次自动类型转换

注释sayHello(long arg)——>输出 hello Character——>一次自动装箱

注释sayHello(Character arg)——>输出 hello Serializable——>装箱之后向上转型

注释sayHello(Serializable arg)——>输出 hello Object——>装箱之后转为父类,由此可见在重载时,接口的优先级要高于父类。

注释sayHello(Object arg)——>输出hello char...——>可见变长参数的重载优先级是最低的。



在讲解动态分派之前,我们非常有必要详细地了解下invokevirtual指令的多态查找过程。

invokevirtual指令的运行时解析过程大致分为以下几个步骤:

(1)找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C。

(2)如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常。

(3)否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。

(4)如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。


动态分派与Java多态性的另外一个重要体现——重写有着密切的关联。看代码:

package com.general.class_structure;

public class DynamicDispatch {
static class Human{
protected void sayHello(){System.out.println("super say hello");};
}
static class Man extends Human{

@Override
protected void sayHello() {
System.out.println("man say hello");
}
}
static class Woman extends Human{

@Override
protected void sayHello() {
// TODO Auto-generated method stub
System.out.println("woman say hello");
}

}
public static void main(String[] args) {
Human man=new Man();
Human woman=new Woman();
man.sayHello();
woman.sayHello();
man=new Woman();
man.sayHello();
}

}
//输出结果:
//man say hello
//woman say hello
//woman say hello


上面代码的输出结果大家都可以轻易的想出来,我们来看一下main的字节码:

public static void main(java.lang.String[]);
descriptor: ([Ljava/lang/String;)V
flags: ACC_PUBLIC, ACC_STATIC
Code:
stack=2, locals=3, args_size=1
.......................................................................................
17: invokevirtual #22 // Method com/general/class_structure/DynamicDispatch$Human.sayHello:()V
...............................
21: invokevirtual #22 // Method com/general/class_structure/DynamicDispatch$Human.sayHello:()V
.................................
33: invokevirtual #22 // Method com/general/class_structure/DynamicDispatch$Human.sayHello:()V
36: return



省略掉其他的字节码,我们只关注sayhello的调用,我们可以看到invokevirtual方法调用指令的参数是一致的,均为 com/general/class_structure/DynamicDispatch$Human.sayHello:()V,那么为什么我们的输出结果不同呢,前面简单介绍过invokevirtual指令的多态查找过程。由于invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法的符号引用解析到了不同的直接引用上,这个过程就是Java语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。


方法的接收者与方法的参数统称为方法的宗量,根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。Java语言的静态分派属于多分派类型,动态分派属于单分派类型。


虚拟机对于动态分派会使用虚方法表(接口方法表)来对动态分派的过程进行稳定的优化。


在文章的开头部分,我们讲到了虚拟机提供了5条方法调用字节码指令,并举了一个例子,但是例子中没有invokedynamic指令的相关测试内容,下面我们着重看一下invokedynamic指令。

invokedynamic的作用:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的。

invokedynamic是JDK1.7为了支持“动态类型语言”而新增加的指令。那么什么是静态类型语言?什么是动态类型语言?

静态类型语言:在编译期就进行类型检查过程的语言就是静态类型语言,如c++和Java等。

动态类型语言:类型检查的主体过程是在运行期而不是编译期,如php,python等,变量无类型而变量值才有类型。

静态类型语言在编译期确定类型,最显著的好处是编译器可以提供严谨的类型检查,这样与类型相关的问题能在编码的时候就及时发现,利于稳定性及代码达到更大规模。而动态类型语言在运行期确定类型,这可以为开发人员提供更大的灵活性。

invokedynamic指令与MethodHandle机制的作用是一样的(至于什么是MethodHandle后面会讲解),都是为了解决原有4条“invoke”指令方法分派规则固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码之中。MethodHandle机制与invokedynamic指令两者的思路也是可类比的,可以把它们想象成为了达到同一个目的,一个采用上层Java代码和API来实现,另一个用字节码和Class中其他属性、常量来完成。


每一处含有invokedynamic指令的位置都称做"动态调用点",这条指令的第一个参数不再是代表方法符号引用的CONSTANT_Methodref_info常量,而是变为JDK1.7新加入的CONSTANT_InvokeDynamic_info常量,从这个新常量中可以得到3项信息:引导方法(Bootstrap Method,此方法存放在新增的BootstrapMethods属性中)、方法类型(MethodType)和名称。引导方法是固定的参数,并且返回值是java.lang.invoke.CallSite对象,这个代表真正要执行的目标方法调用。根据CONSTANT_InvokeDynamic_info常量中提供的信息,虚拟机可以找到并且执行引导方法,从而获得一个CallSite对象,最终调用要执行的目标方法。

我们看下lambda表达式中的invokedynamic:

 //Java 8方式:
List features = Arrays.asList("Lambdas", "Default Method", "Stream API", "Date and Time API");
features.forEach(n -> System.out.println(n));
.....................................................
#8 = InvokeDynamic #0:#48 // #0:accept:()Ljava/util/function/Consumer;
#48 = NameAndType #63:#64 // accept:()Ljava/util/function/Consumer;
#63 = Utf8 accept
#64 = Utf8 ()Ljava/util/function/Consumer;
...................................................
29: invokedynamic #8, 0 // InvokeDynamic #0:accept:()Ljava/util/function/Consumer;
34: invokeinterface #9, 2 // InterfaceMethod java/util/List.forEach:(Ljava/util/function/Consumer;)V
39: return
............................................
BootstrapMethods:
0: #45 invokestatic java/lang/invoke/LambdaMetafactory.metafactory:(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodType;Ljava/lang/invoke/MethodHandle;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;
Method arguments:
#46 (Ljava/lang/Object;)V
#47 invokestatic com/general/test/invoke/InvokeDynamicTestOne.lambda$main$0:(Ljava/lang/Object;)V
#46 (Ljava/lang/Object;)V



上面的代码是使用lambda表达式来输出一个List列表,forEach是jdk1.8才有的方法,这两句代码时翻译为字节码时出现了invokedynamic指令,这里不做过来的解释,后面再专门写一篇文章来介绍lambda表达式。我们看到invokedynamic后面跟的是#8,而#8在常量池中的类型为InvokeDynamic,也即解释了第一个参数不再是代表方法符号引用的CONSTANT_Methodref_info常量,而是变为JDK1.7新加入的CONSTANT_InvokeDynamic_info常量。 #0——>引导方法;accept——>方法名称;()Ljava/util/function/Consumer——>;方法类型。


刚刚说过MethodHandle机制,那么我们现在来看看到底什么是MethodHandle机制。


JDK1.7新加入了java.lang.invoke包,这个包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这种方式以外,提供一种新的动态确定目标方法的机制,这个是由用户自己来控制的,称为MethodHandle。

public class MethodHandleTest {
static class ClassA{
public void println(String s){
System.out.println(s);
}
}
private static MethodHandle getPrintlnMH(Object reveiver) throws Throwable{
/**MethodType:代表“方法类型”,包含了方法的返回值(methodType()的第一个参数)和具体参数(methodType()第二个及以后的参数)*/
MethodType mt=MethodType.methodType(void.class,String.class);
/**lookup()方法来自于MethodHandles.lookup,这句的作用是在指定类型中查找符合给定的方法名称、方法类型,并且符合调用权限的方法句柄
* 因为这里调用的是一个虚方法,按照Java语言的规则,方法第一个参数是隐式的,代表该方法的接收者,也即是this指向的对象,这个参数以前是
* 放在参数列表中进行传递的,而现在提供了bindTo()方法来完成这件事情**/
return MethodHandles.lookup().findVirtual(reveiver.getClass(), "println", mt).bindTo(reveiver);
}
public static void main(String[] args) throws Throwable {
Object object=System.currentTimeMillis()%2==0?System.out:new ClassA();
System.out.println(object.getClass().toString());
/**无论object最终是哪个实现类,下面这句都能正确调用到println方法*/
getPrintlnMH(object).invokeExact("GeneralAndroid");
}
//输出为GeneralAndroid


上面代码就不多说了,摘自书上的,来注释都摘过来了。这里简单概括下从Java语言来看,MethodHandle与Reflection的区别:

1.模拟层次的不同,MethodHandle是在模拟字节码层次的方法调用,Reflectio是在模拟java代码层次的方法调用。

2.包含信息的体量不同,Reflection是重量级的,MethodHandle是轻量级的。

3.性能方面:反射不支持虚拟机的某些优化操作,比如方法内联,但是MethodHandle支持。

如果撇开Java语言来说其差异就是:Reflection API的设计目标是只为Java语言服务的,而MethodHandle则设计成可服务于所有Java虚拟机之上的语言,其中也包括Java语言。



前面的文章中讲过,方法体对应的字节码指令是基于操作数栈来运行的,也相应的介绍了一下常用的字节码指令,以及字节码指令的分类。这里就不过多的讲述了,关于编译优化以及虚拟机执行期优化,后面会有相应的总结。但是这里还是贴一下经典的编译过程,如下图:


《深入理解Java虚拟机》——方法调用与基于栈的字节码解释执行引擎


最后总结一下这篇文章的内容:

1.方法调用主要涉及五种字节码指令:invokestatic、invokespecial、invokevirtual、invokeinterface、invokedynamic。

2.静态分派的典型应用是重载,而动态分派的典型应用就是重写。

3.方法的接收者与方法的参数统称为方法的宗量,根据分派基于多少种宗量,可以将分派划分为单分派或多分派两种。

4.MethodHandle可以让我们自己控制方法分派


转载请注明出处:http://blog.csdn.net/android_jiangjun/article/details/78437126