JVM方法调用

591 阅读6分钟

这里的方法调用需要确定要调用方法是哪一个,但不包含方法的执行。

方法调用字节码指令

  • invokestatic 用于调用静态方法;

  • invokespecial 用于调用实例构造器()方法、私有方法、父类中的方法;

  • invokevirtual 用于调用所有的虚方法。虚方法指的是不被final修饰的实例方法;

    • 静态方法、构造器方法、私有方法、final方法、父类的方法为非虚方法。
    • 由于历史原因,被final修饰的非虚方法也由invokevirtual调用。
  • invokeinterface 用于调用接口方法,会在运行时确定一个实现该接口的对象;

  • invokedynamic 在运行时动态先解析出调用点限定符所引用的方法,然后再执行该方法。

解析调用

Java中的非虚方法(静态方法、final方法、私有方法、构造器方法、父类的方法)都使用解析调用,解析调用一定是静态的,可以在解析阶段确定唯一版本。在类加载过程中的解析阶段,JVM就会把符合解析调用条件的符号引用全部转变为明确的直接引用

静态方法、父类的方法 为什么使用解析调用

虽然子类也可以调用父类的static方法,但是这里具体调用哪个方法在编译期就可以确定,只需要从当前类开始逐层向上寻找即可确定要调用的方法,因此也属于静态解析。

父类的方法分析逻辑与上面的static方法类似。

区分一个概念:这里只能说子类的static方法覆盖了父类的static方法,但是这里不是重写。因为重写的定义是根据对象的运行时类型调用特定的方法。

  • 首先这里是根据类的类型调用的;
  • 其次这里具体调用什么方法是在解析阶段确定的。

实例构造器()方法、私有方法、被final修饰的实例方法 为什么使用解析调用

这三种方法不能被子类重写,一定都使用当前类中的定义,所以可以在解析阶段确定要具体的调用版本。

为什么虚方法不能使用解析调用

JVM在执行虚方法调用时,只有在运行期才能确定具体要执行哪个函数,一个例子如下:

public class VirtualMethodDemo {
    static class Human {
        public void sayHello() {
            System.out.println("Hello from Human");
        }
    }
    static class Man extends Human {
        @Override
        public void sayHello() {
            System.out.println("Hello from Man");
        }
    }
    static class Woman extends Human {
        @Override
        public void sayHello() {
            System.out.println("Hello from Woman");
        }
    }

    public static void main(String[] args) {
        Random random = new Random();
        Human human = random.nextBoolean() ? new Man() : new Woman();
        human.sayHello();
    }
}

在上面的示例中,只有在运行期才能确定human到底指向Man对象还是Woman对象,进而确定函数调用sayHello指的是哪个方法。也就无法进行在解析阶段将符号引用解析为直接引用。

分派调用

首先Java具备面向对象的基本特征:封装、继承和多态。其中多态最基本的体现就是重载和重写。这两个特性和分派的对应关系如下:

  • 重载:静态分派
  • 重写:动态分派

静态分派(重载)

变量的静态类型和动态类型

在描述静态分派和动态分派之前,需要先区分变量的静态类型和动态类型。

/**
 * 演示变量的静态类型和动态类型
 */
public class StaticTypeAndDynamicTypeDemo {
    static class Human {
        
    }
    static class Man extends Human {
        
    }
    static class Woman extends Human {

    }
    static void sayHello(Human human) {
        System.out.println("Hello Human");
    }
    static void sayHello(Man man) {
        System.out.println("Hello Man");
    }
    static void sayHello(Woman woman) {
        System.out.println("Hello Woman");
    }

    public static void main(String[] args) {
        // 这里human的静态类型为Human,实际类型为Man
        Human human = new Man();
        
        // human的实际类型发生变化
        human = new Woman();

        // human的静态类型从Human转变为Woman
        sayHello((Woman)human);
    }

}

如上所示,对于Human human = new Man(); 来说,human的静态类型为Human,实际类型为Man。重载时,编译器将根据变量的静态类型,而不是实际类型,来判断应该使用哪个方法。

