【java虚拟机系列】从java虚拟机字节码执行引擎的执行过程来彻底理解java的多态性

我们知道面向对象语言的三大特点之一就是多态性,而java作为一种面向对象的语言,自然也满足多态性,我们也知道java中的多态包括重载与重写,我们也知道在C++中动态多态是通过虚函数来实现的,而虚函数是通过一个虚函数表来完成的,这也很好理解,那么java语言的多态性是怎么实现的呢?在java中是否也存在类似C++中的虚函数表的结构呢?这就需要我们从java虚拟机字节码执行引擎的执行过程来找答案了,下面就从java虚拟机字节码执行引擎的执行过程带领大家彻底理解java中的多态性。

通过前面的【java虚拟机系列】java虚拟机系列之JVM总述,我们知道java的运行时数据区中包含一个叫做java栈的区域,该区域的作用就是用来描述java方法调用的执行模型。

我们也知道在每个方法执行时会创建一个叫做栈帧的数据结构用来描述当前方法的一些信息。这些信息主要包括方法的局部变量表,操作数栈,动态链接和方法的返回地址等信息。用图表示如下:

《【java虚拟机系列】从java虚拟机字节码执行引擎的执行过程来彻底理解java的多态性》

其中的动态连接部分就是用来支持多态性而存在的,我们知道java中的Class文件的常量池中存在大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转换称为静态解析,另一种将在每次运行时转换为直接引用,这种称为动态连接。下面详细讲解这两种情况。

一方法的解析调用

前面讲过java中的方法调用的目标方法在Class文件中都只是一个常量池中的符号引用,在类加载的解析阶段(关于类加载的知识请参看我的博客:http://blog.csdn.net/htq__/article/details/50990939),会将一部分符号引用转换为直接引用(即最终调用的方法的信息字段),前提条件是在程序运行之前就能够确定调用那个版本的方法,且在运行期间不可更改,满足这两个条件的方法主要包括:静态方法和私有方法。因为静态方法属于整个类的,与类型直接相关联,在申明的时候是哪个类则调用的是哪个类的静态方法,而私有方法对外部不可访问,因此不可能存在重写的其它版本,因此这两种方法在类的加载阶段就进行解析。下面这段代码很好的说明了这点。

class Base{  
        static void fun(){System.out.println("base");  }  
                 }  
}  
public class  Inherit extends Base{  

          static void fun(){//注意此时子类不是重写基类的fun函数,而是隐藏了基类的fun函数
			   //因为被static修饰
		System.out.println("inherit"); 
           }  
                 }  
           public static void main(String args[]){  
                    Base x=new Base();  
                    Base y=new Inherit();  
                    x.fun();  //因为fun函数被static修饰,所以在类的加载阶段就完成解析
                    y.fun();  
         }  
}  

程序的运行结果为:

base

base

程序的运行结果也说明了static方法在类的加载阶段就完成了解析,因为x与y的申明类型都为Base类型,所以调用的fun函数都为Base的static的fun函数。

解析调用在编译期完全确定,在类装载的解析阶段就会把相关的符号引用全部转换为可确定的直接引用,不会延迟到运行期再去确定。

另外final方法也是唯一确定的,这个很好理解因为final表示不可以被重写,因此只可能存在一个版本。

二方法的分派调用

分派调用包括静态分派与动态分派,下面一一讲解。

  1静态分派(重载)

首先我们来看一段代码,读者可以先预测一下这段代码的输出结果。

public class StaticDispatch {

	static abstract class People {
	}

	static class Boy extends People {
	}

	static class Girl extends People {
	}

	public void sayHello(People guy) {
		System.out.println("hello,guy!");
	}

	public void sayHello(Boy guy) {
		System.out.println("hello,boy!");
	}

	public void sayHello(Girl guy) {
		System.out.println("hello,girl!");
	}

	public static void main(String[] args) {
		People boy = new boy();//boy为子类的上转型对象
		People girl = new Girl();//girl为子类的上转型对象
		StaticDispatch sr = new StaticDispatch();
		sr.sayHello(boy);
		sr.sayHello(girl);
	}
}

输出结果为:

hello,guy!

hello,guy!

输出结果在我们的预料之中,那么为何输出结果是这个呢?或者说java中的重载调用的规则是怎样的呢?

首先我们解释两个概念:外观类型(Apparent Type)与实际类型(Actual Type),这两个概念在上转型对象中经常用到,如People boy = new Boy();//boy为子类的上转型对象。我们把People叫做引用boy的外观类型,而Boy则是实际类型。这两者之间的不同之处就是外观类型在编译期就已经可知,而实际类型在运行期间在可以确定,编译器在编译期间可能不知道一个对象的实际类型是啥。


