欢迎您访问程序员文章站本站旨在为大家提供分享程序员计算机编程知识!
您现在的位置是: 首页  >  IT编程

JAVA方法调用中的解析与分派

程序员文章站 2022-05-25 12:19:59
JAVA方法调用中的解析与分派 本文算是《深入理解JVM》的读书笔记,参考书中的相关代码示例,从字节码指令角度看看解析与分派的区别。 方法调用,其实就是要回答一个问题:JVM在执行一个方法的时候,它是如何找到这个方法的? 找一个方法,就需要知道 所谓的 地址。这个地址,从不同的层次看,对它的称呼也不 ......

JAVA方法调用中的解析与分派

本文算是《深入理解JVM》的读书笔记,参考书中的相关代码示例,从字节码指令角度看看解析与分派的区别。

方法调用,其实就是要回答一个问题:JVM在执行一个方法的时候,它是如何找到这个方法的?

找一个方法,就需要知道 所谓的 地址。这个地址,从不同的层次看,对它的称呼也不同。从编译器javac的角度看,我称之为符号引用;从jvm虚拟机角度看,称之为直接引用。或者说,在class字节码角度看,将这个地址称之为符号引用;当将class字节码加载到内存(方法区)中后,称之为直接引用。当然,这是我个人的理解,也许不正确。

从符号引用如何变成直接引用的?

在回答这个问题之前,先看看符号引用是什么?它是怎么来的?为什么需要它?直接引用又是什么?最后,符号引用是怎么转化成直接引用的。

  1. 符号引用是什么?

    根据定义:符号引用属于编译原理方面的概念,包括了下面三类常量:

    • 类和接口的全限定名
    • 字段的名称和描述符
    • 方法的名称和描述符

    抛开定义,举个例子来说明:工程师写的一个JAVA程序如下:

    package org.hapjin.dynamic;
    
    /**
     * Created by Administrator on 2018/7/26.
     */
    public class SymbolicTest {
        private int m;
        public void test(){}
    }

    源代码经过javac编译后生成的class文件,这个class文件当然也是按规定的格式组织的,即class文件格式。使用WinHex打开如下,然后来找一找 类的全限定名,在class文件中的哪个地方。

JAVA方法调用中的解析与分派

如上图,蓝色阴影区域(红色方框)区域中标出了:SymbolicTest.java 这个类的全限定名:!Lorg/hapjin/dynamic/SymbolicTest,而这就是一个符号引用。这样就明白了符号引用是怎么来的了。

  1. 为什么需要符号引用?

    符号引用其实是从字节码角度来标识类、方法、字段。字节码只有加载到内存中才能运行,加载到内存中,就是内存寻址了。

    在class文件中不会保存各个方法、字段的最终内存布局信息,因此这些字段、方法的符号引用不经过运行期转换的话无法得到真正的内存入口地址,也就无直接被虚拟机使用。

那这个运行期转换,到底是在类的生命周期的哪个阶段进行的转换?是在加载阶段、还是在连接阶段、还是在初始化阶段、还是在使用阶段?这个后面再分析。 ​

  1. 直接引用是什么?

    JAVA虚拟机运行时数据区 分为很多部分:

JAVA方法调用中的解析与分派

其中有一个叫做方法区,它用于存储已被虚拟机加载的类信息、常量、静态变量……比如说,类的接口的全限定名、方法的名称和描述符 这些都是类信息。因此,是被加载到方法区存储。

那前面已经提到,类的接口的全限定名、方法的名称和描述符 都是符号引用,当被加载到内存的方法区之后,就变成了直接引用(这样说,有点绝对,因为 有些方法需要等到jvm执行字节码的时候,或者叫程序运行的时候 才能知道要调用哪个方法)

直接引用有两种方式来定位对象,句柄和直接指针。看下面的图加深下理解:

JAVA方法调用中的解析与分派

虚拟机栈里面 reference 可以理解成直接引用,换句话说,直接引用 存储 在虚拟机栈中(并不是说,其它地方就不能存储直接引用了,因为我也不知道其他地方能不能存储直接引用,比如 static 类型的对象的直接引用)。

从这里也可以映证一点:在内存分配与回收过程中,判断对象是否可达的可达性分析算法中:可作为GC roots 的对象有:虚拟机栈中引用的对象。

