虚拟机字节码执行引擎-----方法调用
Posted 竹马今安在
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了虚拟机字节码执行引擎-----方法调用相关的知识,希望对你有一定的参考价值。
方法调用阶段唯一的任务就是确定被调用方法的版本(调用的是哪一个方法),暂时还不涉及方法内部的具体运行过程。Class文件的编译过程中 不包含传统编译过程中的“连接”,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址。这给java带来更强的动态扩展功能的同时,也使用java方法的调用过程变得相对复杂起来,需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。
1.解析
在类加载的解析阶段,会将一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可变得(调用目标在程序代码写好、编译器进行编译时就必须确定下来)这类方法的调用称为解析。
在java语言中符合“编译器可知,运行期不可变”的方法只有类方法和私有方法,因此他们都适用在类加载阶段进行解析。
与之对应的是,java虚拟机里面提供了五条方法调用字节码指令。
invokestatic | 调用静态方法 |
invokespecial | 调用实例构造器、私有方法和父类方法 |
invokevirtual | 调用所有的虚方法(可以被覆写的方法都可以称作虚方法,因此虚方法并不需要做特殊的声明,也可以理解为除了用static、final、private修饰之外的所有方法都是虚方法。)注意:虽然final是用此指令调用,但并不是虚方法 |
invokeinterface | 调用接口方法,会在运行时再确定一个实现此接口的对象 |
invokedynamic | 先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的4条调用指令,分派逻辑是固化在java虚拟机内部的,而invokedynamic的分派逻辑是由用户所设定的引导方法决定的 |
只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,静态方法、实例构造器、私有方法和父类方法这四类在类加载的时候就会把符号引用解析为直接引用。这些方法可以被称为非虚方法。
public class StaticResolution{ public static void sayHello(){ System.out.println("hello world"); } public static void main(String[] args){ StaticResolution.sayHello(); } }
查看这段程序的字节码
发现的确是通过invokestatic命令调用的sayhello();
解析调用是静态的过程,在编译期间就完全确定下来,在类加载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。而分派调用则可能是静态的也可能是动态的,根据分派的宗量数可分为单分派和多分派。
2.分派
可以通过分派调用过程揭示多态性特征的一些最基本的体现,如“重载”和“重写”在java虚拟机中时如何实现的
①静态分派
public class A{ 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(Human guy){ System.out.println("hello guy"); } public void sayHello(Human guy){ System.out.println("hello guy"); } public static void main(String[] args){ Human man=new Man(); Human women=new Woman(); A a=new A(); a.sayHello(man); a.sayHello(women); } }
输出结果是
hello guy
hello guy
关于重载方法的使用,在方法接受者已经确定是对象“sr”的情况下,使用哪个重载版本,完全取决于传入参数的数量和数据类型。代码中可以地定义了两个编译期类型相同但运行期类型不同的变量,但编译器在重载时是通过参数的编译期类型而不是运行期类型作为判定依据的,并且编译期数据在编译期可知的,因此,在编译阶段,javac编译期会根据传入参数的编译期类型决定使用哪个重载版本。
所有依赖编译期类型来定位方法执行版本的分派动态称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。另外,编译期虽然能确定出方法的重载版本,但是在很多情况下这个重载版本并不是“唯一的”,往往是只能确定一个“更加合适的”版本,我们知道编写代码最重要的就是不能让计算机糊涂,而这种模糊的概念很少见,这是因为字面量是不需要定义的,所以字面量没有显示的编译期类型,它的编译期类型只能通过语言上的规则去理解和推断。
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(Serializeable arg){ System.out.println("hello Serializeable "); } public static void main(String[] args){ sayHello(\'a\'); } }
输出为
hello char
很好理解,因为\'a\'是char类型的数据,系统自然会选择参数类型为char的重载方法,如果把char类型的重载方法去掉。
会输出
hello int
这时发生了一次自动类型转换,‘a’除了可以代表一个字符串,还可以代表数字97,因此参数类型为int的重载也是合适的,继续去掉int类型的方法
输出
hello long
发生了两次自动类型转换,char->int->long,实际上还可以继续进行char->int->long->float->double,但是代码中没有float和double的重载方法,去掉long的方法
会输出
hello Character
这是发生了一次自动装箱。去掉Character类型。
输出
hello Serializable
这是因为Character实现了Serializable,自动装箱以后找不到装箱类,只能向父类或接口转型,这是如果Character还实现了一个接口,并且这个接口的重载类型也在上面代码中,编译器会糊涂,拒绝编译。这时程序必须显示地指定字面量的编译器类型,再去掉这个方法
hello Object
这时是char装箱后转型为父类了,依次往上搜索。如果把这个也去掉
输出就变成
hello char...
只剩下了这一个sayhello(char... org),说明变长参数的重载优先级是最低的。
这个例子演示了编译期间选择静态分派目标的过程,这个过程也是java语言实现方法重载的本质。
注意:解析和分派之间并不是互斥的,而是是在不同层次上去筛选、确定目标方法的过程。例如:类方法在类加载的时候进行解析,但是类方法也会有重载版本,选择重载版本的过程也是通过静态分派完成的。
②动态分派
动态分派与重写有着密切的关联。
public class A{ static abstract class Human{ protected abstract void sayHello(); } static class Man extends Human{ protected void sayHello(){ System.out.println("hello man"); } } static class Woman extends Human{ protected void sayHello(){ System.out.println("hello woman"); } } public static void main(String[] args){ Human man=new Man(); Human women=new Woman(); man.sayHello(); women.sayHello(); man=new Woman(); man.sayHello(); } }
输出如下
很明显,不是再根据编译期类型来决定重写方法的调用,决定的是运行期类型。来反编译一下
我们看到invokevirtual字节码指令后面对应的符号引用完全一样,但是这三个invokevirtual指令调用的方法不一样,那么invokevirtual又是根据什么来区分多态的呢,我们来看一下invokevirtual的解析过程:
① 找到操作数栈顶的第一个元素所指向的对象的实际类型,记做C;
②如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常
③否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
④如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
由于invokevirtual指令执行的第一步就是在运行期确定接受者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是java语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派
③单分派和多分派
方法的接受者和方法的参数称为方法的宗量,单分派和多分派是根据有多少种宗量划分的。单分派是根据一个宗量对目标方法进行选择,多分派是根据多余一个宗量对目标方法进行选择。
public class Dispatch{ static class QQ{} static class _360{} public static class Father{ public void hardChoice(QQ arg){ System.out.println("father choose qq"); } public void hardChoice(_360 arg){ System.out.println("father choose 360"); } } public static class Son extends Father{ public void hardChoice(QQ arg){ System.out.println("son choose qq"); } public void hardChoice(_360 arg){ System.out.println("son choose 360"); } } public static void main(String[] args){ Father father=new Father(); Father son=new Son(); father.hardChoice(new _360()); son.hardChoice(new QQ()); } }
运行结果:
看看编译阶段编译器的选择过程,也就是静态分派的过程。这时选择目标方法的依据有两点:
1.是静态类型是Fater还是Son
2.是方法参数是QQ还是360
两条指令的参数分别为常量池中指向Father.hardChoice(360)和Father.hardChoice(QQ)方法的符号引用。因为是根据两个宗量进行选择,所以java语言的静态分派属于多分派类型。
再来看看运行阶段虚拟机的选择,也就是动态分配的过程。在执行“son.hardChoice(new QQ());”时,可以看到
由于编译期已经决定目标方法的签名必须为QQ,因此唯一可以影响虚拟机选择的因素只有此方法的接受者的实际类型是Father还是Son。因为只有一个宗量作为选择,因此java语言的动态分派是单分派类型。
我们可以下一个结论:今天的java语言是一门静态多分派,动态多分派的语言。
④虚拟机动态分派的实现
动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正地进行如果频繁的搜索,面对这种情况,最常用的“稳定优化”手段就是为类在方法区中建立一个虚方法表,使用虚方法表索引来代替元数据查找以提高性能。
虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同的方法是一致的,都是父类的实现入口
如果子类中重写了这个方法,那么子类方发表的地址将会替换为指向子类实现版本的入口地址。图中,Son重写了来自father的全部方法,因此son的方法表没有指向father数据类型的,但是son和father都没有重写Object的方法,因此全部指向Object类型的数据。
除此之外,还会使用内联缓存和基于“类型继承关系分析”技术的守护内联两种非稳定的“激进优化”手段来获得更高的性能。
3.动态类型语言支持
java是静态类型语言,但是在JDK1.7中,增加了invokedynamic指令,这条指令是JDK1.7实现“动态类型语言”支持而进行改进之一,也是为JDK8可以顺利实现Lambda表达式做技术准备。
①动态类型语言
动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期,例如javascript就是。相反,在编译期就进行类型检查过程的语言(C++和java)就是最常用的静态类型语言。
最本质的区别我觉得应该是动态类型语言在运行期才能确定接受者类型,而静态类型语言在编译期就确定了接受者类型。
第一点,一门语言的哪一种检查行为要在编译期进行,哪一种检查要在运行期进行,并没有必然的因果逻辑关系,关键是语言规范中认为规定的。
什么是“类型检查”呢?
obj.println("hello word")
假设是在java中,obj必须是PrintStream的子类才是合法的,否则类型检查不合法,但是在JavaScript中,只要这种类型的定义中含有println(String)方法,那么方法调用便可成功。
这是因为java语言在编译期已将pritln方法完整的符号引用生成出来,作为方法调用指令的参数存储到Class文件中,例如下面:
可以看刀这个符号引用包含了此方法定义在哪个具体类型之中、方法的名字以及参数顺序、参数类型和方法返回值等信息,通过这个符号引用,虚拟机可以翻译出这个方法的直接引用。而在JavaScript等动态类型语言中,变量obj本身是没有类型的,变量obj的值才有类型,编译时方法接受者不固定。“变量无类型而变量值有类型”这个特点也是动态类型语言的一个重要特点
②JDK1.7与动态类型
java虚拟机想做到两种无关性,平台关性做得很好,但是语言无关性一直有欠缺,主要表现在方法调用方面,以前的4条方法调用指令的第一个参数都是被调用方法的符号引用,而方法的符号引用在编译时产生,动态类型语言只有在运行期才能确定接受者类型。这样,在java虚拟机上实现的动态类型语言就不得不使用其他方式(如编译时留个占位符类型,运行时动态生成字节码实现具体类型到占位符类型的适配)来实现,这样虽然可以实现,但是治本不治标,想治好,就要深入到虚拟机底层,因此在虚拟机上提供动态类型的直接支持就势在必得。这就是invokedynamic指令和java.lang.invoke包出现的技术背景
③java.lang.invoke包
为了提供一种新的动态确定目标方法的机制,称为MethodHandle。
举个例子:我们要实现一个带谓词的排序函数
C/C++中常用的做法是把为此定义为函数,用函数指针把谓词传递给排序方法
void sort(int list[],const int size,int(*compare)(int ,int))
java语言没有办法把一个函数作为参数进行传递。普遍的做法是设计一个带有compare()方法的Comparator接口,以实现了这个接口的对象作为参数。例如Collections.sort()方法就是这样定义的
void sort(List list,Comparator c)
在拥有了MethodHandle之后 ,java语言也可以拥有类似于函数指针或者委托的别名的工具了。
下面这个例子obj是何种类型都可以正确地调用到println()方法。
public class MethodHandleTest { static class ClassA{ public void println(String s){ System.out.println(s); } } public static void main(String[] args) throws Throwable{ Object obj=System.currentTimeMillis()%2==0?System.out : new ClassA(); getPrintlnMH(obj).invokeExact("icyfenix"); } private static MethodHandle getPrintlnMH(Object reveiver) throws Throwable { //MethodType代表“方法类型”,包含了方法的返回值(第一个参数)和具体参数(第二个参数) MethodType mt=MethodType.methodType(void.class.String.class); //lookup()方法来自于MethodHandles.lookup,这句话的作用是在指定类中查找符合给定的方法名称、方法类型,并且符合调用权限的方法句柄 //因为这里调用的是一个虚方法,按照java语言的规则,方法第一个参数是隐式的,代表该方法的接受者,也即是this指向的对象,这个参数以前是放在参数列表中进行传递的,现在提供了bindTo()方法 return lloup().findVirtual(reveiver.getClass(),"println",mt).bindTo(reveiver); } }
实际上,方法getPrintlnMH中模拟了invokevirtual指令的执行过程,只不过它的分派逻辑并非固定在Class文件的字节码上,而是通过一个具体方法来实现。而这个方法本身的返回值,可以视为对最终调用方法的一个“引用”。以此为基础,有了MethodHandle就可以写出类似于下面这样的函数声明:
void sort(List list,MethodHandle compart)
s使用MethodHandle并没什么困难,但是相同的事情,用反射不是早就可以实现了么?
④invokedynamic指令
⑤掌握方法分派规则
在java程序中,想要访问父类的方法可以使用super(),但是祖类的方法呢??
在JDK1.7之前,很难做到。看看下面这个
class Test{ class GrandFather{ void thinkint(){"i am grandfather"} } class Fater extends GrandFather{ void thinkint(){"i am father"} } class Son extends Father{ void thinkint(){ try{ MethodType mt=MethodType.methodType(void.class); MethodHandle mh=lookup().findSpecial(GrandFather.class,"thinking",mt,getClass()); mh.invoke(this); }catch(Throwable e){ } } } public static void main(String[] args){ (new Test().new Son()).thinking(); } }
以上是关于虚拟机字节码执行引擎-----方法调用的主要内容,如果未能解决你的问题,请参考以下文章