[Java] JVM方法调用

字节码指令'invokexxx' 方法调用的过程

Posted by Penistrong on April 6, 2023

JVM方法调用

方法调用的唯一任务是确定被调用方法的版本(即具体调用哪个方法?父类的还是子类的?静态的还是动态的?),方法的具体执行过程还需要即时编译器JIT等翻译成机器码执行

字节码Class文件中,类中方法要调用的其他目标方法都是常量池中对应的目标方法符号引用,而不是目标方法在运行时实际内存中的入口地址(直接引用),这样的方法调用方式让JVM拥有更强大的动态扩展能力

方法的重载与重写

首先,要从Java层面说明方法重载与重写的区别

方法的特征签名仅由方法名和参数列表决定,而与返回值无关

重载 Overload

方法的重载发生在同一个类(或者父类与子类之间),其方法名必须相同,但是参数列表必须不同,这样就可以使得方法的特征签名不同,从而让编译器认为这是一个重载方法

除了参数列表不同之外,重载方法的返回值、抛出的异常、访问修饰符都可以不同。类中最常见的方法重载就是对构造器方法的重载,而Java允许重载任何方法,在编译期即可确定代码中调用的具体是哪个重载方法(根据方法特征签名进行解析)

总结: 重载就是同一个类中多个同名方法根据不同的参数列表传参来执行不同的处理逻辑

重写 Override

方法的重写发生在子类与父类之间,是子类对父类中允许子类访问的方法其方法体的重新编写,遵循”两同两小一大“的重写原则

  1. 两同: 子类重写方法的特征签名必须相同,即方法名和参数列表必须相同

  2. 两小: 子类重写方法的返回值类型应小于等于父类方法的返回值类型(对于void和8大基本数据类型,重写时返回值类型不可修改;对于对象引用类型,重写时返回值类型为该引用类型的子类或其本身),子类重写方法声明抛出的异常类应小于等于父类方法声明抛出的异常类(即是父类声明抛出的异常类的子类)

  3. 一大: 子类重写方法的访问权限应大于等于父类方法的访问权限,比如父类方法被protected修饰,那么子类重写时只能使用protected或者public修饰,而不能用范围更小的private修饰

    如果父类方法的访问修饰符包含private/final/static,根据上述重写原则子类无法重写这些方法 注意static静态方法,可以被子类重新声明,即子类可以声明一个具有相同特征签名的静态方法,但它不属于重写方法,对于JVM来说在编译期就将类的静态方法绑定到该类型的对象上,不存在多态,见下文非虚方法一节

总结: 重写方法的调用发生在运行期,由JVM在运行期确定调用的到底是子类重写的方法还是父类里的原方法,是多态的体现

字节码方法调用指令

JVM支持5种方法调用的字节码指令:

  • invokestatic: 调用静态方法

  • invokespecial: 调用对象实例构造器<init>()方法、private类私有方法、父类中的方法

  • invokevirtual: 调用所有的虚方法

  • invokeinterface: 调用接口方法,这时就需要在运行时动态确定实现类该接口方法的其他对象,才能进行调用

  • invokedynamic: 根据字节码常量池表里的动态方法调用点限定符(CONSTANT_InvokeDynamic_info),在运行时对其进行动态解析

虚方法与非虚方法

JVM规范中规定了5种可以在类加载的解析阶段直接确定唯一调用版本的方法:

  • 1.静态方法(由invokestatic调用)
  • 2.实例构造器、3.私有方法、4.父类方法(由invokespeical调用)
  • 5.被final修饰的方法(虽然该方法由虚方法调用指令invokevirtual调用)

这5种方法都可以在类加载时直接确定,所以统称为非虚方法(Non-Virtual Method),其他方法统称为虚方法(Virtual Method),需要进行多态选择

解析 Resolution

在类加载的解析阶段,如果能确定目标方法在运行期不可改变(即目标方法在程序真正运行前便拥有了一个确定的调用版本),那么将该目标方法的符号引用转换为直接引用的过程称为解析

非虚方法一节中给出了5种这类确定的方法:

  1. 静态方法: 与类型直接关联,运行时肯定不可变

  2. 私有方法: 无法被外部访问,不可能通过继承等方式对方法进行重写

  3. 实例构造器: 运行时无法重写构造函数,所以不可变

  4. 父类方法: 本类没有重写的父类方法,同样不可变

  5. final方法: 无法被覆盖、重写,不可能存在其他版本

分派 Dispatch