JAVA方法调用中的解析与分派

对符号引用和直接引用有了一定认识之后,最后来看看:符号引用是如何变成直接引用的?先来看张图:

JAVA方法调用中的解析与分派

类从被载到虚拟机内存,到卸载出内存为止,整个生命周期如上图。那有些 符号引用转化成直接引用,是不是也发生在上面某个阶段呢?

其实就是根据 在哪个阶段 符号引用 转化成直接引用,将方法调用分成:解析调用 与 分派调用。

在类加载的解析阶段,会将一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。

换句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来,这类方法的调用称为解析

只要能被 invokestatic 和 invokespecial 指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的方法有:静态方法、私有方法、实例构造器、父类方法 4类。

下面来看下,这四类方法 调用的字节码指令和符号引用是啥?

public class StaticResolution {
    public static void sayHello() {
    System.out.println("hello world");
    }

    private void sayBye() {
    System.out.println("bye");
    }

    public static void main(String[] args) {
    StaticResolution.sayHello();//静态方法调用

    StaticResolution sr = new StaticResolution();
    sr.sayBye();//私有方法调用
    }
}

使用javap -v StaticResolution 对class文件反编译,查看main方法的内容如下:

JAVA方法调用中的解析与分派

  • 序号0 是静态方法的调用

    这个静态方法的描述符 是sayHello:()V,由于静态方法是与类相关的,不能在一个类里面再定义一个与描述符sayHello:()V一样的方法,不然编译期就会提示“重名的方法”错误。(虽然可以通过修改字节码的方式,在同一个class字节码文件里面可存在2个方法描述符相同的方法,但是在类加载的验证阶段,就会验证失败,具体可参考中提到的方法描述符与特征签名的区别)

    所以,虚拟机在执行 invokestatic 这条字节码指令的时候,能够根据sayHello:()V方法描述符(符号引用) 来唯一确定调用的方法就是public static void sayHello() {System.out.println("hello world");}

  • 序号7 是实例方法的调用

  • 序列12 是私有方法的调用

    同理,由于私有方法不能被子类继承,因此在同一个类里面也不能再定义一个与描述符sayBye:()V一样的方法。

因此,上面四类方法的调用称为 解析调用,对于这四类方法,它们的符号引用在 解析阶段 就转成了 直接引用。另外其实可以看出,解析调用的方法接收者是唯一确定的。

下面再来看分派调用:

用重载和覆盖来解释分派调用,可参考 。后面的讲解也以这篇参考文章中的 图一 和 图二 进行说明。

分派调用分成两类:静态分派和动态分派。其中,重载属于静态分派、方法覆盖属于动态分派。下面来解释一下为什么?

在分派中,涉及到一个概念:叫实际类型 和 静态类型。比如下面的语句:

    Human man = new Man();
    Human woman = new Woman();

等式左边叫静态类型,等式右边是实际类型。比如 man 这个引用,它的静态类型是Human,实际类型是Man;woman这个引用,静态类型是Human,实际类型是Woman

从参考文章的图一和图二中看出:sayHello方法的调用都是由 invokevirtual 指令执行的。我想,这也是解析与分派的一个区别吧 ,就是分派调用是由 invokevirtual 指令来执行。

那静态分派调用 和 动态分派调用的区别在哪儿呢?

  • 静态分派

    静态分派方法的调用(方法重载)如下:

        sr.sayHello(man);//hello, guy
        sr.sayHello(woman);//hello, guy

    man引用和woman引用的静态类型都是Human,因此方法重载是根据 引用的静态类型来选择相应的方法执行的,结果就是选择了 Human类的sayHello方法执行。

  • 动态分派

    动态分派方法调用(方法覆盖)的代码如下:

        Human man = new Man();
        Human woman = new Woman();
        man.sayHello();//man say hello
        woman.sayHello();//woman say hello

    由上面可知:变量man引用的动态类型是Man,变量woman引用的动态类型是Woman,方法的执行是根据引用的 实际类型来选择相应的方法执行的。结果就是分别选择了 Man类的sayHello方法 和 Woman类的sayHello方法执行。

当然了,静态分派与动态分派的具体执行过程的差异也可以由参考文章窥出端倪。

