方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定要调用哪一个方法,暂时还未涉及方法内部的具体运行过程。但由于 Class 文件的编译过程中是不包含连接步骤的,在编译时,我们并不知道目标方法的具体内存地址,因此 Javac 编译器会暂时用 符号引用 来表示该目标方法。一切方法调用在 Class 文件的常量池里存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址,即 直接引用。

这个特性给 Java 带来了更强大的动态扩展能力,但也使得 Java 方法调用过程变得相对复杂,某些调用需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。

解析

所有方法调用的目标方法在 Class 文件里面都是一个常量池中的 符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为 直接引用,这种解析能够成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。这类方法的调用被称为解析(Resolution)。

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

具体来说,调用不同类型的方法,Java 虚拟机在字节码指令集里设计了五种不同的指令,分别是:

  • invokestatic:用于调用静态方法


  • invokespecial:用于调用构造器 方法、私有实例方法,以及使用 super 关键字调用父类的实例方法或构造器,和所实现接口的默认方法


  • invokevirtual:用于调用非私有实例方法(即可以被重写的方法,也叫虚方法)


  • invokeinterface:用于调用接口方法,会在运行期确定一个实现该接口的子类对象


  • invokedynamic:用于调用动态方法,会先在运行时动态解析出符号引用的方法,然后再执行该方法

对于 invokestatic 以及 invokespecial 而言,Java 虚拟机能够直接识别具体的目标方法,在类加载的解析阶段就可以把符号引用解析为该方法的直接引用。而对于 invokevirtual 及 invokeinterface 而言,通常虚拟机需要在执行过程中根据调用者的动态类型来确定具体的目标方法。唯一的例外在于 final 方法(由于历史原因,final 方法使用 invokevirtual 指令调用),它也是在类加载的解析阶段把符号引用解析为直接引用。

下面演示一种常见的解析引用的例子,可以看到 main() 方法在编译阶段通过 invokestatic 指令调用了 inc() 静态方法,并且其调用的方法版本已经在编译时就明确以常量池项的形式固化在字节码指令的参数之中了。
image.png
对于这个符号引用,如果是一个非接口符号引用,假定该符号引用所指向的类为 C,则 Java 虚拟机会按照如下步骤进行查找:

  • 在 C 中查找符合名字及描述符的方法。如果没有找到,在 C 的父类中继续搜索,直至 Object 类。
  • 如果没有找到,在 C 所直接实现或间接实现的接口中搜索,这一步搜索得到的目标方法必须是非私有、非静态的。如果有多个符合条件的目标方法,则任意返回其中一个。

从这个解析算法可以看出,静态方法也可以通过子类来调用。此外,子类的静态方法会隐藏(注意与重写区分)父类中的同名、同描述符的静态方法。

对于接口符号引用,假定该符号引用所指向的接口为 I,则 Java 虚拟机会按照如下步骤进行查找:

  • 在 I 中查找符合名字及描述符的方法。如果没有找到,在 Object 类中的公有实例方法中搜索。
  • 如果没有找到,则在 I 的超接口中搜索。搜索要求同上。

经过上述的解析步骤之后,符号引用会被解析成实际引用。对于可以静态绑定的方法调用而言,实际引用是一个指向方法的指针。对于需要动态绑定的方法调用而言,实际引用则是一个方法表的索引。

分派

解析调用是一个静态的过程,在编译期间就完全确定,在类加载的解析阶段就会把涉及的符号引用全部转变为明确的直接引用,不必延迟到运行期再去完成。而另一种主要的方法调用形式:分派(Dispatch)调用则要复杂许多,它可能是静态(通过参数的静态类型去重载)也可能是动态(通过动态绑定去判断真正执行方法的对象实例)的,分派调用的过程揭示了面向对象的多态性特征的一些基本体现,如重载和重写在 Java 虚拟机中是如何实现的。下面我们来看看虚拟机中的方法分派是如何进行的。

1. 静态分派与重载

在 Java 程序里,如果同一个类中出现多个名字相同且参数类型相同的方法,那么它无法通过编译。因为在正常情况下,如果我们想要在同一个类中定义名字相同的方法,那么它们的参数类型必须不同。这些方法之间的关系我们称之为重载。

为了解释静态分派(Static Dispatch)和重载(Overload)的关系,我们先看下面这段代码:

  1. public class StaticDispatch {
  2. static abstract class Human {}
  3. static class Man extends Human {}
  4. static class Woman extends Human {}
  5. public void sayHello(Human human) { System.out.println("hello,human!"); }
  6. public void sayHello(Man man) { System.out.println("hello,man!"); }
  7. public void sayHello(Woman woman) { System.out.println("hello,woman!"); }
  8. public static void main(String[] args) {
  9. Human man = new Man();
  10. Human woman = new Woman();
  11. StaticDispatch staticDispatch = new StaticDispatch();
  12. staticDispatch.sayHello(man);
  13. staticDispatch.sayHello(woman);
  14. }
  15. }