方法的分派调用是面向对象三大特性之一多态的最基本体现,由于方法存在重写、重载,JVM需要确定调用的目标方法到底是哪一个版本

静态分派 Static Dispatch

JVM规范中将静态分派称为方法重载解析(Method Overload Resolution),主要是解析实际类型到静态类型的变化,其最典型的应用例子就是方法重载

Java中,通常会在创建一个对象时使用其父类作为其静态类型,如下所示:

public class StaticDispatchExample {

    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, Mr.");
    }

    public void sayHello(Woman woman) {
        System.out.println("Hello, Miss.");
    }

    public static void main (String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        StaticDispatchExample sde = new StaticDispatchExample();
        sde.sayHello(man);      // 运行结果: "Hello, human."
        sde.sayHello(woman);    // 运行结果: "Hello, human."
    }
}

ManWoman都继承于抽象父类Human,在创建对象时由于多态的特性,可以用父类Human作为对象的静态类型(Static Type, 也可称为外观类型Apparent Type),子类ManWoman作为对象的实际类型(Actual Type, 或称为运行时类型Runtime Type)

静态类型在运行时是不会自行改变的(除非代码中添加了类型转换),而实际类型可能需要在运行时才能确定,比如下面用Random在运行时随机创建不同的实际类型

Human human = (new Random()).nextBoolean() ? new Man() : new Woman();

在编译期间对象的实际类型是不可知的,只能到运行时确定,但是由于其静态类型已固定,那么在调用拥有两个重载的sayHello()方法时javac编译器根据参数的静态类型决定使用sayHello(Human human)作为方法调用目标,并将其写到main方法表Code属性表中的invokevirtual指令的参数中,如下所示:

静态分派例子

由于静态分派发生在编译阶段,所以JVM规范将其称为Method Overload Resolution,而不是运行时由JVM进行确定

动态分派 Dynamic Dispatch

多态特性的另一体现就是方法重写(Method Override),将静态分派例子的代码稍作修改,让抽象父类具有抽象方法sayHello(),然后子类对该方法进行重写:

public class DynamicDispatchExample {

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

    static class Man extends Human {
        @Override
        protected void sayHello() {
            System.out.println("Hello, Mr.");
        }
    }

    static class Woman extends Human {
        @Override
        protected void sayHello() {
            System.out.println("Hello, Miss.");
        }
    }

    public static void main (String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        man.sayHello();         // 运行结果: "Hello, Mr."
        woman.sayHello();       // 运行结果: "Hello, Miss."
        man = new Woman();
        man.sayHello(man);      // 运行结果: "Hello, Miss."
    }
}

JVM在判断这两个静态类型相同的对象其调用的方法版本时,显然需要它们的实际类型进行动态分派确定方法的执行版本,查看javac编译器将这段代码编译成的字节码:

动态分派例子

使用newdupinvokespecial:<init>分别创建完manwoman对象后,都使用了astore_<n>指令分别将两个对象的引用存入局部变量表中索引为12的引用类型变量槽中

根据上图的16、17两行字节码指令,在调用man.sayHello()时,首先使用aload_1将局部变量表中的位于变量槽1的对象引用压入操作数栈栈顶,然后执行invokevirtual指令:

  1. 找到操作数栈栈顶的第一个引用类型元素指向的对象的实际类型,记作C

  2. 如果实际类型C中找到与常量池中方法描述符和简单名称都对应的方法,再对其进行访问权限校验,校验通过则返回目标方法的直接引用,校验不通过则返回java.lang.IllgelAccessError

  3. 如果实际类型C中找不到对应方法,则按照继承关系自底向上依次查找类型C的各个父类

  4. 全部查找完毕后若仍找不到对应方法,抛出java.lang.AbstractMethodError

从上述解析过程可以看到,invokevirtual需要运行时根据操作数栈顶的引用对象确定方法接收者的实际类型以选择具体方法版本

这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派

JVM实现动态分派的方式

因为动态分派是面向对象程序中的高频执行动作,如果每次都要在方法接收者类型里的方法元数据中搜索目标方法,显然会影响JVM运行性能

所以,JVM会采用多种方式优化动态分派过程:

  1. 虚方法表(Virtual Method Table):

    每个类的虚方法表里存放着类中各个方法的实际入口地址,如果子类没有重写父类的某个方法,那么该方法在子类中的入口地址与父类中该方法的入口地址相同,否则就替换为子类重写方法的入口地址

    同时为了便于查找,具有相同特征签名的方法其在父类与子类的虚方法表中的索引一致,当类型变换后只需要变更当前查找的虚方法表,直接根据索引得到目标方法的入口地址而不需要再次进行查找

  2. 类型继承关系分析(Class Hierarchy Analysis)

  3. 守护内联(Guarded Inlining)

  4. 内联缓存(Inline Cache)

