深入理解Java虚拟机:(三)JVM是如何执行方法调用的?(上)

一、前言

上一篇我们主要介绍了类加载的机制,这一篇我们主要来介绍下Java虚拟机是如何执行方法调用的?我们前面讲过,Class 文件的编译过程中不包含传统编译中的连接步骤,一切方法调用在 Class 文件里面存储的都只是符号引用,而不是方法在实际运行中的入口地址(相当于之前说的直接引用)。这个特性给 Java 带来了强大的动态扩展能力,但也使得 Java 方法调用过程变得相对复杂起来,需要在类加载期间,甚至运行期间才能确定目标方法的直接引用。

二、解析

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

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

Java 字节码中与调用字节码的指令共有五种。

  1. invokestatic:用于调用静态方法。
  2. invokespecial:用于调用私有实例方法、构造器,以及使用 super 关键字调用父类的实例方法或构造器,和所实现接口的默认方法。
  3. invokevirtual:用于调用非私有实例方法。
  4. invokeinterface:用于调用接口方法。
  5. invokedynamic:用于调用动态方法。(先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法)

前 4 条指令,分派逻辑是固化在 Java 虚拟机内部的,而 invokedynamic 指令的分派逻辑是有用户所设定的引导方法决定的。

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

public class StaticResolution {
    public static void main(String[] args) {
        StaticResolution.sayHello();
    }

    private static void  () {
        System.out.println("hello jvm");
    }
}

先通过 javac.exe StaticResolution.java 先编译完。

javac.exe StaticResolution.java

然后使用 javap 命令查看这段程序的字节码,会发现的确是通过 invokestatic 命令来调用 sayHello() 方法的。

javap -verbose StaticResolution
public static void main(java.lang.String[]);
  descriptor: ([Ljava/lang/String;)V
  flags: ACC_PUBLIC, ACC_STATIC
  Code:
    stack=0, locals=1, args_size=1
       0: invokestatic  #2                  // Method sayHello:()V
       3: return
    LineNumberTable:
      line 5: 0

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

解析调用一定是个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部变为可确定的直接引用,不会延迟到运行期再去完成。而分派(Dispatch) 调用则可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派和多分派。下面我们再看看虚拟机中的方法分派是如何进行的。

三、分派

分派调用过程会揭示多态性特征的一些最基本的体现,如 “重载” 和 重写” 在 Java 虚拟机之中是如何实现的,我们来看看虚拟机是如何确定正确的目标方法。

1、静态分派

我们先来看一段代码,后续我们将围绕这个类的方法来重载(Overload)代码,以分析虚拟机和编译器确定方法版本的过程。

package com.jvm;

/**
 * 方法静态分派
 */
public class StaticDispatch {

    static abstract class Human {}

    static class Man extends Human {}

    static class Woman extends Human {}

    public void sayHello (Human human) {
        System.out.println("hello human");
    }

    public void sayHello (Man man) {
        System.out.println("hello man");
    }

    public void sayHello (Woman woman) {
        System.out.println("hello woman");
    }

    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        StaticDispatch sd = new StaticDispatch();
        sd.sayHello(man);
        sd.sayHello(woman);
    }
}

运行结果:

hello human
hello human

我们把上面代码中的 “Human” 称为变量的静态类型(Static Type),后面的 “Man” 则称为变量的实际类型(Actual Type),静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译器可知的,而实际类型在运行期才可确定。

解释了这两个概念,我们来看 main() 里面两次调用 sayHello() ,在方法接收者已经确定是对象 “sd” 的前提下,使用哪个重载版本,就完全取决于传入参数的数量和类型。代码中刻意的定义了两个静态类型相同但实际类型不同的变量,但虚拟机在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译器可知的。因此,在编译阶段,javac 编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了 sayHello(Human) 作为调用目标,并把这个方法的符号引用写到 main() 方法里的两条 invokevirtual 指令的参数中。

编译器虽然能确定出方法的重载版本,但很多情况下这个重载版本并不是 “唯一的” ,往往只能确定一个 “更加合适的版本”。我么来看下面的代码

void invoke(Object obj, Object... args) { ... }
void invoke(String s, Object obj, Object... args) { ... }

invoke(null, 1);    // 调用第二个invoke方法
invoke(null, 1, 2); // 调用第二个invoke方法
invoke(null, new Object[]{1}); // 只有手动绕开可变长参数的语法糖,
                               // 才能调用第一个invoke方法

这里我想调用第一个方法,传入的参数为 (null, 1)。也就是说,声明为 Object 的形式参数所对应的实际参数为 null,而变长参数则对应 1。

通常来说,之所以不提倡可变长参数方法的重载,是因为 Java 编译器可能无法决定应该调用哪个目标方法。在这种情况下,编译器会报错,并且提示这个方法调用有二义性。然而,Java 编译器直接将我的方法调用识别为调用第二个方法,这究竟是为什么呢?

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