动态分派(重写)

动态分派就是指在运行过程中,根据实际类型确定方法执行版本的分派过程。

例子

/**
 * 演示变量的动态分派
 */
public class DynamicTypeDemo {
    static class Human {
        public void sayHello() {
            System.out.println("Hello Human");
        }
    }
    static class Man extends Human {
        @Override
        public void sayHello() {
            System.out.println("Hello Man");
        }
    }
    static class Woman extends Human {
        @Override
        public void sayHello() {
            System.out.println("Hello Woman");
        }
    }
    
    public static void main(String[] args) {
        Human man = new Man();
        Human woman = new Woman();
        man.sayHello();
        woman.sayHello();
    }
}

字节码分析:

public static void main(java.lang.String[]);
    descriptor: ([Ljava/lang/String;)V
    flags: (0x0009) ACC_PUBLIC, ACC_STATIC
    Code:
      stack=2, locals=3, args_size=1
         0: new           #7                  // class DynamicTypeDemo$Man
         3: dup
         4: invokespecial #9                  // Method DynamicTypeDemo$Man."<init>":()V
         7: astore_1
         8: new           #10                 // class DynamicTypeDemo$Woman
        11: dup
        12: invokespecial #12                 // Method DynamicTypeDemo$Woman."<init>":()V
        15: astore_2
        16: aload_1
        17: invokevirtual #13                 // Method DynamicTypeDemo$Human.sayHello:()V
        20: aload_2
        21: invokevirtual #13                 // Method DynamicTypeDemo$Human.sayHello:()V
        24: return

其中 man.sayHello(); 和woman.sayHello();两行对应字节码的16、17和20、21行。可以看到两者都是执行invokevirtual #13 ,但是却表现出不同的行为。因此原因应该在invokevirtual内部。

invokevirtual指令的执行逻辑

  1. 找到操作数栈顶元素指向的对象的类型,记为C。

  2. 在类型C中寻找与常量中的简单名称和描述符都相符的方法。(简单名称:变量或方法的名字;描述符:变量的类型、方法的参数类型和返回值类型)

    1. 如果找到则进行权限校验。如果通过则返回这个方法的直接引用,查找过程结束;如果校验不通过则返回java.lang.IllegalAccessError异常。
    2. 如果没找到,则按照继承关系从下向上依次对C的父类执行第二步的搜索和验证工作。
  3. 如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

分析invokevirtual指令

正因为invokevirtual 需要在运行时才能确定具体的方法的直接引用,因此也就无法在类加载的解析阶段解析符号引用。

invokevirtual指令的实现

动态分派在JVM中执行地非常频繁,每次都按照上面的逻辑搜索效率很低,因此需要优化性能。

**常用的优化手段:**在方法区创建一个虚方法表。

  • 虚方法表中保存着每个虚方法的实际入口地址。如果某虚方法没有被子类重写,那么子类方法表中该方法的入口地址=父类方法表中该类的入口地址。
  • 每次调用invokevirtual 时,可以直接从虚方法表中获取到方法的入口地址,避免了频繁地执行上面的搜索过程。
  • 虚方法表的构建通常在类加载过程中的链接阶段完成。在准备阶段为类变量赋零值之后,JVM会顺便将该类的虚方法表初始化完毕。

动态类型语言支持

关键特征:类型检查的主体过程实在运行期,而不是编译期。

与静态语言相比的显著差异:变量obj本身没有类型,obj的值才有类型。

静态类型语言和动态类型语言各自的优点

  • 静态语言:类型错误在编译期就可以被发现,更稳定。
  • 动态语言:灵活性高,开发效率高。

JVM提供的动态语言支持

  1. invokedynamic指令
  2. java.lang.invoke包。在之前的依靠符号引用确定调用的目标方法的方式之外,提供了 MethodHandle 这种动态确定目标方法的调用机制。

参考资料

understanding-the-jvm/02-虚拟机字节码执行引擎_01-方法调用.md at master · TangBean/understanding-the-jvm

《深入理解Java虚拟机》