开发两年!JVM方法调用都玩不明白,你离被炒鱿鱼不远了!

前言

方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还未涉及方法内部的具体运行过程。一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(也就是直接引用)。这个特性给Java带来了更强的动态扩展能力,但也使得Java方法调用过程变得相对复杂,这些调用需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。

解析

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

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

Java中的静态方法、私有方法、实例构造器、父类方法,再加上被final修饰的方法,这5种方法调用会在类加载的时候就可以把符号引用转换为直接引用。这些方法统称为“非虚方法” 。与之相反,其他的方法被称为“虚方法”。

解析调用一定是一个静态过程 ,在编译期就完全确定,在类加载解析阶段就会把涉及的符号引用全部转变为明确的直接引用,不必延迟到运行期再去完成。而另一种主要的方法调用形式:分派(Dispatch)调用,可能是静态的也可能是动态的。按照分派依据的宗量数可分为单分派和多分派。这两类分派方式两两组合就构成了静态单分派,静态多分派,动态单分派,动态多分派。

分派

分派调用将会解释多态性特征的一些最基本的体现。

静态分派

/**
 * 静态分派
 */
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

运行结果如上,要解决这个问题,首先需要定义两个关键概念:

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的方法)。

开发两年!JVM方法调用都玩不明白,你离被炒鱿鱼不远了!_第1张图片

因为是根据两个宗量进行选择,所以Java的静态分派属于多分派类型。

再看看运行阶段中虚拟机的选择,也就是动态分派的过程。在执行son.show(B)的方法时,由于编译器已经决定目标方法的签名是show(B),虚拟机此时不会关系传递过来的参数是什么,因为这时候参数的静态类型、实际类型都对方法的选择不会构成影响,唯一可以影响虚拟机选择的因素只有该方法的接收者的实际类型是Father还是Son。因为只有一个宗量作为选择依据,所以Java的动态分派为单分派类型。

由上可知,java是一门静态多分派、动态单分派的语言。

虚拟机动态分派的实现

动态分派是执行非常频繁的动作,而且动态分派的方法版本选择过程需要运行时再接收者类型的方法元数据中搜索合适的目标方法,因此,Java虚拟机实现基于执行性能的考虑,真正运行时一般不会如此频繁地去反复搜索类型元数据。这种情况下,一种基础而且常见的优化手段是为类型在方法区中建立一个虚方法表,使用虚方法表索引来代替元数据查找以提高性能。

开发两年!JVM方法调用都玩不明白,你离被炒鱿鱼不远了!_第2张图片

虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表中的地址和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类虚方法表中的地址也会被替换为指向子类实现版本的入口地址。如图,Son重写了来自Father的全部方法,因此Son的方法表中没有指向Father类型数据的箭头。但是Son和Father都没有重写来自Object的方法,所以它们的方法表中所有从Object继承来的方法都指向了Object的数据类型。

为了程序实现方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序号,这样当类型变换时,仅需要变更查找的虚方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。虚方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的虚方法表也一同初始完毕。

最后

在文章的最后作者为大家整理了很多资料!包括java核心知识点+全套架构师学习资料和视频+一线大厂面试宝典+面试简历模板+阿里美团网易腾讯小米爱奇艺快手哔哩哔哩面试题+Spring源码合集+Java架构实战电子书等等!
全部免费分享给大家,有需要的朋友欢迎关注公众号:前程有光,领取!

你可能感兴趣的:(面试程序员编程后端JVM)