高亮的杂货铺

JVM从0到1-方法调用与虚方法表

2020-03-15 · 6 min read
JVM Java

JVM字节码中,共有五种指令来实现方法的调用

  1. invokevirtual 为最常见的情况,用于调用所有的虚方法
  2. invokespecial 是作为对 private 和构造方法<init>()的调用,以及父类中的方法的调用
  3. invokeinterface 的实现跟 invokevirtual 类似,用于调用接口方法,会在运行时再确定一个实现该接口的对象。
  4. invokestatic 是对静态方法的调用。
  5. invokedynamic 先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法

解析调用

我们知道,在class文件中,所有的方法调用都是一个常量池中的符号引用。 在类的解析阶段,会将其中的一部分符号引用转换为直接引用,这种解析能够成立的前提是方法在真正运行之前就有一个可确定的调用版本,并且这个版本是在运行期间不可变的。 这类方法的调用被称为解析(Resolution)

使用invokestaic和invokespecial指令调用的方法,都是可以在class文件的解析阶段中确定唯一的调用版本的,java语言中符合这种条件的方法一共有四种,包括静态方法,私有方法,实例方法,父类方法。非此四类方法会存在多态的情况,因此无法在解析阶段就确定方法的唯一调用版本。

除了被invokestatic和invokespecial指令调用的方法外,还有被final修饰的方法,因为其无法被继承,所以也可以被解析调用。但是final方法是使用invokevirtual指令进行调用的。

解析调用是一个静态的过程,在编译期间就可以完全确定,在类加载的阶段会把涉及到的符号引用完全转换为明确的直接引用,无需延迟到运行期间进行动态处理。

动态分派(Dispatch)

因为Java是一门面向对象的程序语言,所以具有继承、封装、多态三个特征,分派调用过程就是多态性的提现。

静态分派

首先要理解静态类型和实际类型的关系

class Human{};
class Man extends Human{};
Human man = new Man();

在上述代码中,Human被成为man的静态类型,而Man则是man的实际类型。静态类型在编译期间是可知的,但是实际类型在编译期间是不可知的,例如下面这个例子

Human human = (new Random()).nextBoolean() ? new Man() : new Woman();
public static sayHello(Human human){human.sayHello();}
public static sayHello(Man man){man.sayHello();}
public static sayHello(Woman woman){woman.sayHello();}

编译期间,编译器会根据对象的静态类型去选择使用方法的哪个重载版本,在上述方法中,如果我们调用sayHello(human),必定是调用的父类Human的sayHello方法,因为javac编译器会根据静态类型选择合适的重载版本。

动态分派

动态分派主要体现在方法重写(Overide)上。

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

这是由于,此时选择方法的调用版本时,不在是根据静态类型来决定的,而是根据实际类型。
查看这个方法的字节码,可以发现调用sayHello方法时间,生成的字节码是invokevirtual,invokevirual方法并不是把常量池中方法的符号引用解析到直接引用上就结束了,还会根据方法接收者的实际类型来选择方法版本,这就是Java语言中方法重写的本质。
invokevirutal指令运行时解析的过程大概分为四个步骤

  1. 找到操作数栈顶的第一个元素所指向的实际类型,计做C
  2. 如果在类型C中找到了与常量的描述符和简单方法都相符的方法,则进行权限校验,通过则返回引用,查找结束,否则返回java.lang.IllegalAccessError异常。
  3. 否则,按照继承关系从下往上查找C的各个父类进行第二步的搜索验证
  4. 如果没有找到合适的方法,则抛出java.lang.AbstarctMethodError异常

字段是没有多态性的

虚拟机动态分配的实现

动态分派是一个非常频繁的动作,而且动态分派需要在接收者类型的方法元数据中搜索合适的目标方法,因此Java虚拟机为了优化性能,运行时一般不会如此品牌的搜索类型元数据。

通常,会通过建立一个虚方法表的方式,使用虚方法表索引来代替元数据查找以提高性能。
虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表中的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类虚方法表中的地址也会被替换为指向子类实现版本的入口地址。虚方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的虚方法表也一同初始化完毕。