概述

方法调用不是方法执行,唯一的任务就是确定被调用方法的版本(即确定调用哪一个方法),暂时不涉及方法内部的执行过程。

解析

所有方法调用在Class文件里面都是一个常量池的符号引用,在类加载解析阶段,会将其中的一部分符号引用转化为直接饮用,这种转化的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法方法的调用版本在运行期是不可变的。意思就是,调用的方法在程序代码被编译成Class文件的时候就已经确定下来了。这类方法的调用称为解析(Resolution)。
在Java语言中符合“编译器可知,运行前不可变”这个要求的方法,主要包括静态方法和私有方法两大类。在Java虚拟机里面提供了5条方法调用字节码指令:

  • invokestatic:调用静态方法。
  • invokespecial:调用实例公祖奥器init方法、私有方法和父类方法。
  • invokevirtual:调用所有的虚方法。
  • invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。
  • invokedynamic:现在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。

被invokestatic、invokespecial指令调用的方法都可以在解析阶段中唯一确定调用版本,在类加载的时候就会把符号引用解析为该方法的直接引用,这些方法可以称为非虚方法,与之相反的方法称为虚方法。但是final修饰的方法除外,尽管final方法是invokevirtual指令调用的,但是却不是虚方法,因为final修饰的方法无法被覆盖,没有其它版本。

解析调用是一个静态的过程,在编译期间就完全确定了,在类加载的解析阶段就会把涉及的符号引用变为可确定的直接引用。

分派调用可能是静态也可能的动态的,根据分派的宗量数可分为单分派和多分派,两两组合就有了静态单分派、静态多分派、动态单分派和动态多分派。

分派

静态分派

先看一段代码:

package com.overridere.eight;

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

    static abstract class Human {
    }

    static class Man extends Human {
    }

    static class Woman extends Human {
    }

    public void sayHello(Human guy) {
        System.out.println("hello,guy!");
    }

    public void sayHello(Man guy) {
        System.out.println("hello,gentleman!");
    }

    public void sayHello(Woman guy) {
        System.out.println("hello,lady!");
    }

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

运行结果:
hello,guy!
hello ,guy!
上面的代码示例是一个重载示例,对Java编程稍有了解就知道运行结果,但是为什么会是这样的结果呢?再解开疑惑之前先了解两个概念:

Human man = new Man();

上面一行代码中“Human”称为变量的静态类型(Static Type),或者叫做外观类型(Apparent Type),后面的“Man”则称为变量的实际类型(Actual Type),静态类型和是实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译器可知的;而实际类型的变化结果在运行期才可以确定。
回到上面的代码,main()方法里面的两次sayHello()方法调用,使用哪个重载版本完全取决于传入参数的数量和数据类型,而编译器在重载时是通过参数的静态类型作为判定依据的,所以选择了sayHello(Human)作为调用目标。

所有依赖静态类型来定位方法执行版本的分派称为静态分派,典型的应用就是方法重载。静态分派是发生在编译阶段的,所以不是由虚拟机执行的。不过,编译器虽然能够确定方法的重载版本,但很多情况这个重载版本并不是“唯一的”,往往只能确定一个“更加合适的”版本。如下面的例子:

package com.overridere.eight;

import java.io.Serializable;

public class Overload {

    public static void sayHello(Object arg) {
        System.out.println("hello Object");
    }

    public static void sayHello(int arg) {
        System.out.println("hello int");
    }

    public static void sayHello(long arg) {
        System.out.println("hello long");
    }

    public static void sayHello(Character arg) {
        System.out.println("hello Character");
    }

    public static void sayHello(char arg) {
        System.out.println("hello char");
    }

    public static void sayHello(char... arg) {
        System.out.println("hello char ...");
    }

    public static void sayHello(Serializable arg) {
        System.out.println("hello Serializable");
    }

    public static void main(String[] args) {
        sayHello('a');
    }
}

不妨试着按照顺序逐步注释掉参数类型为char、int、long、Character、Serializable、Object的重载方法,看看每次注释掉一个方法之后会输出什么。

动态分派

动态分派和多态的另一个重要体现——重写(Override)有着很密切的关系。先看一段代码:

package com.overridere.eight;

