java虚拟机之方法调用(上)——静态分派与动态分派

前言

我们都知道,面象对象的几大特性:封装,继承,多态。

其实在面试过程中也是经常被问到这个问题的。那么问题来了,java虚拟机是如何实现多态的?

其实在java虚拟机中,说到多态的实现,就不得不说说方法调用了。

方法调用概念

方法调用并不等于方法执行,方法调用阶段唯一的任务是确定被调用方法的版本(其实就是调用哪一个方法)。我们都知道,Class文件的编译过程中不包含c语言编译中的连接步骤,一切方法调用在Class文件里面都是符号引用,并不是java运行时的入口地址(这里也侧面印证了上一篇文章里面java虚拟机之类加载机制解析工作要做的事)。其实正是因为这样,才给java带来了强大的扩展功能。

然而,到底什么时候才会确定方法目标方法的直接引用呢?

其实这个问题的答案得从类加载期间贯穿到运行期间。

解析

上一篇博客讲了,在类加载的解析阶段,会将部分符号引用转化成直接引用,这个解析阶段解析的部分“符号引用”必须满足下面的条件:

  • 方法在程序真正运行前就有一个确定的版本。
  • 这个方法的调用版本在运行期间是不可改变的。

当满足这两个方法时,那么在类加载的解析阶段,就会转换这个方法的符号引用到直接引用。

其实,满足这两具要求的方法主要有两种:

1、静态方法
2、私有方法

其实这两种方法有一个共同点,那就是它们在继承的时候都不可以被重写,所以它们可以在类加载的解析阶段就能被确定唯一版本。

与上面对应,在java虚拟机中,提供了5种方法调用字节码命令,分别如下:

  • invokestatic 调用静态方法
  • invokespecial 调用构造方法,私有方法和父类方法。
  • invokevirtual 调用虚方法
  • invokeinterface 调用接口方法,它会在运行的时候确定一个实现该接口的对象
  • invokedynamic 这货是动态解析再调用方法。有点复杂,我们先不管它

通过上面的总结,我们发现invokestaticinvokespecial 修饰的方法似乎都能被唯一确定。所以他们都可以在类加载的时候唯一确定版本,就是说他们都能在类加载的时候被解析。

我们都知道静态方法不能被继承,那么我们来看看下面这个栗子:

public class Main {
    public static void go(){
        
        System.out.println("go");
        
    }
    public static void main(String[] args) {
        
        Main.go();
    }
    
}

用javap来看一下字节码,由于字节码有点长,我就截取了部分重要代码:


重点在白色框框住的代码里面,invokestatic #30

我们再看看#30是什么鬼:


原来是Main.go();程序里面直接就调用了Main.go();方法,可见go()方法是唯一确定的。

其实java里面除了invokestaticinvokespecial ,其实还有一种情况是可以在java类加载的解析阶段被唯一确定的,那就是final修饰的方法。因为final修饰的方法不能被覆盖,因此方法的接收者不会进行多态的选择,所以在解析的时候是可以被唯一确定的。

由于解析是一个静态的过程,编译期间即可确定,在解析的时候就能把符号引用直接转化成直接引用。

其实在java中,还存在一种调用(分派调用),它既可以静态也可以动态。其实在这里,java多态的性质实现会得到体现。

分派调用

其实分派分为两种,即动态分派和静态分派。我们在了解分派的时候,通常把它们与重写和重载结合到一起。

重载(overload)与静态分派

我们先看一个题:

public class Main {
    static abstract class Father {

    }

    static class Son extends Father {

    }

    static class Daughter extends Father {

    }

    public void getSex(Daughter daughter) {
        System.out.println("i am a girl");
    }

    public void getSex(Son son) {
        System.out.println("i am a boy");
    }

    public void getSex(Father son) {
        System.out.println("i am a father");
        
    }
    public static void main(String[] args) {

        Father son = new Son();
        Father daughter = new Daughter();
        Main main = new Main();
        
        main.getSex(son);
        main.getSex(daughter);
    }

}

大家凭自己的经验看能不能猜出会输出什么?

其实这个栗子就体现了重载。

要是我们在代码里改一下:

main.getSex((Son)son);

就会输出i am a boy

其实这里也体现出了java的静态分派,我们都可以看到main对象已经确认了,那么main在运行main.getSex(son);时选择方法的时候,到底是选择getSex(Son son)还是getSex(Father son)呢?

我们在代码中son的引用类型是Father,但是它的实际类型却是Son
我们再来看看生成的字节码:

字节码里面0-23我们直接跳过,因为0-23对用的代码是

        Father son = new Son();
        Father daughter = new Daughter();
        Main main = new Main();

这里的字节码的作用是创建内存空间,然后把son 、daughter 和main 实例放到第1、2、3个实例变量表Slot中,这里其实还有第0个实例,是this指针,放到的第0个slot中,这个超出了本文要讲解的内容,故跳过。

我们从24看起,aload_x是把刚刚创建的实例放到操作数栈中,然后才能对其操作。后面第26行可以看到:

invokevirtual #50 //Method getSex:(LMain$Father;)

这里相信大家都可以看出,字节码中已经确定了方法的接收者是main和方法的版本是getSex(Father father),所以我们在运行代码的时候,会输出i am a father

其实java编译器在重载的时候是通过参数的静态类型而不是实际类型来确定使用哪个重载的版本的。所以这里在字节码中,选择了getSex(Father father)作为调用目标并把这个方法的符号引用写到main方法的几个invokevirtual指令的参数里面。

所以依赖静态类型来定位方法执行的版本的分派动作成为静态分派。静态分派的典型应用是方法重载,而且静态分派发生在编译期间,因此,静态分派的动作是由编译器发出的。

另外,编译器能确定出方法的重载版本,但在很多的时候,这个版本并不一定是唯一的,比如我把上面的代码改一下:

public class Main {
    static abstract class Father {

    }

    static class Son extends Father {

    }

    public void getSex(Son son) {
        System.out.println("i am a boy");
    }

    public void getSex(Father son) {
        System.out.println("i am a father");
        
    }
    public static void main(String[] args) {

        Son son = new Son();
        Main main = new Main();
        
        main.getSex(son);
    }

}

然后再输出:

QQ截图20160724221829.png

这是很正常的执行结果,要是我们把getSex(Son son)注释掉,然后再运行试试:

发现,编译器并找不到getSex(Son son)这个方法,只有作出适当的妥协,把son向上转型为Father,然后选择了getSex(Father son)方法。

要是我们再把getSex(Father son)注释掉,会发现:

这里又选择了妥协并向上继续转型成Object。

综上所述:静态分派是选择的最合适的一个方法版本来重载,然而这个版本并不是唯一确定的。我们在写代码的时候,要尽量避免这种情况发生,虽然这似乎能显示出你知识的很渊博,但这并不是一个明智的选择。

重写(override)与动态分派

看完了静态分派,我们再来看看动态分派。动态分派经常与重写紧密联系在一起,那么我们就先来看一个重写的栗子:

public class Main {
    static  class Father {
        public void say(){
            System.out.println("i am fasther");
        }
    }

    static class Son extends Father {

        @Override
        public void say() {
            System.out.println("i am son");
        }

        
    }

    static class Daughter  extends Father {

        @Override
        public void say() {
            System.out.println("i am daughter ");
        }

    }
    public static void main(String[] args) {

        Father son = new Son();
        Father daughter = new Daughter();
        
        son.say();
        daughter.say();
        
    }

}

output:

i am son
i am daughter 

相信大家都知道输出结果是什么,三个类都有say()方法,但是虚拟机是怎样知道调用哪个方法的呢? 别急,我们还是按照惯例,看看字节码:

现在相信大家大概都能看懂里面字节码是怎样回事了吧?

我们发现第17行和第21行对应的java代码应该是:

        
        son.say();
        daughter.say()

从字节码来看,这两行代码是一样的。调用了同一个类的同一个方法,都是Father.say(),那为什么他们最后的输出却不一样??

这里的原因其实要从invokevirtual的多态查找开始说起,invokevirtual指令运行时的解析过程大概如下:

  • 找到操作数栈的栈顶元素所指向的对象的实际类型,记作C
  • 如果在类型C中找到与描述符和简单名称都相符的方法,则进行访问权限校验。通过则放回这个方法的直接引用,否则返回illegalaccesserror
  • 否则,则按照继承关系从下住上依次对C的父类进行步骤2的查找。
  • 如果始终没有找到合适的方法,则跑出AbstractMethodError异常。

由于invokevirtual指令在执行的第一步就对运行的时候的接收者的实际类型进行查找,所以上面两次调用的invokevirtual指令都能成功找到实际类型的say()方法,然后把类方法的符号引用解析到不同的直接引用上面,这也是重写的体现。

然后这种运行期根据实际类型来判断方法的执行版本的分派过程叫作动态分派。

对于重写与重载就先告一段落,后面还会补上分派的其他内容,欢迎关注。

参考资料

《深入理解java虚拟机》
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章