输出结果:
image.png
为了弄清虚拟机为什么会选择参数类型为 Human 的重载版本,我们先定义两个关键概念:

  1. Human man = new Man();

我们把上面代码中的 Human 称为变量的 静态类型,后面的 Man 则被称为变量的 实际类型。静态类型和实际类型在程序中都可能会发生变化,区别是静态类型的变化仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译时并不知道一个对象的实际类型是什么。具体解释如下:

实际类型变化:

  1. // 对象human的实际类型是可变的,只有程序运行到这行的时候才能确定
  2. Human human = (new Random()).nextBoolean() ? new Man() : new Woman();

静态类型变化:

  1. // 对象human的静态类型是Human,可以在使用时(如强转)临时改变这个类型,但这个改变仍是在编译期是可知的
  2. staticDispatch.sayHello((Man) human);
  3. staticDispatch.sayHello((Woman) human);

因此,在上面重载的代码示例中。main() 里的两次 sayHello() 方法调用的是哪个重载版本,就完全取决于传入参数的数量和数据类型。而编译器在重载时是通过参数的 静态类型 作为判定依据的,因为静态类型在编译期是可知的,所有依赖 静态类型 来决定方法执行版本的分派动作,都称为静态分派
image.png

重载方法的选取逻辑:

Javac 编译器会根据所传入参数的 静态类型 来选取重载方法,选取过程共分为三个阶段:

  • 在不考虑对基本类型自动装拆箱及可变长参数的情况下选取重载方法
  • 否则在允许自动装拆箱但不允许可变长参数的情况下选取重载方法
  • 最后在允许自动装拆箱及可变长参数的情况下选取重载方法

如果 Javac 编译器在同一个阶段中找到了多个适配的方法,那么它会在多个符合的方法中选择一个最为贴切的方法,而决定贴切程度的一个关键就是静态参数类型的继承关系。除了同一个类中的方法,重载也可以作用于这个类所继承而来的方法。如果子类定义了与父类中非私有方法同名的方法且这两个方法的参数类型不同,那么在子类中这两个方法同样构成了重载。

  1. public static void main(String[] args) {
  2. Father.print(); // print()方法为非私有静态方法
  3. Son.print();
  4. Son son = new Son();
  5. son.print();
  6. // 静态方法还是根据调用者的静态类型确定的,通过invokestatic指令在解析阶段就确定了直接引用
  7. Father father = new Son();
  8. father.print();
  9. Father father2 = new Father();
  10. father2.print();
  11. }

输出结果:
image.png
由于对重载方法的区分在编译阶段已经完成,我们可以认为 Java 虚拟机不存在重载这一概念。但因为某个类中的重载方法可能被它的子类所重写,因此 Java 编译器会将所有对非私有实例方法的调用编译为需要动态绑定的类型。

2. 动态分派与重写

动态分派的实现与 Java 语言多态性的另一个重要体现——重写(Override)有着很密切的关联。Java 虚拟机中关于方法重写的判定同样基于方法描述符,如果子类定义了与父类中非私有、非静态方法同名的方法且这两个方法的参数类型及返回类型一致,Java 虚拟机才会判定为重写。对于 Java 语言中重写而 Java 虚拟机中非重写的情况,编译器会通过生成【桥接方法】来实现 Java 中的重写语义。

还是用前面的 Man 和 Woman 一起 sayHello 的例子来讲解下动态分派:

  1. public class DynamicDispatch {
  2. static abstract class Human {
  3. protected abstract void sayHello();
  4. }
  5. static class Man extends Human {
  6. @Override
  7. protected void sayHello() {
  8. System.out.println("man say hello!");
  9. }
  10. }
  11. static class Woman extends Human {
  12. @Override
  13. protected void sayHello() {
  14. System.out.println("woman say hello!");
  15. }
  16. }
  17. public static void main(String[] args) {
  18. Human man = new Man();
  19. Human woman = new Woman();
  20. man.sayHello();
  21. woman.sayHello();
  22. man = new Woman();
  23. man.sayHello();
  24. }
  25. }

输出结果:
image.png
显然这里选择调用的方法版本不再是根据静态类型来决定的了,那 Java 虚拟机是如何根据对象的实际类型来分派方法执行版本的呢?我们用 javap 命令输出这段代码的字节码:
image.png
0~15 行的字节码是准备动作,作用是建立 man 和 woman 的内存空间、调用 Man 和 Woman 类型的实例构造器,将这两个实例的引用存放在第 1、2 个局部变量表的变量槽中。

1620 行的 aload 指令分别把刚刚创建的两个对象的引用压到栈顶。

