Java方法调用——解析与分派

前言

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

解析调用一定是个静态的过程,在编译期间就完全确定,在类加载的解析阶段就会把设计的符号引用全部转换为可确定的直接引用,不会延迟到运行期间再去完成。而分派调用则可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派和多分派。这两类分派方式的两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派4中分派组合情况。

解析

所有方法调用中的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个调用版本在运行期间是不可改变的。换句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来,这类方法的调用称为解析。

在Java语言中符合“编译器可知,运行期不可变”这个要求的方法,主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。

Java虚拟机中提供了5条方法调用字节码指令,分别是:

  • invokestatic:调用静态方法
  • invokespecial:调用实例构造器< init >方法,私有方法和父类方法
  • invokevirtual:调用所有的虚方法
  • invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象
  • invokedynamic:现在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的四条调用指令,分派逻辑是固化在Java虚拟机内部的,而invokedynamic指令的分派逻辑是由用户所设定的引导方法决定的

只要能被invokestatic和invokespecial指令调用的方法,都可以在解析阶段确定唯一的调用版本,符合这个条件的静态方法、私有方法、实例构造器、父类方法4类,它们在类加载的时候就会把符号引用解析为该方法的直接引用,这些方法可以被称为非虚方法,与之相反,其他方法称为虚方法(除去final方法)。

Java中的非虚方法除了使用invokestatic、invokespecial调用方法之外还有一种,就是被final修饰的方法,虽然final方法是使用invokevirtual指令来调用的,但是由于它无法被覆盖,没有其它版本,所以也无须对方法接受者进行多态选择,又或者说多态选择的结果肯定是唯一的,在Java语言规范中明确说明了final方法是一种非虚方法。

分派

Java是一门面向对象的程序语言,因为Java具有面向对象的3个基本特征:继承、封装和多态。分派调用过程将会揭示多态性的一些最基本的体现,如重载和重写在Java虚拟机之中是如何实现的。

1、静态分派

静态分派的典型应用是重载,重载根据参数的静态类型而不是实际类型作为判断依据。而静态类型在编译器可知,所以静态分派发生在编译阶段。另外虽然编译器能确定方法的重载版本,但是咋爱很多情况下重载版本不唯一,往往只能确定一个更加合适的版本,主要原因是字面量作为参数传入是没有显式的静态类型的,只能选择一个最贴近该字面量类型的方法。

关于分派与解析的关系并不是排他关系,而是在不同层次上去筛选,确定目标方法的过程。静态方法在类加载时就进行解析,而静态方法显然也是可以拥有重载版本的,选择重载版本也是通过静态分派来完成的。

2、动态分派

动态分派的典型应用是重写,运行期间方法接受者的实际类型来选择方法。invokevirtual指令的工作过程是,优先寻找当前类中是否有该方法,如有直接选择该方法,若没有找到,则在父类中寻找,直到找到为止。

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

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

由于invokevirtual指令执行的第一步就是在运行期确定接受者的实际类型,所以两次调用中的invokevirtual指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java语言中方法重写的本质,我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。

3、单分派和多分派

方法的接收者与方法的参数统称为方法的宗量,根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。

编译阶段的选择过程,也就是静态分派的过程,这时选择目标方法的依据有两点:一是静态类型,二是方法的参数,这次选择的结果最终产物是产生了两条invokevirtual指令,两条指令的参数分别为常量池中指向重载方法的符号引用,因为是根据两个宗量进行选择,所以Java语言的静态分派属于多分派类型。

运行阶段虚拟机的选择,也就是动态分派的过程,在执行代码所对应的invokevirtual指令时,由于编译期已经决定目标方法的签名,所以虚拟机此时不关心传递过来的参数,因为这时参数的静态类型、实际类型都对方法的选择不会构成任何影响,唯一可以影响虚拟机选择的因素只有此方法的接受者的实际类型,因为只有一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型。

虚拟机动态分派的实现

由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正地进行如此频繁的搜索。面对这种情况,最常用的稳定优化手段就是为类在方法区建立一个虚方法表,使用虚方法表索引来代替元数据查找以提高性能。

虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。方法表是在类加载的连接阶段(验证、准备、解析)进行初始化,准备了子类的初始化值后,虚拟机会把该类的虚方法表也进行初始化。

方法表是分派调用的稳定优化手段,虚拟机除了使用方法表之外,在条件允许的情况下,还会只用内联缓存和属于典型集成关系分析技术的守护内联两种非稳定的激进优化手段来获得更高的性能,关于这两种优化技术的原理和运作过程,可以参考JIT晚期运行期。