从虚拟机指令执行的角度分析JAVA中多态的实现原理
前几天突然被一个“家伙”问了几个问题,其中一个是:JAVA中的多态的实现原理是什么?
我一想,这肯定不是从语法的角度来阐释多态吧,隐隐约约地记得是与Class文件格式中的方法表有关,但是不知道虚拟机在执行的时候,是如何选择正确的方法来执行的了。so,趁着周末,把压箱底的《深入理解Java虚拟机》拿出来,重新看了下第6、7、8章中的内容,梳理一下:从我们用开发工具(Intellij 或者Eclipse)写的 .java 源程序,到经过javac 编译成class字节码文件,再到class字节码文件被加载到虚拟机并最终根据虚拟机指令执行选择出正确的(多态)方法执行的整个过程。
在讨论的多态(一般叫运行时多态)的时候,不可避免地要和重载(Overload)进行对比,为什么呢?因为这涉及到一种方法调用方式----分派(分派这个名字来源于 深入理解Java虚拟机 第8章8.3.2节)
先从源代码(语法)的角度看看二者的区别:
重载(Overload)
-
重写(Override),或者叫运行时多态,这是本文主要要讨论的内容。
先来看看重载,(代码来源于书中)
public class StaticDispatch {
static abstract class Human {}
static class Man extends Human{}
static class Woman extends Human{}
public void sayHello(Human guy) {
System.out.println("hello, guy");
}
public void sayHello(Man guy) {
System.out.println("hello, gentleman");
}
public void sayHello(Woman guy) {
System.out.println("hello, lady");
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
StaticDispatch sr = new StaticDispatch();
sr.sayHello(man);//hello, guy
sr.sayHello(woman);//hello, guy
}
}
从语法的角度来聊一聊为什么上面的三个sayHello方法是重载的。方法之间是重载的要求这些方法具有:相同的简单名称 和 不同的特征签名。
- 方法的简单名称是:没有类型和参数修饰的方法名称。比如上面的sayHello方法的简单名称就是 字符串"sayHello"
- 方法的特征签名:可简单粗暴地理解成方法的 参数类型、参数顺序、参数个数。
对于上面的三个sayHello方法而言,它们的简单名称是相同的,而参数类型不同(一个是Human 类型、一个是Woman类型、一个是Man类型),因此:它们是重载的。
额外多补充一点:
上面并没有提到方法的返回值,因为方法的返回值并不属于特征签名。
当你在编辑器,比如IDEA或者Eclipse 中写了两个 简单名称相同、特征签名也相同、但是方法返回值不同的两个方法时,会报编译错误:“定义了两个同名的方法”。但是,这两个“同名的方法”是可以共存于同一个class文件中的。因为class文件格式规定了:描述符不完全一致的两个方法可以共存于同一个class文件中。
那什么是方法的描述符呢?
每个人编写代码的时候,给方法定义一个方法、给方法取个名字、带上参数……写出来的方法的无穷无尽的,如何用一套统一的规则来描述这些写出来的方法,就是方法描述符干的事情。
用描述符来描述方法时,先按照参数列表,后返回值的顺序描述,参数列表按照参数的严格顺序放在一组小括号内
从上面的描述符定义看,方法的描述符是包含了方法的返回值的。因此, 简单名称相同、特征签名也相同、但是方法返回值不同的两个方法可共存于同一个class文件中。
总结一下,讨论一个方法是否“相同”,这里涉及到了三个概念:
- 简单名称
- 方法特征签名
- 方法描述符
我的理解,也许不准确:简单名称和特征签名在语法层面 来判断 编写的两个方法是否 是相同的;方法描述符在字节码层面 来判断 两个方法是否是 相同的;方法描述符不仅包含了特征签名、还包含了方法返回值。搞明白这三者的区别及作用就好了。
这个时候,你可能就有疑问了:既然简单名称相同、特征签名也相同、但是方法返回值不同的两个方法可共存于同一个class文件中,在jvm在执行代码(这两个方法)的时候怎么办呢?其实不用担心,在类加载的时候,有一个验证阶段,验证阶段包含了一个叫元数据验证的过程,元数据验证过程会验证 加载到内存方法区里面的class字节码是否符合方法重载的规则。因此,虽然这两个方法共存于同一个class文件中,但这种不符合java语义(语言规范)的情形 最终在验证阶段会被检查出来的。
再来看看重写(Override)
public class DynamicDispatch {
static abstract class Human{
protected abstract void sayHello();
}
static class Man extends Human{
@Override
protected void sayHello() {
System.out.println("man say hello");
}
}
static class Woman extends Human{
@Override
protected void sayHello() {
System.out.println("woman say hello");
}
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
man.sayHello();//man say hello
woman.sayHello();//woman say hello
}
}
在StaticDispatch.java 中,并不存在子类方法、父类方法。只有StaticDispatch.java的sayHello方法,即:sayHello 方法的接收者都是 StaticDispatch sr 对象,需要根据sayHello方法的参数类型来确定,具体执行下面这三个方法中的哪一个方法:
public void sayHello(Human guy) {
System.out.println("hello, guy");
}
public void sayHello(Man guy) {
System.out.println("hello, gentleman");
}
public void sayHello(Woman guy) {
System.out.println("hello, lady");
}
而在DynamicDispatch.java中,首先有一个父类Human,它有一个sayHello方法,然后有两个子类:Woman、Man,它们分别@Override 了父类中的sayHello方法,也就是说:子类重写了父类中的方法。
上而就是从(源代码)语法的角度 描述了一下 重载(Overload) 和 重写(Override 或者叫运行时多态)的区别。程序要想执行,先要将源代码编译成字节码文件。
接下来看一下,二者在字节码文件上的不同
首先javac 命令将 StaticDispatch.java 和 DynamicDispatch.java编译成 class文件,然后使用分别使用下面命令输出这两个文件字节码的内容:
javap -verbose StaticDispatch
(图一)
上面截取的是 StaticDispatch.java main方法中的方法表中的内容。方法表的结构 可参考书中第6.3.6小节的描述。
main方法中的代码,经过编译器编译成字节码指令后,存放在方法属性表集合中的一个名为 "Code" 的属性里面。
StaticDispatch 的main方法 字节码的执行过程
上面的 序号26 和 序号31 红色方框标出来的内容叫做:方法的符号引用,从而可以判断:sr.sayHello(man);
和 sr.sayHello(woman);
是由 invokevirtual指令执行的。
而且方法的符号引用都是:Method sayHello:(Lorg/hapjin/dynamic/StaticDispatch$Human;)V
好,那咱就来看看,invokevirtual指令的具体执行过程,看它是如何将符号引用 解析到 具体的方法上的。
因为,覆盖(Override)或者说运行时多态也是通过invokevirtual指令来选择具体执行哪个方法的,因此:invokevirtual指令的解析过程 可以说是JAVA中实现多态的原理吧。
invokevirtual指令的解析过程大致分为以下几个步骤:
1. 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C
2. 如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessError异常。
3. 否则,按照继承关系从下往上依次对C的各个父类进行第2步的搜索和验证过程。
4. 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。
因此,第一步,找到操作数栈顶的第一个元素所指的对象的实际类型,这个对象其实就是方法接收者的实际类型,它是StaticDispatch对象sr StaticDispatch sr = new StaticDispatch()
为什么是sr对象呢?比如对于序号26的invokevirtual指令,序号24、25行的两条aload_3 和 aload_1字节码指令 分别是把第四个引用类型的变量推送到栈顶,把第二个引用类型的变量推送到栈顶。而第四个引用类型的变量是StaticDispatch sr 对象;第二个引用类型的变量则是Man类的对象Human man = new Man()
第二步,根据常量 Method sayHello:(Lorg/hapjin/dynamic/StaticDispatch$Human;)V
寻找 StaticDispatch类中哪个方法的简单名称和描述符都与该常量相同。
常量Method sayHello:(Lorg/hapjin/dynamic/StaticDispatch$Human;)V
的简单名称是 'sayHello',描述符信息是:返回的类型为空,参数类型为Human,只有一个参数。
而在StaticDispatch.java中一共有三个不同的sayHello方法,它们的简单名称都是'sayHello',而描述符中的参数类型为'Human'类型的方法是:
public void sayHello(Human guy) {
System.out.println("hello, guy");
}
因此,sr.sayHello(man);
实际调用的方法就是上面的public void sayHello(Human guy)
方法。
同样地,sr.sayHello(woman);
的方法接收者的实际类型是StaticDispatch对象sr,由序号31可知方法常量还是Method sayHello:(Lorg/hapjin/dynamic/StaticDispatch$Human;)V
,因此,实际调用的方法还是public void sayHello(Human guy)
从这里可看出:对于重载(Overload)而言,它的方法接收者的类型是相同的,那调用哪个重载方法就取决于:传入的参数类型、参数的数量等。而参数类型在编译器生成字节码的时候就已经确定了,比如上面的sayHello方法的参数类型都是Human(sayHello:(Lorg/hapjin/dynamic/StaticDispatch$Human;)V
)
因此,sr.sayHello(man);
和sr.sayHello(woman);
执行的是相同的方法public void sayHello(Human guy){}
。
接下来看看:覆盖(Override),也即运行时多态的执行情况:
javap -verbose DynamicDispatch
(图二)
上面截取的是DynamicDispatch.java的main方法的执行过程。从序号17和21 可知:man.sayHello();
和woman.sayHello();
也都是由虚拟机指令invokevirtual指令执行的,并且调用的sayHello方法的符号引用都是Method org/hapjin/dynamic/DynamicDispatch$Human.sayHello:()V
那为什么最终执行的结果却是:man.sayHello()
输出 'man say hello',而woman.sayHello()
输出'woman say hello'呢?
man.sayHello();//man say hello
woman.sayHello();//woman say hello
下面再来过一遍invokevirtual指令的执行过程。当虚拟机执行到man.sayHello()
这条语句时,invokevirtual指令第一步:找到操作数栈顶的第一个元素,这个元素就是序号7 astore_1存进去的,它是一个Man类型的对象。
接下来,第二步,在 Man 类中寻找与常量中描述符和简单名称都相符的方法,在这里常量是Method org/hapjin/dynamic/DynamicDispatch$Human.sayHello:()V
,而Man 类中与该常量的描述符和简单名称都相符的方法,显然就是 Man 类中的sayHello方法了。
于是invokevirtual指令就把 常量池中的类方法符号引用 解析 到了 具体的Man类的sayHello方法的直接引用上。
同理,类似地,在执行woman.sayHello()
这条语句时,invokevirtual指令找到的操作数栈顶的第一个元素是由 指令15astore_2存储进去的Woman类型的对象。于是,在Woman类中 寻找与常量池类方法的符号引用Method org/hapjin/dynamic/DynamicDispatch$Human.sayHello:()V
都相符的方法,这个方法就是Woman类中的sayHello方法。
从上面的invokevirtual指令的执行过程看,语句man.sayHello();
和woman.sayHello();
对应的类方法的符号引用是一样的,都是org/hapjin/dynamic/DynamicDispatch$Human.sayHello:()V
,但由于方法接受者的实际类型不同,一个是Man类型、另一个是Woman类型,因为最终执行的方法也就不一样了。
文中涉及到的一些额外的概念:
- 方法接收者:sr.sayHello(new Man()), sr 对象就是 sayHello方法的接收者
- 常量:常量池中的常量,可参考常量池中的项目类型
- 描述符:用来描述字段的数据类型,方法的参数列表和返回值,方法的参数列表指的是:方法有多少个参数、方法的参数是什么类型、参数的顺序
- 简单名称:没有类型和参数修饰的方法或者字段名称。比如说方法:
public void m(String a){}
,那简单名称就是 m
总之,jvm在判断具体执行哪个方法时,不仅要看方法的描述符(特征签名),而且要看方法的接收者的实际类型。而在多态中,从上面的示例中可以看出:方法的接收者的类型是不同的
以上纯个人理解,有些概念可能表述地不太严谨,若有错误,望指正,感激不尽。
写完这篇文章,我抬头望向窗外,天又黑了。目光缓缓移回到电脑屏幕上,一个技术人的追求到底是什么?我应该往哪个方向深入下去呢?后台、算法、ML、或者高大上的DL?
于是又想起了上一次的对话中那个人说的:关键是看你能不能持续地花时间把背后的原理搞清楚。
参考书籍:《深入理解JVM虚拟机》