1721 行的 invokevirtual 指令用于执行方法调用,这两条调用指令的参数虽然都是常量池中第 6 项的常量,即都是 Human.sayHello(),但这两条指令最终执行的目标方法却并不相同。于是进一步分析 invokevirtual 指令是如何确定调用方法版本、如何实现多态查找的。

根据《Java 虚拟机规范》,invokevirtual 指令的运行时解析过程大致分为以下几步:

  • 找到操作数栈顶的第一个元素所指向的对象的【实际类型】,记作 C。


  • 如果在类型 C 中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找结束;不通过则返回 java.lang.IllegalAccessError 异常。


  • 否则,按照继承关系从下往上依次对 C 的各个父类进行第二步的搜索和验证过程。


  • 如果始终没有找到合适的方法,则抛出 java.lang.AbstractMethodError 异常。

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

如果子类定义了与父类中非私有方法同名的方法,且这两个方法的参数类型相同的话。如果这两个方法都是静态的,那么子类中的方法隐藏了父类中的方法。如果这两个方法都不是静态的且都不是私有的,那么子类的方法重写了父类中的方法。

注意事项:

由于这种多态性的根源在于虚方法调用指令 invokevirtual 的执行逻辑,且这个指令只会对方法有效,对字段是无效的,因此字段永远不可能是虚的,即字段永远不参与多态。当子类声明了与父类同名的字段时,虽然在子类的内存中两个字段都会存在,但是子类的字段会遮蔽父类的同名字段。

  1. public class FieldHasNoPolymorphic {
  2. static class Father {
  3. public int money = 1;
  4. public Father() {
  5. money = 2;
  6. showMeTheMoney();
  7. }
  8. public void showMeTheMoney() {
  9. System.out.println("I am Father, i have $" + money);
  10. }
  11. }
  12. static class Son extends Father {
  13. public int money = 3;
  14. public Son() {
  15. money = 4;
  16. showMeTheMoney();
  17. }
  18. public void showMeTheMoney() {
  19. System.out.println("I am Son, i have $" + money);
  20. }
  21. }
  22. public static void main(String[] args) {
  23. Father gay = new Son();
  24. System.out.println("This gay has:" + gay.money);
  25. }
  26. }

输出结果:
image.png
Son 类在创建时首先隐式调用了 Father 的构造函数,而 Father 构造函数中对 showMeTheMoney() 的调用是一次虚方法调用,实际执行的是 Son::showMeTheMoney(),此时虽然父类的 money 已经被初始化成 2 了,但 Son::showMeTheMoney() 中访问的是子类的 money,结果自然还是 0,因为它要等子类构造函数执行时才会被初始化。而 main() 的最后一句通过静态类型访问到了父类中的 money,所以输出 2。

3. 动态分派的实现

动态分派是执行非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在接收者类型的方法元数据中搜索合适的目标方法,因此,Java 虚拟机实现基于执行性能的考虑,真正运行时一般不会如此频繁地去反复搜索类型元数据。

3.1 方法表

面对这种情况,Java 虚拟机采取了一种空间换时间的策略来实现动态分派。在类加载的准备阶段,虚拟机除了为静态字段分配内存外,还会在方法区中构造该类的 方法表 来代替元数据查找以快速定位目标方法。

方法表具体可以分为:

  • 虚方法表(Virtual Method Table):invokevirtual 指令所使用的方法表
  • 接口方法表(Interface Method Table):invokeinterface 指令所使用的方法表

方法表是 Java 虚拟机实现动态绑定的关键所在,本质上是一个数组,每个数组元素指向一个当前类及其父类中非私有的实例方法。这些方法可能是具体的、可执行的方法,也可能是没有相应字节码的抽象方法。方法表满足下面两个特质:

  • 子类方法表中包含父类方法表中的所有方法
  • 子类方法在方法表中的索引值,与它所重写的父类方法的索引值相同。

我们知道,方法调用指令中的符号引用会在执行之前解析成实际引用。对于静态绑定的方法调用,实际引用将指向具体的目标方法。对于动态绑定的方法调用,实际引用则是方法表的索引值。方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也一同初始化完毕。在执行过程中 Java 虚拟机将获取调用者的实际类型并在该实际类型的虚方法表中,根据索引值获得目标方法。

代码示例:

  1. abstract class Passenger {
  2. abstract void passThroughImmigration();
  3. @Override
  4. public String toString() { ... }
  5. }
  6. class ForeignerPassenger extends Passenger {
  7. @Override
  8. void passThroughImmigration() { /* 进外国人通道 */ }
  9. }
  10. class ChinesePassenger extends Passenger {
  11. @Override
  12. void passThroughImmigration() { /* 进中国人通道 */ }
  13. void visitDutyFreeShops() { /* 逛免税店 */ }
  14. }