/**
 * 方法动态分派演示
 */
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
运行结果在意料之中,问题是,虚拟机是如何知道要调用哪个方法的?
使用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           #16                 // class com/overridere/eight/DynamicDispatch$Man
         3: dup
         4: invokespecial #18                 // Method com/overridere/eight/DynamicDispatch$Man."<init>":()V
         7: astore_1
         8: new           #19                 // class com/overridere/eight/DynamicDispatch$Woman
        11: dup
        12: invokespecial #21                 // Method com/overridere/eight/DynamicDispatch$Woman."<init>":()V
        15: astore_2
        16: aload_1
        17: invokevirtual #22                 // Method com/overridere/eight/DynamicDispatch$Human.sayHello:()V
        20: aload_2
        21: invokevirtual #22                 // Method com/overridere/eight/DynamicDispatch$Human.sayHello:()V
        24: new           #19                 // class com/overridere/eight/DynamicDispatch$Woman
        27: dup
        28: invokespecial #21                 // Method com/overridere/eight/DynamicDispatch$Woman."<init>":()V
        31: astore_1
        32: aload_1
        33: invokevirtual #22                 // Method com/overridere/eight/DynamicDispatch$Human.sayHello:()V
        36: return

看了前两篇文章应该能看懂这些字节码了,其中17行和21行都是invokevirtual方法指令,而且传入的参数都是一样的,都是常量池中第22行的方法符号引用,也就是sayHello()方法的符号引用,但是这两句指令的最终执行目标方法却不相同。原因就需要从invokevirtual指令的多态查找过程说起,invokevirtual指令的运行时解析过程如下:

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

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

单分派与多分派

方法的接受者与方法的参数统称为方法的宗量。根据分派基于多少种宗量可以将分派划分为单分派和多分派两种。看一个代码实例:

package com.overridere.eight;
/**
 * 单分派、多分派演示
 */
public class Dispatch {

    static class QQ {}

    static class _360 {}

    public static class Father {
        public void hardChoice(QQ arg) {
            System.out.println("father choose qq");
        }

        public void hardChoice(_360 arg) {
            System.out.println("father choose 360");
        }
    }

    public static class Son extends Father {
        public void hardChoice(QQ arg) {
            System.out.println("son choose qq");
        }

        public void hardChoice(_360 arg) {
            System.out.println("son choose 360");
        }
    }

    public static void main(String[] args) {
        Father father = new Father();
        Father son = new Son();
        father.hardChoice(new _360());
        son.hardChoice(new QQ());
    }
}

运行结果:
father choose 360
son choose qq
先看看静态分派过程。静态分派选中目标方法的依据有两点:一是静态类型是Father还是Son,二是方法参数是QQ还是360。因为是根据两个宗量进行选中,所以Java语言的静态分派属于多分派类型。
再看看动态分派过程。在执行“son.hardChoice(new QQ())”这句代码所对应的invokevirtual指令时,由于编译器已经确定了参数为QQ,所以签名必须为hardChoice(QQ),所以参数不用关心,唯一需要关心的是方法的调用者这一个宗量。因为只有一个宗量作为选择依据,所以Java语言的动态分派属于单分派类型。

需要注意的是,静态分派虽然是根据调用者的静态类型和参数的静态类型来查找方法的,但是并不能确定方法,因为方法的调用者并不是依据静态类型来确定而是依据实际类型来确定的,虽然确定了静态类型,但是之后还是要确定方法调用者的实际类型,所以还是需要进行动态分派。

虚拟机动态分派的实现

由于动态分派是非常频繁的操作,所以动态分派方法版本选择过程需要运行时进行频繁的搜索,基于性能的考虑,大部分实现都不会真正的进行如此频繁的搜索。明对这种情况,最常用的“稳定优化”手段就是为类在方法区中建立一个虚方法表(Vritual Method Table),使用虚方法表索引来代替元数据查找以提供性能。先看看上述代码对应的虚方法表结构:
这里写图片描述
如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。如上图,Son重写了来自Father的全部方法,因此Son的方法表没有指向Father类型数据的箭头。但是Son和Father都没有重写来自Object类的方法,所以所有继承自Object类的方法都指向了Object。
除了这个“稳定优化”手段之外,还有内联缓存(Inline Cache)和基于“类型继承关系分析”(Class Hierarchy Analysis,CHA)技术的守护内联(Guarded Inlining)两种“激进优化”手段。

动态类型语言