字段不具备多态

虚方法调用的多态性由JVM提供的invokevirtual指令的执行逻辑实现,但是类中字段是不具备多态的,当使用一个对象的虚方法时,该方法里面使用的类中字段只能是它当前可见的直接字段,而不会到其父类中寻找同名字段的”父类版”

public class NonPolymorphicFieldExample {
    static class Human {
        public int age = 18;

        public Human () {
            age = 20;
            showTheAge();
        }

        public void showTheAge() {
            System.out.println("I am a human, now " + age + " years old");
        }
    }

    static class Man extends Human {
        public int age = 30;

        public Man () {
            age = 40;
            showTheAge();
        }

        @Override
        public void showTheAge() {
            System.out.println("I am a gentle man, now " + age + " years old");
        }
    }

    public static void main(String[] args) {
        Human man = new Man();
        System.out.println("The human is " + man.age + " years old");
    }
}

运行结果为

I am a gentle man, now 0 years old
I am a gentle man, now 40 years old
The human is 20 years old

前两行都是执行Man类的构造函数中输出的,由于子类在实例化时会先隐式调用父类的构造函数,所以先执行Human::new构造方法,其过程中调用了方法showTheAge(),这个方法被JVM以动态分派的方式解析,确定其方法版本应为Man::showTheAge()

其中使用到的年龄age字段并不是父类的Human.age而是子类中直接可见的Man.age,后者由于还没在子类构造函数<init>()里初始化,这个时候该字段只有默认零值,所以输出age = 0

父类构造函数调用完毕后继续执行子类构造函数,Man.age字段被正确初始化,所以输出age = 40

最后,由于实例化Man对象时,其静态类型是父类Human,所以在main方法中直接使用man.age访问的是父类中的字段Human.age,所以输出age = 20

单分派与多分派

《Java与模式》中将方法接收者与方法的参数并称为方法的宗量,根据分派时存在的宗量个数,可以将分派划分为单分派与多分派两种:

  • 单分派: 基于一个宗量(只考虑方法接收者或只考虑方法参数)对目标方法进行选择

  • 多分派: 基于多个宗量(同时考虑方法接收者和方法参数)对目标方法进行选择

Java本身是一种静态多分派、动态单分派的语言,但是JVM支持动态多分派(其他运行在JVM上的动态语言比如Kotlin就可以通过dynamic类型实现动态多分派)

以下面代码为例:

public class StaticMultiDispatchWhileDynamicSingleDispatch {

    static class Vegetable {}

    static class Meat {}

    static class Human {
        public void eat (Vegetable arg) {
            System.out.println("Human eat vegetable");
        }

        public void eat (Meat arg) {
            System.out.println("Human eat meat");
        }
    }

    static class ModernHuman extends Human{
        @Override
        public void eat (Vegetable arg) {
            System.out.println("ModernHuman eat vegetable");
        }
        @Override
        public void eat (Meat arg) {
            System.out.println("ModernHuman eat meat");
        }
    }

    public static void main(String[] args) {
        Human ancestor = new Human();
        Human modernMan = new ModernHuman();
        ancestor.eat(new Vegetable());
        modernMan.eat(new Meat());
    }
}

输出结果如下:

Human eat vegetable
ModernHuman eat meat

main方法的字节码如下

静态多分派动态单分派

  • 静态多分派:

    首先根据JVM静态分派过程,javac编译器会根据两个对象的静态类型Human确定调用的目标方法的符号引用,所以第24行和第35行中的invokevirtual字节码指令的参数分别指向两个重载的方法Human::eat(Vegetable)Human::eat(Meat)

    即JVM在编译期静态分派过程中,同时从静态类型(方法接收者)和参数类型(方法参数)这2个宗量上确定对应的重载方法,所以是静态多分派

  • 动态单分派:

    对于实际类型为ModernHumanmodernMan对象而言,invokevirtual指令调用目标方法时,其参数类型已被固定为了Meat,所以只能根据操作数栈栈顶的引用对象(this)的类型ModernHuman搜索到最匹配的重写方法ModernHuman::eat(Meat)

    即JVM在运行时动态分派过程中,只能根据实际类型(方法接收者)这个唯一宗量确定对应的重写方法,所以是动态单分派