该方法对应的方法表如下:
image.png
图中 Passenger 类的方法表包括两个方法:toString 和 passThroughImmigration,分别对应方法表中的 0 号和 1 号位。之所以方法表调换了 toString 和 passThroughImmigration 的位置,是因为 toString 的索引值需要与 Object 类中同名方法的索引值一致。为了保持简洁,这里就不考虑 Object 类中的其他方法了。

ForeignerPassenger 的方法表同样有两行。其中 0 号方法指向继承而来的 Passenger 类的 toString 方法。1 号方法则指向自己重写的 passThroughImmigration 方法。

ChinesePassenger 的方法表则包括三个方法,除了继承而来的 Passenger 类的 toString 方法,自己重写的 passThroughImmigration 方法之外,还包括独有的 visitDutyFreeShops 方法。

当我们要执行如下代码时:

  1. Passenger passenger = ...
  2. passenger.passThroughImmigration();

这里,Java 虚拟机会先访问栈上的调用者 passenger,读取调用者的动态类型,再读取该类型的方法表,最后读取方法表中某个索引值所对应的目标方法进行方法调用(因为在它的静态类型中该方法的索引值为 1,所以这里直接用 1 作为索引来查找方法表所对应的目标方法)。

实际上,使用了方法表的动态绑定与静态绑定相比,仅仅多出几个内存解引用操作:访问栈上的调用者,读取调用者的动态类型,读取该类型的方法表,读取方法表中某个索引值所对应的目标方法。相对于创建并初始化 Java 栈帧来说,这几个内存解引用操作的开销简直可以忽略不计。

那么我们是否可以认为虚方法调用对性能没有太大影响呢?

其实是不能的,上述优化的效果看上去十分美好,但实际上仅存在于解释执行中,或者即时编译代码的最坏情况中。这是因为即时编译还拥有另外两种性能更好的优化手段:内联缓存(inlining cache)和方法内联(method inlining)。

3.2 内联缓存

Java 虚拟机中的即时编译器会使用内联缓存来加速动态绑定。它能够缓存虚方法调用中调用者的动态类型,以及该类型所对应的目标方法。在之后的执行过程中,如果碰到已缓存的类型,内联缓存便会直接调用该类型所对应的目标方法。如果没有碰到已缓存的类型,内联缓存则会退化至使用基于方法表的动态绑定。

在上面的例子中,这相当于虚拟机记住了上一个调用该方法的是 ChinesePassenger 对象实例,当下一次调用这个方法时,虚拟机会先判断调用者的动态类型还是不是 ChinesePassenger,如果是则直接调用已缓存的目标方法。如果不是才会去查询方法表,获取方法索引所指向的目标方法。

内联缓存通常可分为:单态内联缓存、多态内联缓存 和 超多态内联缓存。单态内联缓存,顾名思义,便是只缓存了一种动态类型以及它所对应的目标方法。它的实现非常简单:比较所缓存的动态类型,如果命中,则直接调用对应的目标方法。多态内联缓存则缓存了多个动态类型及其目标方法。它需要逐个将所缓存的动态类型与当前动态类型进行比较,如果命中,则调用对应的目标方法。一般来说,我们会将更加热门的动态类型放在前面。

在实践中,大部分的虚方法调用均是单态的,也就是只有一种动态类型。为了节省内存空间,Java 虚拟机只采用单态内联缓存。当内联缓存没有命中的情况下,Java 虚拟机需要重新使用方法表进行动态绑定。而对于内联缓存中的内容,我们有两种选择:

一是替换单态内联缓存中的纪录。这种做法对数据的局部性有要求,即在替换内联缓存之后的一段时间内,方法调用的调用者的动态类型应当保持一致,从而能够有效地利用内联缓存。因此,在最坏情况下,如果我们用两种不同类型的调用者,轮流执行该方法调用,那么每次进行方法调用都将替换内联缓存。这会导致内联缓存不断被更新,但却没有命中缓存。

另外一种选择则是劣化为超多态内联缓存。处于这种状态下的内联缓存,实际上放弃了优化的机会。它将直接访问方法表,来动态绑定目标方法。与替换内联缓存纪录的做法相比,它牺牲了优化的机会,但是节省了写缓存的额外开销。

虽然内联缓存附带内联二字,但是它并没有内联目标方法。这里需要明确的是,任何方法调用除非被内联,否则都会有固定开销。这些开销来源于保存程序在该方法中的执行位置,以及新建、压入和弹出新方法所使用的栈帧。对于简单的 getter/setter 方法而言,这部分固定开销占据的 CPU 时间甚至超过了方法本身。此外,在即时编译中,方法内联不仅仅能够消除方法调用的固定开销,而且还增加了进一步优化的可能性。