方法调用并不等同于方法中的代码被执行,方法调用阶段惟一的任务就是肯定被调用方法的版本(即调用哪个方法),暂时还未涉及方法内部的具体运行过程。一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(也就是直接引用)。这个特性给Java带来了更强的动态扩展能力,但也使得Java方法调用过程变得相对复杂,这些调用须要在类加载期间,甚至到运行期间才能肯定目标方法的直接引用。java
全部方法调用的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析可以成立的前提是:方法在程序真正运行以前就有一个可肯定的调用版本,而且这个方法的调用版本在运行期是不可改变的(调用目标在程序代码写好,编译阶段就已肯定下来)。这类方法的调用被称为解析。面试
在java中符合编译期可知,运行期不可变的方法,主要有静态方法和私有方法,前者与类型关联,后者在外部不可访问,这两种方法各自的特色决定了它们都不可能经过继承或别的方式重写出其余版本,所以它们都适合在类加载阶段进行解析。数组
Java中的静态方法、私有方法、实例构造器、父类方法,再加上被final修饰的方法,这5种方法调用会在类加载的时候就能够把符号引用转换为直接引用。这些方法统称为“非虚方法” 。与之相反,其余的方法被称为“虚方法”。安全
解析调用必定是一个静态过程 ,在编译期就彻底肯定,在类加载解析阶段就会把涉及的符号引用所有转变为明确的直接引用,没必要延迟到运行期再去完成。而另外一种主要的方法调用形式:分派(Dispatch)调用,多是静态的也多是动态的。按照分派依据的宗量数可分为单分派和多分派。这两类分派方式两两组合就构成了静态单分派,静态多分派,动态单分派,动态多分派。架构
分派调用将会解释多态性特征的一些最基本的体现。dom
/** * 静态分派 */ public class StaticDispatch { static abstract class Human{ } static class Man extends Human{ } static class Woman extends Human{ } public void say(Human human){ System.out.println("Human say"); } public void say(Man man){ System.out.println("Man say"); } public void say(Woman woman){ System.out.println("Woman say"); } public static void main(String[] args) { Human man=new Man(); Human woman=new Woman(); StaticDispatch sd=new StaticDispatch(); sd.say(man); sd.say(woman); } } //Human say //Human say
运行结果如上,要解决这个问题,首先须要定义两个关键概念:ide
Human man=new Man();
咱们把上面代码中的Human称为变量的静态类型(Static Type),或者叫外观类型,后面的Man称为变量的实际类型或者叫运行时类型 。静态类型和实际类型在程序中均可能发生变化,区别是静态类型的变化仅仅在使用时发生,变量自己的静态类型不会被改变,而且在最终的静态类型是编译期可知的;而实际类型变化的结果在运行期才可肯定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。布局
//实际类型变化 Human human=(new Random()).nextBoolean() ? new Man() : new Woman(); //静态类型变化 sd.say((Man)human); sd.say((Woman)human);
而上面的代码中,human的实际类型是可变的,编译期彻底不肯定究竟是man仍是woman,必须等到程序运行时才知道。而human的静态类型是Human,也能够在使用时强制转型临时改变这个类型,但这个改变还是在编译期可知。性能
回到上面静态分派的案例中,两次say方法的调用,在方法接收者已经肯定是对象sd的前提下,使用哪一个重载版本,彻底取绝于传入参数的数量和数据类型。代码中故意定义了两个静态类型相同,而实际类型不一样的变量,但编译器在重载时是经过参数的静态类型而不是实际类型做为断定依据的。因为静态类型在编译期可知,所以选择了say(Human man)进行调用。全部依赖静态类型来决定方法执行版本的分派动做,称为静态分派。静态分派最典型应用表现就是重载。静态分派发生在编译阶段,所以肯定静态分派的动做实际上不是由虚拟机来执行的。学习
须要注意Javac编译期虽然能肯定出方法重载的版本,但在不少状况下这个重载版本并非惟一的,每每只能肯定一个相对更适合的版本。
/** * 重载方法匹配优先级 */ public class OverLoad { public static void say(Object obj){ System.out.println("Object"); } public static void say(int obj){ System.out.println("int"); } public static void say(long obj){ System.out.println("long"); } public static void say(Character obj){ System.out.println("Character"); } public static void say(char obj){ System.out.println("char"); } public static void say(char... obj){ System.out.println("char..."); } public static void say(Serializable obj){ System.out.println("Serializable"); } public static void main(String[] args) { say('a'); } }
运行结果为:char。
这很好理解’a’就是char类型,天然选择char的重载方法,若是去掉char的重载方法,那输出会变为:int。这时候发生了一次自动类型转换,‘a’除了能够表明一个字符,还能够表明数字97,所以会选择int的重载方法。若是继续去掉int的方法,那么输出会变为:long。这时发生了两次自动转向,先转为整数97后,进一步转为长整型97L,匹配了long 的重载。实际上自动转型还能发生屡次,按照char > int > long > float > double的顺序进行匹配,但不会匹配到byte和short的重载,由于char 到这两个类型是不安全的。继续去掉long的方法,输出会变为:Character,这时发生了一次自动装箱,'a’变为了它的包装类。继续去掉Character方法,输出变为:Serializable。这个输出可能会让你们有点疑惑,字符或数字与序列化有什么关系?实际上是Character是Serializable接口的一个实现类,当自动装箱后仍是找不到装箱类,可是找到了装箱类所实现的接口类型,因此又发生一次自动转型。char能够转为int,但Character不会转为Integer,它只能安全地转型为它实现的接口或父类。Character还实现了另一个接口java.lang.Comparable< Character>,若是同时出现这两个接口类型地重载方法,那优先级是同样的,但编译器会拒绝编译。继续去掉Serializable,输出会变为Object。这是char装箱后转型为父类了。若是有多个父类,将在继承关系中从下往上开始搜索,越上层优先级越低。继续去掉Object,输出会变为char…。可见不定长数组地重载优先级最低。但要注意,char 转型为int,在不定长数组是不成立的。
动态分派与java多态性的重写有密切的关系。
/** * 动态分派 */ public class DynamicDispatch { static abstract class Human{ protected abstract void say(); } static class Man extends Human{ @Override protected void say() { System.out.println("man"); } } static class Woman extends Human{ @Override protected void say() { System.out.println("woman"); } } public static void main(String[] args) { Human man=new Man(); Human woman=new Woman(); man.say(); woman.say(); man=new Woman(); man.say(); } } //man //woman //woman
这个结果相信没什么太大疑问。这里选择调用的方法不可能再根据静态类型来决定的,由于静态类型一样是Human的两个变量,man和woman在调用时产生了不一样行为,甚至man在两次调用中还执行了两个不一样的方法。致使这个的缘由,是由于两个变量的实际类型不一样,实际执行方法的第一步就是在运行期间肯定接收者的实际类型,因此并非把常量池中方法的符号引用解析到直接引用上就结束,还会根据方法接收者的实际类型来选择方法版本,这个过程就是方法重写的本质。这种在运行期根据实际类型肯定方法执行版本的分派过程称为动态分派。
注意,字段永不参与多态。
/** * 字段没有多态 */ public class FieldTest { static class Father{ public int money=1; public Father(){ money=2; show(); } public void show(){ System.out.println("Father 有"+money); } } static class Son extends Father{ public int money=3; public Son(){ money=4; show(); } public void show(){ System.out.println("Son 有"+money); } } public static void main(String[] args) { Father obj=new Son(); System.out.println(obj.money); } } //Son 有0 //Son 有4 //2
上面的输出都是son,这是由于son在建立的时候,首先隐式调用father的构造,而father构造中堆show的调用是一次虚方法调用,实际执行的是son类的show方法,因此输出son。而这时候虽然父类的money已经被初始化为2了,可是show访问的是子类的money,这时money为0,由于它要在子类的构造中才能被初始化。main的最后一句时经过静态类型访问到父类的money,因此为2。
方法的接收者与方法的参数统称为方法的宗量。根据分派基于多少种宗量,能够将分派划分为单分派和多分派。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。
/** * 单分派、多分派 */ public class Dispatch { static class A{} static class B{} public static class Father{ public void show(A a){ System.out.println("Father A"); } public void show(B b){ System.out.println("Father B"); } } public static class Son extends Father{ public void show(A a){ System.out.println("Son A"); } public void show(B b){ System.out.println("Son B"); } } public static void main(String[] args) { Father f=new Father(); Father son=new Son(); f.show(new A()); son.show(new B()); } } //Father A //Son B
在main中调用了两次show,这两次的选择结果已经在输出中显示的很清楚了。首先关注的是编译阶段中编译器的选择,也就是静态分派的过程。这时候选择方法的依据有两点:一是静态类型是Father仍是Son,二是方法参数是A仍是B。此次的选择结果能够经过查看字节码文件得知,生成的两条指令的参数分别为常量池中指向Father::show(A)和Father::show(B)的方法。(查看字节码的常量池得知,#8和#11分别指向参数为A和B的方法)。
由于是根据两个宗量进行选择,因此Java的静态分派属于多分派类型。
再看看运行阶段中虚拟机的选择,也就是动态分派的过程。在执行son.show(B)的方法时,因为编译器已经决定目标方法的签名是show(B),虚拟机此时不会关系传递过来的参数是什么,由于这时候参数的静态类型、实际类型都对方法的选择不会构成影响,惟一能够影响虚拟机选择的因素只有该方法的接收者的实际类型是Father仍是Son。由于只有一个宗量做为选择依据,因此Java的动态分派为单分派类型。
由上可知,java是一门静态多分派、动态单分派的语言。
动态分派是执行很是频繁的动做,并且动态分派的方法版本选择过程须要运行时再接收者类型的方法元数据中搜索合适的目标方法,所以,Java虚拟机实现基于执行性能的考虑,真正运行时通常不会如此频繁地去反复搜索类型元数据。这种状况下,一种基础并且常见的优化手段是为类型在方法区中创建一个虚方法表,使用虚方法表索引来代替元数据查找以提升性能。
虚方法表中存放着各个方法的实际入口地址。若是某个方法在子类中没有被重写,那子类的虚方法表中的地址和父类相同方法的地址入口是一致的,都指向父类的实现入口。若是子类中重写了这个方法,子类虚方法表中的地址也会被替换为指向子类实现版本的入口地址。如图,Son重写了来自Father的所有方法,所以Son的方法表中没有指向Father类型数据的箭头。可是Son和Father都没有重写来自Object的方法,因此它们的方法表中全部从Object继承来的方法都指向了Object的数据类型。
为了程序实现方便,具备相同签名的方法,在父类、子类的虚方法表中都应当具备同样的索引序号,这样当类型变换时,仅须要变动查找的虚方法表,就能够从不一样的虚方法表中按索引转换出所需的入口地址。虚方法表通常在类加载的链接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的虚方法表也一同初始完毕。
在文章的最后做者为你们整理了不少资料!包括java核心知识点+全套架构师学习资料和视频+一线大厂面试宝典+面试简历模板+阿里美团网易腾讯小米爱奇艺快手哔哩哔哩面试题+Spring源码合集+Java架构实战电子书等等!所有免费分享给你们,有须要的朋友欢迎关注公众号:前程有光,领取!