至此,解析与分派就介绍完了。

最后,书中使用QQ和_360 的示例,谈到了JAVA语言的静态分派属于多分派类型;动态分派属于单分派类型。趁着前面对分派的分析,记录一下我的理解:

首先,它是根据宗量的个数来区分单分派与多分派的。那宗量是什么呢?宗量可理解成:引用的静态类型、实际类型、方法的接收者。看代码:

public class Dispatch {
    static class QQ{}
    static class _360{}

    public static class Father{
    public void hardChoice(QQ arg)
    {
        System.out.println("father choose qq");
    }
    public void hardChoice(_360 arg)
    {
        System.out.println("father choose 360");
    }
    }

    public static class Son extends Father{
    public void hardChoice(QQ arg)
    {
        System.out.println("son choose qq");
    }
    public void hardChoice(_360 arg)
    {
        System.out.println("son chooes 360");
    }
    }

    public static class Son2 extends Father{
    public void hardChoice(QQ arg)
    {
        System.out.println("son2 choose qq");
    }
    public void hardChoice(_360 arg)
    {
        System.out.println("son2 chooes 360");
    }
    }

    public static void main(String[] args) {
    Father father = new Father();
    Father son = new Son();
    Father son2 = new Son2();

    father.hardChoice(new _360());//father choose 360
    son.hardChoice(new QQ());//son choose qq
    son2.hardChoice(new QQ());//son2 choose qq
    son2.hardChoice(new _360());//son2 chooes 360
    }
}

javap -v Dispatch 反编译出来class字节码文件的main方法如下:

JAVA方法调用中的解析与分派

其中下面这两句方法调用的符号引用是一样的,都是:org/hapjin/dynamic/Dispatch$Father.hardChoice:(Lorg/hapjin/dynamic/Dispatch$QQ;)V

    son.hardChoice(new QQ());//son choose qq
    son2.hardChoice(new QQ());//son2 choose qq

既然这两个方法调用的符号引用是一样,但是它们最终输出了不同的值。说明,虚拟机在执行的时候,选择了不同的方法来执行。而变量son 和 son2 的静态类型都是Father,但是son的实际类型是 类Son,son2的实际类型是 类Son2。(变量son和son2 都是它们各自方法的接收者)

而书中说:“因为这里参数的静态类型、实际类型都对方法的选择不会构成任何影响”,其实在编译出class字节码文件的时候,方法的参数的类型就已经确定了,在这个示例中都是 类QQ,那当然不能构成影响了,但我总觉得这种说法有点勉强,导致费解。
JAVA方法调用中的解析与分派

动态分派不仅要看方法接收者的实际类型,也是要看方法的参数类型的,只是编译成class文件的时候方法的参数类型就已经确定了而已。其实也不用管,只需要明白 invokevirtual 指令解析过程的大致步骤就能区分,方法在运行时到底是调用哪个方法了。

invokevirtual指令的解析过程大致分为以下几个步骤:

1. 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C
2. 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常。
3. 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
4. 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

而这两句方法调用的符号引用也是一样的,都是:org/hapjin/dynamic/Dispatch$Father.hardChoice:(Lorg/hapjin/dynamic/Dispatch$_360;)V

father.hardChoice(new _360());//father choose 360
son2.hardChoice(new _360());//son2 chooes 360

但是,这两句的执行结果也不一样,根据invokevirtual指令的解析过程可知:

father.hardChoice(new _360());语句操作数栈顶的第一个元素所指的对象的实际类型是Father。

son2.hardChoice(new _360());语句操作数栈顶的第一个元素所指的对象的实际类型是Son2。

所以它们一个执行的是Father类中的hardChoice(_360 arg),一个执行的是Son2类中的hardChoice(_360 arg)方法。

总结一下:虚拟机具体在选择哪个方法执行时:

根据在编译成class字节码文件后就确定了执行哪个方法----解析 or 分派

根据方法接收者的静态类型 和 实际类型 ---- 动态分派 or 静态分派

根据宗量个数来 确定具体执行哪个方法----多分派 or 单分派

但总感觉这样划分有点绝对,不太准确。

构思了一个星期的文章,终于完成了。

参考文章:

参考书籍:《深入理解java虚拟机》

原文: