Java方法调用、动态语言支持及基于栈的执行
Java方法调用、动态语言支持及基于栈的执行
方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还未涉及方法内部的具体运行过程。在程序运行时,进行方法调用是最普遍、最频繁的操作之一,class文件的编译过程中不包含传统程序语言编译的连接步骤,一切方法调用在class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址。
一、调用
所有方法调用的目标方法在class文件里面都是一个常量池中的符号引用,在类加载阶段,会将其中的一部分引用转化为直接引用,这种解析的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且在运行期不可改变。这类方法的调用被称为解析。
在Java语言中符合编译器可知,运行期不可变的方法,主要有静态方法和私有方法。
在Java虚拟机中有5条方法调用字节码指令。
- invokestatic,用于调用静态方法
- invokespecial,用于调用实例构造器init方法、私有方法和父类中的方法。
- invokevirtual,用于调用所有的虚方法。
- invokeinterface,用于调用接口方法,会在运行时再确定一个实现该接口的对象。
- invokedynamic,先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。
前面四条调用指令,分派逻辑都固化再Java虚拟机的内部,而invokedynamic指令的分派逻辑是由用户设定的引导方法来决定的,只要能被invokestatic和invokespecial指令调用的方法,都可以再解析阶段中确定唯一的调用版本,在类加载的时候就可以把符号引用解析为该方法的直接引用,这些方法统称为非虚方法,与之相反,其他方法被称为虚方法。
解析调用一定是一个静态的过程,在编译期间就完全确定,在类加载的解析阶段就会把涉及的符号引用全部转变为明确的直接引用。而另一种主要的方法调用形式:分派调用,会复杂很多,它可能是静态的也可能是动态的,按照分派依据的宗量数可分为单分派和多分派。这俩个俩俩组合就会由四种分派情况。
二、分派
1、静态分派
1 public class Human{} 2 public class Man extends Human{} 3 public calss Woman extends Human{} 4 5 public void sayHello(Human guy){ 6 System.out.println("hello guy") 7 } 8 9 public void sayHello(Man guy){ 10 System.out.println("hello guy,Man ") 11 } 12 public void sayHello(Woman guy){ 13 System.out.println("hello guy,Woman ") 14 } 15 16 // 伪代码 17 public static void main(String[] args){ 18 Human man = new Man(); 19 Human woman = new Woman (); 20 sayHello(man); 21 sayHello(Woman ); 22 }
上面代码为一段伪代码,其中第18、19行的Human称为“静态类型”,或者叫做“外观类型”,后面的Man或者Woman称为“实际类型”,或者叫“运行时类型”。
静态类型和实际类型在程序中都可能发生变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型在编译器是可知的。而实际类型变化的结果在运行期才能确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。
虚拟机在重载时是通过静态类型而不是实际类型作为判定的依据,静态类型在编译期可知,javac编译器可以根据参数的静态类型决定了会使用那个重载版本。
所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派。静态分派最典型应用表现就是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是有虚拟机来执行的
,javac编译器虽然能确定出方法的重载版本,但是在很多情况下这个重载版本并不是“唯一的”,往往只能确定一个“相对更合适的版本”,字面量没有限制的静态类型,他的静态类型只能通过语言和语法的规则去理解和推断。
2、动态分派
上图代码的javap字节码的内容如下:
可以看到在调用sayHello方法的时候,字节码指令为:invokevirtual,根据Java虚拟机规范,invokevirtual指令的运行时解析过程大致分为以下几步:
1、找到操作数栈顶的第一个元素所指向的对象的实际类型,记作c,
2、如果在类型c中找到与常量中描述符和简单名称都相符的方法,则进行访问权限校验,如果通过,则返回这个方法的直接引用,查找结束,不通过则报异常。
3、否则,按照继承关系从下往上依次对c的各个父类进行第二步的搜索和验证过程。
4、如果始终没有找到合适的方法,则报异常。
这个指令的第一步就是在运行期确定接受者的实际类型,所以这个指令并不是把常量池中的方法符合引用解析到直接引用上就结束了,还会根据方法接受者的实际类型来选择方法的版本,这个过程就是Java语言中方法重写的本质。在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
3、单分派和多分派
方法的接受者与方法的参数统称为方法的宗量,根据分派基于多少宗量,可以将分派划分为单分派和多分派俩种,单分派根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。
在上面代码里面,俩次hardChoice方法的选择目标方法的依据有俩点:一是静态类型是Father还是Son,二是方法的参数是qq还是360.因为是根据俩个宗量进行选择,所以java语言的静态分派属于多分派类型。
在执行son.hardchoice(new qq());这行代码时,或者更准确的说在执行这行代码所对应的invokevirtual指令时,由于编译器已经决定目标方法的签名必须是hardchoice(new qq()),所以虚拟机不会关心,这个qq的实例,只要类型一致就可以,这个时候的静态类型、实际类型对方法的选择不会构成任何影响,唯一可以影响虚拟机选择的因素只有该方法的接受者的实际类型是Father还是Son。因为只有一个宗量最为选择依据,所以Java语言的动态分派属于单分派。
4、虚拟机动态分派的实现
动态分派是执行非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在接受者类型的方法元数据中搜索合适的目标方法,因此,Java虚拟机基于执行性能的考虑,真正运行时一般不会如此频繁的去反复搜索类型元数据。
面对这种情况,一种优化手段就是为类型在方法区中建立一个虚方法表,使用虚方法表索引来代替元数据查找一提高性能。
虚方法表中存放着各个方法的实际入口地址。如果子类有重写方法,子类虚方法表中的地址会替换为指向子类的实现版本,未重写则指向父类。
为了程序实现方便,具有相同签名的方法,在父类和子类的虚方法表中都应当具有一样的索引符号。虚方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的虚方法表也一同初始化完毕。除此之外,还会使用类型继承关系、守护内联、内联缓存等优化技术来进一步提高性能。
三、动态类型语言支持
Java虚拟机的字节码指令集的数量从一开始到现在,只新增了一条指令,他就是jdk7版本版本发布的--invokedynmic指令。目的是为了实现动态类型语言支持,也是java8 lambda表达式的技术储备。
“变量无类型,而变量的值才有类型”这个特点是动态类型语言的一个核心特征。假设:obj.print();在java语言编译的时候就已经把print方法的完整的符号引用生成出来了,并把方法的调用参数存储在class文件中,其包含了方法的具体类型,名字、参数、参数类型和方法返回值,但是在动态语言中,编译器最多确定方法的名称、参数和返回值。
Java语言方法的符号引用在编译时产生,而动态类型语言只有在运行期才能确定方法的接收者。这样,在虚拟机上实现动态类型语言就不得不使用曲线救国的方式(如编译时留个占位符,等运行时动态生成具体类型到占位符类型的适配)。这种方式会有很大的性能问题,所以基于这样的背景,才提出了jdk7的提案中的invokedynmic指令和java.lang.invoke包。
java.lang.invoke包
invke包(MethedHandle(方法句柄))和relection(反射)的区别:
1、relection和MethedHandle机制本质上都是在模拟方法的调用,但是relection模仿java代码层次的方法调用,MethedHandle模拟字节码层次的方法调用,
2、relection中的java.lang.relect.Method对象远比MethedHandle机制包含的信息多。通俗来讲,relection是重量级的,MethedHandle是轻量级的。
3、MethedHandle是对字节码指令调用的模拟,那理论上虚拟机在这方面做的各种优化(如方法内联),在MethedHandle上也应当可以采用类似的思路支持,而通过反射去调用方法则几乎不可能实施各类调用点优化。
4、relection api是只为java语言服务的,而MethedHandle则设计为可服务于所有Java虚拟机之上的语言。
invokedynmic指令
某种意义上可以说,invokedynmic指令和MethedHandle机制的作用是一样的,都是为了解决原有4条invoke指令分派规则完全固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码之中,让用户有更高度的自由度。而且它们俩者的的思路是可以类比的,只是一个用上层代码和api来实现。另一个用字节码和class中其他属性、常量来完成。
每一处含有invokedynmic指令的位置都被称作是“动态调用点”,这条指令的第一个参数不再是代表方法符号引用的constant_method_info常量,而是变为constant_invokedynmic_info常量,这个新的常量包含三个信息,引导方法,方法类型和名称,引导方法有固定的参数,并且返回值规定是java.lang.invoke.callsite对象,这个对象代表了真正要执行的目标方法调用。虚拟机找到并且执行引导方法,从而获得一个callsite对象,最终调用到要执行的目标方法上。
四、基于栈的字节码解释执行引擎
Java虚拟机的执行引擎在执行Java代码的时候都有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)俩种选择。
此处我们分析的都是基于概念模型下的Java虚拟机解释执行字节码实现。在实际的虚拟机实现中,譬如hotspot的模版解释器工作的时候,并不是按照一板一眼的基于栈进行机械计算,而是动态产生每条字节码对应的汇编代码来运行。
解释执行
无论是解释还是编译,也无论是物理机还是虚拟机,大部分的程序代码转换成物理机的目标代码或者虚拟机能执行的指令集之前,都需要经过下图的步骤:
在Java语言中,javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流,因为这一部分动作是在Java虚拟机之外进行的,而解释器在虚拟机的内部,所以Java虚拟机的编译就是半独立的实现。
五、基于栈的指令集和基于寄存器的指令集
Javac编译器输出的字节码指令流,基本上是一种基于栈的指令集架构,字节码指令流里面的指令大部分都是零地址指令,它们依赖操作数栈进行工作。与之相对的另外一套常用的指令集架构是基于寄存器的指令集,这些指令依赖寄存器进行工作。
举个例子:
1+1:
基于栈的指令集会是这样:
这种指令流中的指令通常都是不带参数的,使用操作数栈中的数据作为指令的输入,指令的运算结果也存储在操作数栈中。
基于寄存器的指令集:
mov指令把eax寄存器的值设为1,然后add指令再把这个值加1,结果保存在eax寄存器中。这种二地址指令,每个指令都包含俩个单独的输入参数,依赖于寄存器来访问和存储数据。
参考:
《深入理解Java虚拟机》周志明