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

7次阅读

共计 6267 个字符,预计需要花费 16 分钟才能阅读完成。

前言

办法调用并不等同于办法中的代码被执行,办法调用阶段惟一的工作就是确定被调用办法的版本(即调用哪一个办法),临时还未波及办法外部的具体运行过程。所有办法调用在 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 的办法)。

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

再看看运行阶段中虚拟机的抉择,也就是动静分派的过程。在执行 son.show(B) 的办法时,因为编译器曾经决定指标办法的签名是 show(B),虚拟机此时不会关系传递过去的参数是什么,因为这时候参数的动态类型、理论类型都对办法的抉择不会形成影响,惟一能够影响虚拟机抉择的因素只有该办法的接收者的理论类型是 Father 还是 Son。因为只有一个宗量作为抉择根据,所以 Java 的动静分派为单分派类型。

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

虚拟机动静分派的实现

动静分派是执行十分频繁的动作,而且动静分派的办法版本抉择过程须要运行时再接收者类型的办法元数据中搜寻适合的指标办法,因而,Java 虚拟机实现基于执行性能的思考,真正运行时个别不会如此频繁地去重复搜寻类型元数据。这种状况下,一种根底而且常见的优化伎俩是为类型在办法区中建设一个虚办法表,应用虚办法表索引来代替元数据查找以进步性能。

虚办法表中寄存着各个办法的理论入口地址。如果某个办法在子类中没有被重写,那子类的虚办法表中的地址和父类雷同办法的地址入口是统一的,都指向父类的实现入口。如果子类中重写了这个办法,子类虚办法表中的地址也会被替换为指向子类实现版本的入口地址。如图,Son 重写了来自 Father 的全副办法,因而 Son 的办法表中没有指向 Father 类型数据的箭头。然而 Son 和 Father 都没有重写来自 Object 的办法,所以它们的办法表中所有从 Object 继承来的办法都指向了 Object 的数据类型。

为了程序实现不便,具备雷同签名的办法,在父类、子类的虚办法表中都该当具备一样的索引序号,这样当类型变换时,仅须要变更查找的虚办法表,就能够从不同的虚办法表中按索引转换出所需的入口地址。虚办法表个别在类加载的连贯阶段进行初始化,筹备了类的变量初始值后,虚构机会把该类的虚办法表也一起初始结束。

最初

在文章的最初作者为大家整顿了很多材料!包含 java 外围知识点 + 全套架构师学习材料和视频 + 一线大厂面试宝典 + 面试简历模板 + 阿里美团网易腾讯小米爱奇艺快手哔哩哔哩面试题 +Spring 源码合集 +Java 架构实战电子书等等!
全副收费分享给大家,有须要的敌人欢送关注公众号:前程有光,支付!

正文完
 0