之所以输出结果如上所示,是因为虚拟机(更确切的说是编译器)在重载时是通过参数的外观类型而不是实际类型来作为重载调用的判定依据的,外观类型是在编译期可知的,因此,在编译阶段,javac编译去会根据参数的外观类型来确定调用哪个重载版本,所以最终都是选择的sayHello(People guy) 这个重载版本。


2动态分派(重写)

首先解释一下java中的动态多态性:让父类的引用指向不同的子类对象时(即上转型对象),上转型对象调用方法实际调用的是子类重写的方法。我们仍然以上述代码为例进行讲解。

public class DynamicDispatch {

	static abstract class People {
		protected abstract void sayHello();
	}

	static class Boy extends People {
		@Override
		protected void sayHello() {
			System.out.println("boy say hello");
		}
	}

	static class Girl extends People {
		@Override
		protected void sayHello() {
			System.out.println("girl say hello");
		}
	}

	public static void main(String[] args) {
		People boy = new Boy();//boy为Boy的上转型对象
		People girl = new Girl();//girl为Girl的上转型对象
		boy.sayHello();
		girl.sayHello();
		boy = new Girl();//更改boy的实际类型,让其指向Girl对象
		boy.sayHello();
	}
}

程序输出结果为:

boy say hello

girl say hello

girl say hello

同样输出结果在我们的预料之中,那么为何输出结果是这个呢?或者说java中的重写调用的规则是怎样的呢?

这就涉及到java字节码执行引擎中的invokevirtual指令,invokevirtual指令的多态性查找过程如下所示:

1找到操作数栈顶的第一个元素(即调用方法的外观类型)所指向对象的实际类型,即为T。

2如果在类型T中找到了与常量中的描述符和简单名称都相符的方法(即子类重写了父类的方法),则进行访问权限检验,如果通过则返回这个方法的直接引用(即将会调用子类中重写的方法),查找过程结束,如果不通过,则抛出java.lang.IllegalAccessError异常。

3如果在步骤2中没找到(即子类的实际类型没重写父类的方法),则按照继承关系从下往上对T的各个父类进行第2步的搜索和验证过程。

4如果始终没找到合适的方法(即实际类型T和其间接父类都没重写其超基类的方法),则会抛出java.lang.AbstractMethodError异常。

从上述过程可以看到,虽然压入操作数栈的仍然是外观类型,但是在调用重写方法时invokevirtual指令会在运行期间确定调用者的实际类型,所以两次调用invokevirtual指令会把常量池中的类方法的符号引用解析到了不同的直接引用上(一次为Boy一次为Girl),这样调用到的就是子类重写的父类的方法。

三虚拟机是如何实现的动态分派


在开头我们讲过在C++中动态调用是通过虚函数表来实现的,其实在java中也是这么做的,因为动态分派是非常频繁的操作,且动态分派方法的选择需要在程序运行期间动态的确定,因此虚拟机在实现时基于性能的考虑,不会像上述介绍的那样在运行时去搜索应该调用那个重写版本,而是在类的方法区建立一个虚方法表(Virtual Method Table 简称vtable,与此对应的实现接口重写的方法会用到接口方法表(Interface Method Table 简称itable))i,使用虚方法表的索引来代替元数据的查找从而提高性能。那么虚方法表中存储哪些数据呢?

虚方法表中存放着各个方法的实际调用的入口地址。如果某个方法在子类中没被重写,那么子类的虚方法表中地址入口与父类相同方法的入口地址是同一个地址,如果子类重写了父类中的方法,那么子类的虚方法表中地址将会替换为指向子类实现的重写的函数的入口地址。

通常具备相同签名的方法,在父类与子类的虚方法表中具备相同的索引号。方法表一般在类加载的连接阶段进行初始化,在准备了类的变量初始值之后,虚拟机会把类的虚方法表初始化完毕。

以上就是本博客的主要内容,如果读者觉得不错,记得小手一抖,点个赞哦!另外欢迎大家关注我的博客账号哦,将会不定期的为大家分享技术干货,福利多多哦!

    原文作者:欢迎关注我的公众号huqi_tech
    原文地址: https://blog.csdn.net/htq__/article/details/51747121
    本文转自网络文章,转载此文章仅为分享知识,如有侵权,请联系博主进行删除。
点赞