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);
    }

}

然后再输出:

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

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

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

这里又选择了妥协并向上继续转型成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()方法,然后把类方法的符号引用解析到不同的直接引用上面,这也是重写的体现。

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

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

时间: 2024-10-08 14:06:34

java虚拟机之方法调用(上)的相关文章

深入理解Java虚拟机笔记---方法调用

方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定调用方法的版本(即调用哪一个方法),暂时还不涉及方法内部的具体运行过程.在程序运行时,进行方法调用是最普遍.最频繁的操作.在Class文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在Class文件里存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(相当于直接引用).这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法的调用过程变得相对复杂,需要在类加载期间甚至到运行期间才能确定目标方法的直接引用.

java虚拟机:方法区

一.方法区简介 方法区,Method Area, 对于习惯在HotSpot虚拟机上开发和部署程序的开发者来说,很多人愿意把方法区称为"永久代"(Permanent Generation),本质上两者并不等价,仅仅是因为HotSpot虚拟机的设计团队选择把GC分代收集扩展至方法区,或者说使用永久代来实现方法区而已.对于其他虚拟机(如BEA JRockit.IBM J9等)来说是不存在永久代的概念的. 主要存放已被虚拟机加载的类信息.常量.静态变量.即时编译器编译后的代码等数据(比如spr

[Java问题] 继承方法调用问题

由于用java的时间不长,一上来就让我把一个软件从matlab移到java中,所以不断出现了很多问题,解决这些问题最快的方法就是写一些小代码调试和上网问度娘,另一个方法是问师兄,但是跟他们描述一个问题的时候比我上网搜发的时候还多,另外他们也未必知道答案,所以第二个方法我很少用,这也是我写这篇文章的原因,把问题写下来,慢慢思考,随着java技术的增进,说不定会解决,即使不能解决,下一次编程的时候,我也知道这样编写代码是不能达到我的要求的,要换一中方法. 说了这么多,来说我遇到的继承方法调用的问题吧

6.3-全栈Java笔记:异常处理方法(上)

异常的处理办法之一  捕获异常 图1 异常处理 上面过程详细解析: 1.try块: try语句指定了一段代码,该段代码就是一次捕获并处理的范围.在执行过程中,当任意一条语句产生异常时,就会跳过该段中后面的代码.代码中可能会产生并抛出一种或几种类型的异常对象,它后面的catch语句要分别对这些异常做相应的处理. 一个try语句必须带有至少一个catch语句块或一个finally语句块 . 注意事项 当异常处理的代码执行结束以后,是不会回到try语句去执行尚未执行的代码. 2.catch: 1)每个

JAVA类的方法调用

一.类的分类: 1.普通类 2.抽象类(含有抽象方法的类) 3.静态类(不需要实例化,就可以使用的类) 二.方法的分类: 1.私有方法(只有类的内部才可以访问的方法) 2.保护方法(只有类的内部和该类的子类可以访问的方法) 3.共有方法(无论内部或外部均可访问的方法) 4.静态方法(可以不实例话对象,通过类名.方法就可以调用的方法) 5.抽象方法(只有方法的签名而没有实现的方法) 三.方法的调用: 1.普通类:实例化一个该类的对象,然后通过对像访问.例如: class A { public vo

Java RMI远程方法调用

参考: 利用java-RMI进行大文件传输 RMI远程方法调用的简单方法和实例 RMI是类似RPC的一种远程方法调用协议,比RPC类型的WebService更简单,也可以跨进程访问 先来创建一个model或者javaBean,注意,该类必须实现序列化Serializable  public class Person implements Serializable {     private int id;     private String name;     private int age;

深入理解Java虚拟机笔记---方法表集合

方法表的结构与字段表一样,依次包含了访问标志(access_flags),名称索引(name_index),描述符索引(descriptor_index),属性表集合(attributes)几项,如下表所示: 因为volatile关键字和transient关键字不能修改方法,所以方法表的访问标志中没有了ACC_VOLATILE与ACC_TRANSIENT标志.与之相对的,synchronized, native, strictfp和abstract关键字可以修饰方法,所以方法表的访问标志中增加了

Java学习笔记之RMI远程方法调用

RMI 应用通常有两个分开的程序组成,一个服务端程序和一个客户端程序.一个典型的服务端程序创建一些远程对象,使得对这些远程对象的引用可以被访问,等待客户端调用这些远程对象提供的方法.一个典型的客户端程序获取远程引用,指向一个或者多个服务端上的远程对象,然后调用这些远程对象所提供的方法.通常我们称这为分布式对象应用程序. 3.1 RMI的工作方式 分布式对象应用程序需要做的事情: l 查找(定位)远程对象. 应用程序可以使用各种不同的机制取得远程对象的引用.比如应用程序可以通过 RMI 提供的简单

JVM 运行时数据区:程序计数器、Java 虚拟机栈和本地方法栈,方法区和堆

Java 虚拟机可以看作一台抽象的计算机,如同真实的计算机,它也有自己的指令集和运行时内存区域. Java 虚拟机在执行 Java 程序的过程中会把它所管理的内存(运行时内存区域)划分为若干个不同的数据区域. 如下图所示: 一.程序计数器 Program Counter Register 定义:程序计数器是当前线程所执行字节码的行号指示器. 原因:Java 中的多线程是线程间轮流切换并需要 CPU 给予时间片的方式实现的.在任何一个确定的时刻,都只有一个线程在执行指令.为了线程间轮流切换后能够快