动态类型语言的关键特征是它的类型检查的主题过程是在运行期而不是编译器,相对的,在编译器就进行类型检查过程的语言(如C++和Java等)就是最常用的静态类型语言。下面举个例子来说明什么是“在编译器/运行期进行”和什么是“类型检查”。如下面代码:

public static void main(String[] args){
    int[][][] array = new int[1][0][-1];
}

这段代码能够正常编译,但是运行时会报NegativeArraySizeException运行时异常。通俗的来说,只要不运行到这里就不会报错。与运行时异常相对应的是连接时异常,即使不会执行到,类加载时也照样抛出异常。
总结一下就是,编译期就是Java程序代码编译成Class文件,连接时就是将Class文件加载到虚拟机中,运行时就是在虚拟机中执行代码。

在上面的代码中:

Father father = new Father();
Father son = new Son();
father.hardChoice(new _360());
son.hardChoice(new QQ());

静态类型为Father的变量其实际类型一定得是Father及其子类,如果不是则无法通过编译,因为在编译期间需要确定方法引用者的静态类型,而在JavaScript中则不一样,比如说

var myDate = new Date();

这里面并没有什么静态类型,只有实际类型,在编译期间也不会确定什么静态类型,只能确定方法名称、参数、返回值这些信息。“变量无类型而变量值才有类型”这个特点也是动态类型的一个重要特征。或者可以理解为没有静态类型只有实际类型。

这里动态语言与前面说的动态分派有点像,按照我的理解是这样的:
动态分派在编译期确定了静态类型,或者说动态分派在编译器确定了方法调用者的范围——静态类型或其子类,在运行期间再确定具体调用者;而动态语言在编译器完全没有确定范围,只有在运行时才确定具体的调用者。

然后结合以前了解到的内容总结一下:

  • 解析是编译器就完全确定了方法的直接引用。
  • 动态分派是编译的时候确定了方法调用者的静态类型和其它信息,然后运行时确定方法调用者的实际类型,进而确定方法的直接引用。
  • 动态语言是编译的时候没有方法调用者的类型,运行时才会确定。

静态语言的优点是,编译器可以提供严谨的类型检查,与类型相关的问题能在编码的时候就及时发现,利于稳定性及代码达到更大规模。
动态语言的优点是,为开发人员提供更大的灵活性。

基于栈的字节码解释执行引擎

基于栈的指令集与基于寄存器的指令集

Java编译器输出的指令流基本上是一种基于栈的指令集架构(Instruction Set Architecture,ISA),指令流中的指令大部分都是零地址指令,依赖操作数栈进行工作。与之相对的另外一套常用的指令集架构是基于寄存器的指令集,如我们主流PC机中直接支持的指令集架构。那么,这两者之间有什么不同呢?举个简单的例子比如说计算“1+1”的结果:
基于栈的指令集:
iconst_1
iconst_1
iadd
istore_0
两条iconst_1指令连续把两个常量1压入栈之后,iadd指令把栈顶的两个元素出栈、想加,然后把结果放回栈顶,最后istore_0把栈顶的值放到局部变量表的第0个Slot中。
基于寄存器:
mov eax 1
add eax 1
mov指令把EAX寄存器的值设为1,然后add指令再把这个值加1,结果就包存在EAX寄存器里面。

基于栈的指令集优点是可移植,代码相对紧凑,编译器实现更简单等。
缺点是执行速度相对较慢。

基于栈的解释器执行过程

准备如下一段代码:

public int calc(){
    int a = 100;
    int b = 200;
    int c = 300;
    return (a + b) * c;
}

javap命令查看字节码:

 public int calc();
    Code:
      stack=2, locals=4, args_size=1
      0:    bipush  100
      2:    istore_1
      3:    sipush  200
      6:    istore_2
      7:    sipush  300
      10:   istore_3
      11:   iload_1
      12:   iload_2
      13:   iadd
      14:   iload_3
      15:   imul
      16:   ireturn

执行过程模型图:
这里写图片描述
这里写图片描述
这里写图片描述
这里写图片描述
这里写图片描述
这里写图片描述
这里写图片描述

Logo

华为开发者空间,是为全球开发者打造的专属开发空间,汇聚了华为优质开发资源及工具,致力于让每一位开发者拥有一台云主机,基于华为根生态开发、创新。

更多推荐