重载的方法在编译过程中即可完成识别。具体到每一个方法调用,Java 编译器会根据所传入参数的声明类型(注意与实际类型区分)来选取重载方法。选取的过程共分为三个阶段:

  1. 在不考虑对基本类型自动装拆箱(auto-boxing,auto-unboxing),以及可变长参数的情况下选取重载方法;
  2. 如果在第 1 个阶段中没有找到适配的方法,那么在允许自动装拆箱,但不允许可变长参数的情况下选取重载方法;
  3. 如果在第 2 个阶段中没有找到适配的方法,那么在允许自动装拆箱以及可变长参数的情况下选取重载方法。

如果 Java 编译器在同一个阶段中找到了多个适配的方法,那么它会在其中选择一个最为贴切的,而决定贴切程度的一个关键就是形式参数类型的继承关系。

在开头的例子中,当传入 null 时,它既可以匹配第一个方法中声明为 Object 的形式参数,也可以匹配第二个方法中声明为 String 的形式参数。由于 String 是 Object 的子类,因此 Java 编译器会认为第二个方法更为贴切。

除了同一个类中的方法,重载也可以作用于这个类所继承而来的方法。也就是说,如果子类定义了与父类中非私有方法同名的方法,而且这两个方法的参数类型不同,那么在子类中,这两个方法同样构成了重载。

那么,如果子类定义了与父类中非私有方法同名的方法,而且这两个方法的参数类型相同,那么这两个方法之间又是什么关系呢?

如果这两个方法都是静态的,那么子类中的方法隐藏了父类中的方法。如果这两个方法都不是静态的,且都不是私有的,那么子类的方法重写了父类中的方法。

2、动态分派

了解了静态分派,我们接下来看下动态分派的过程,它和多态性的另外一个重要的体现——重写(Override)有着很密切的关联。我们还是用前面的 Man 和 Woman 一起 sayHello 的例子来讲解动态分派,请看下面的代码:

package com.jvm;

/**
 * 方法动态分派
 */
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();
        woman.sayHello();
        man = new Woman();
        man.sayHello();
    }
}

运行结果:

man say hello
woman say hello
woman say hello

显然,这里不可能再根据静态类型来决定,因为静态类型相同都是 Human 的两个变量 man 和 woman 在调用 sayHello() 方法时执行了不同的行为,并且变量 man 在两次调用中执行了不同的方法。导致这个现象的原因很明显,是这两个变量的实际类型不同,Java 虚拟机是如何根据实际类型来分派方法执行版本的呢?我们用 javap 命令输出这段代码的字节码,尝试从中寻找答案。

 public static void main(java.lang.String[]);
   descriptor: ([Ljava/lang/String;)V
   flags: ACC_PUBLIC, ACC_STATIC
   Code:
     stack=2, locals=3, args_size=1
        0: new           #2                  // class com/jvm/DynamicDispatch$Man
        3: dup
        4: invokespecial #3                  // Method com/jvm/DynamicDispatch$Man."<init>":()V
        7: astore_1
        8: new           #4                  // class com/jvm/DynamicDispatch$Woman
       11: dup
       12: invokespecial #5                  // Method com/jvm/DynamicDispatch$Woman."<init>":()V
       15: astore_2
       16: aload_1
       17: invokevirtual #6                  // Method com/jvm/DynamicDispatch$Human.sayHello:()V
       20: aload_2
       21: invokevirtual #6                  // Method com/jvm/DynamicDispatch$Human.sayHello:()V
       24: new           #4                  // class com/jvm/DynamicDispatch$Woman
       27: dup
       28: invokespecial #5                  // Method com/jvm/DynamicDispatch$Woman."<init>":()V
       31: astore_1
       32: aload_1
       33: invokevirtual #6                  // Method com/jvm/DynamicDispatch$Human.sayHello:()V
       36: return
     LineNumberTable:
       line 27: 0
       line 28: 8
       line 29: 16
       line 30: 20
       line 31: 24
       line 32: 32
       line 33: 36

0~15 行的字节码是准备动作,作用是建立 man 和 woman 的内存空间、调用 Man 和 Woman 类型的实例构造器,将这两个实例的引用存放在第1、2个局部变量表 Slot 之中。这个动作也就对应了代码中的这两句:

Human man = new Man();
Human woman = new Woman();

接下来16~21行是关键部分,16、20 两行分别把刚刚创建的两个对象的引用压到栈顶,这两个对象是将要执行的 sayHello() 方法的所有者,称为接收者(Receiver);17和21行是方法调用指令,这两条指令单从字节码角度来看,无论是指令(都是 invokevirtual)还是参数(都是常量池中第22项的常量,注释显示了这个常量是 Human.sayHello() 的符号引用)完全一样的,但是这两句指令最终执行的目标方法并不相同。原因就需要从 invokevirtual 指令的多态查找过程开始说起,invokevirtual 指令运行时解析过程大致分为以下几个步骤:

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

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

发布了332 篇原创文章 · 获赞 198 · 访问量 12万+

猜你喜欢

转载自blog.csdn.net/riemann_/article/details/103929975