JAVA方法调用中的解析与分派
本文算是《深入理解JVM》的读书笔记,参考书中的相关代码示例,从字节码指令角度看看解析与分派的区别。
方法调用,其实就是要回答一个问题:JVM在执行一个方法的时候,它是如何找到这个方法的?
找一个方法,就需要知道 所谓的 地址。这个地址,从不同的层次看,对它的称呼也不同。从编译器javac的角度看,我称之为符号引用;从jvm虚拟机角度看,称之为直接引用。或者说从class字节码角度看,将这个地址称之为符号引用;当将class字节码加载到内存(方法区)中后,称之为直接引用。当然,这是我个人的理解,也许不正确。
从符号引用如何变成直接引用的?
在回答这个问题之前,先看看符号引用是什么?它是怎么来的?为什么需要它?直接引用又是什么?最后,符号引用是怎么转化成直接引用的。
-
符号引用是什么?
根据定义:符号引用属于编译原理方面的概念,包括了下面三类常量:
- 类和接口的全限定名
- 字段的名称和描述符
- 方法的名称和描述符
抛开定义,举个例子来说明:工程师写的一个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文件中的哪个地方。
如上图,蓝色阴影区域(红色方框)区域中标出了:SymbolicTest.java 这个类的全限定名:!Lorg/hapjin/dynamic/SymbolicTest
,而这就是一个符号引用。这样就明白了符号引用是怎么来的了。
-
为什么需要符号引用?
符号引用其实是从字节码角度来标识类、方法、字段。字节码只有加载到内存中才能运行,加载到内存中,就是内存寻址了。
在class文件中不会保存各个方法、字段的最终内存布局信息,因此这些字段、方法的符号引用不经过运行期转换的话无法得到真正的内存入口地址,也就无直接被虚拟机使用。
那这个运行期转换,到底是在类的生命周期的哪个阶段进行的转换?是在加载阶段、还是在连接阶段、还是在初始化阶段、还是在使用阶段?这个后面再分析。
-
直接引用是什么?
JAVA虚拟机运行时数据区 分为很多部分:
其中有一个叫做方法区,它用于存储已被虚拟机加载的类信息、常量、静态变量……比如说,类的接口的全限定名、方法的名称和描述符 这些都是类信息。因此,是被加载到方法区存储。
那前面已经提到,类的接口的全限定名、方法的名称和描述符 都是符号引用,当被加载到内存的方法区之后,就变成了直接引用(这样说,有点绝对,因为 有些方法需要等到jvm执行字节码的时候,或者叫程序运行的时候 才能知道要调用哪个方法)
Class 文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。另一部分将在每次运行期间转化为直接引用,称为动态连接(动态分派)。栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区:虚拟机栈(不同于堆、方法区)中的内容,栈帧存储了方法的局部变量表、操作数栈、动态连接、和方法返回地址等信息。这里所说的动态连接,就是:一个指向运行时常量池中该栈帧所属方法的引用,虚拟机就是根据这个信息知道要调用哪个具体的方法。
直接引用有两种方式来定位对象,句柄和直接指针。看下面的图加深下理解:
虚拟机栈里面 reference 可以理解成直接引用,换句话说,直接引用 存储 在虚拟机栈中(并不是说,其它地方就不能存储直接引用了,因为我也不知道其他地方能不能存储直接引用,比如 static 类型的对象的直接引用)。
从这里也可以映证一点:在内存分配与回收过程中,判断对象是否可达的可达性分析算法中:可作为GC roots 的对象有:虚拟机栈中引用的对象。
对符号引用和直接引用有了一定认识之后,最后来看看:符号引用是如何变成直接引用的?先来看张图:
类从被载到虚拟机内存,到卸载出内存为止,整个生命周期如上图。那有些 符号引用转化成直接引用,是不是也发生在上面某个阶段呢?
其实就是根据 在哪个阶段 符号引用 转化成直接引用,将方法调用分成:解析调用 与 分派调用。
在类加载的解析阶段,会将一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。
换句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来,这类方法的调用称为解析
只要能被 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方法的内容如下:
-
序号0 是静态方法的调用
这个静态方法的描述符 是
sayHello:()V
,由于静态方法是与类相关的,不能在一个类里面再定义一个与描述符sayHello:()V
一样的方法,不然编译期就会提示“重名的方法”错误。(虽然可以通过修改字节码的方式,在同一个class字节码文件里面可存在2个方法描述符相同的方法,但是在类加载的验证阶段,就会验证失败,具体可参考从虚拟机指令执行的角度分析JAVA中多态的实现原理中提到的方法描述符与特征签名的区别)
“虽然可以通过修改字节码的方式,在同一个class字节码文件里面可存在2个方法描述符相同的方法”表明:class 字节码的描述能力是强于Java语言的,这也验证了为什么可以将其他类型的语言(比如 动态类型)转换成字节码,从而运行在JVM上。只要class字节码能有效地支持这种 动态类型 即可。
所以,虚拟机在执行 invokestatic 这条字节码指令的时候,能够根据sayHello:()V
方法描述符(符号引用) 来唯一确定调用的方法就是public static void sayHello() {System.out.println("hello world");}
序号7 是实例方法的调用(默认构造函数的调用)
-
序列12 是私有方法的调用
同理,由于私有方法不能被子类继承,因此在同一个类里面也不能再定义一个与描述符
sayBye:()V
一样的方法。
因此,上面四类方法的调用称为 解析调用,对于这四类方法,它们的符号引用在 解析阶段 就转成了 直接引用。另外其实可以看出,解析调用的方法接收者是唯一确定的。
总结一下:在java语言中,重载的方法(overload),由于方法的描述符是唯一的。因此.java文件编译成.class字节码后,生成的方法符号引用也是唯一的,那么Code属性表里面方法调用指令就能确定具体调用哪个方法,因而是解析调用。
下面再来看分派调用:
用重载和覆盖来解释分派调用,可参考从虚拟机指令执行的角度分析JAVA中多态的实现原理 。后面的讲解也以这篇参考文章中的 图一 和 图二 进行说明。
分派调用分成两类:静态分派和动态分派。其中,重载属于静态分派、方法覆盖属于动态分派。下面来解释一下为什么?
在分派中,涉及到一个概念:叫实际类型 和 静态类型。比如下面的语句:
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, guyman引用和woman引用的静态类型都是Human,因此方法重载是根据 引用的静态类型来选择相应的方法执行的,也就是说:上面两条语句中的
sayHello(Human )
方法的参数类型都是Human,结果就是选择了参数类型为 Human 的 sayHello方法执行。
再来解释一下是如何确实选择哪一个sayHello方法执行的?完整代码是这篇文章中的StaticDispatch.java 。main方法中有一行语句:StaticDispatch sr = new StaticDispatch();
,因此 main 方法的栈帧中,局部变量表中存储局部变量是sr,由于栈帧中还包含了动态连接信息,动态连接信息是:指向运行时常量池中该栈帧所属方法的引用。对于这行语句sr.sayHello(man);
执行的时候,就会去字符串常量池中寻找sayHello方法的方法描述符。sayHello方法有一个名称为man的参数,这个名为man的参数是由这条语句定义的Human man = new Man();
,可以看出:名为man的参数声明的类型是Human,并且可从class字节码文件中看出方法描述符的内容是sayHello:(Lorg/hapjin/dynamic/StaticDispatch$Human;)V
,因此,就能根据方法描述符唯一确定调用的方法是public void sayHello(Human guy)
。
再啰嗦一句,这里分析的是方法重载(Overload)而不是方法覆盖(Override),是通过方法描述符来唯一确定具体调用执行哪个方法,这与下面分析的动态分派中 通过invokevirtual 指令运行时解析 来确定执行哪个方法是有区别的。
Human man = new Man();// man 是“语句类型的引用”
public void sayHello(Human 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方法如下:
其中下面这两句方法调用的符号引用是一样的,都是: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,那当然不能构成影响了,但我总觉得这种说法有点勉强,导致费解。
动态分派不仅要看方法接收者的实际类型,也是要看方法的参数类型的,只是编译成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)
方法。
----2018.12.8 更新-----
当虚拟机执行某个方法时,会为这个方法创建栈帧,栈帧在 虚拟机运行进数据区 中的 虚拟机栈 中。栈帧包含四部分内容:局部变量表、操作数栈、动态连接、方法返回地址。局部变量表存储我们在这个方法里面定义的局部变量,比如father这个局部变量。动态连接是:方法调用时 指向运行时常量池中 的方法的引用(其实就是符号引用)。比如在执行语句father.hardChoice(new _360());
时, invokevirtual指令的解析过程的第一步就是:根据动态连接信息,找到变量father的实际类型,在这个实际类型对应的类中找符合hardChoice(new _360())
的 方法符号引用( invokevirtual指令的解析过程的第二、三、四步)
总结一下:虚拟机具体在选择哪个方法执行时:
根据在编译成class字节码文件后就确定了执行哪个方法----解析 or 分派
根据在方法是否由字节码指令 invokevirtual 调用----解析 or 分派(分派调用是由 invokevirtual 指令执行的)
根据方法接收者的静态类型 和 实际类型 ---- 动态分派 or 静态分派
根据宗量个数来 确定具体执行哪个方法----多分派 or 单分派
但总感觉这样划分有点绝对,不太准确。
构思了一个星期的文章,终于完成了。
参考文章:从虚拟机指令执行的角度分析JAVA中多态的实现原理
参考书籍:《深入理解java虚拟机》