面试官:动态分配,静态分配与重载,重写的关系?

1,340 阅读8分钟

方法调用

方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法)

解析

所有方法调用的目标方法在class文件里都是常量池中的一个符号引用,在类加载的解析阶段,会将其中一部分符号引用转化为直接引用,这种解析能够成立的条件是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好、编译器进行编译那一刻就已经确定下来。这类方法的调用被称为解析(Resolution)。

调用不同类型的方法,字节码指令集里设计了不同的指令

编译期可知,运行期不可变

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

虚方法 vs 非虚方法

只要能被invokestaticinvokespecial指令调用的方法,都可以在解析阶段中确定唯一的调用版本,Java语言里符合这个条件的方法共有静态方法、私有方法、实例构造器、父类方法4种,再加上被final修饰的方法(尽管它使用invokevirtual指令调用),这5种方法调用会在类加载的时候就可以把符号引用解析为该方法的直接引用。这些方法统称为“非虚方法”(Non-Virtual Method),与之相反,其他方法就被称为“虚方法”(Virtual Method)

方法静态解析演示

public class StaticResolution {
    public static void sayHello() {
        System.out.println("hello world");
    }
    public static void main(String[] args) {
        StaticResolution.sayHello();
    }
}

使用javap命令查看这段程序对应的字节码,会发现的确是通过invokestatic命令来调用sayHello()方法,而且其调用的方法版本已经在编译时就明确以常量池项的形式固化在字节码指令的参数之中(#5 为常量池的索引):

5 代表常量池中的索引,根据该值可以知道方法调用的 符号引用

分派

众所周知,Java是一门面向对象的程序语言,因为Java具备面向对象的3个基本特征:继承、封装和多态。

分派调用过程将会揭示多态性特征的一些最基本的体现,如“重载”和“重写”在Java虚拟机之中是如何实现的。

静态分派和重载

public class StaticDispatch {
  static class Human {

  }
  static class Man extends Human{

  }
  static class Woman extends Human{

  }
  public void sayHello(Human human) {
    System.out.println("hi, guy");
  }
  public void sayHello(Man man) {
    System.out.println("hi, man");
  }
  public void sayHello(Woman woman) {
    System.out.println("hi, woman");
  }
  public static void main(String[] args) {
    Human man = new Man();
    Human woman = new Woman();
    StaticDispatch sd = new StaticDispatch();

    sd.sayHello(man); // hi, guy
    sd.sayHello(woman);// hi, guy
  }
}

通过结果可以发现,虚拟机选择了参数类型为Human的重载版本,为什么呢?在解决这个问题之前,我们先通过如下代码来定义两个关键概念:

Human man = new Man();

我们把上面代码中的“Human”称为变量的“静态类型”(Static Type),或者叫“外观类型”(Apparent Type),后面的“Man”则被称为变量的“实际类型”(Actual Type)或者叫“运行时类型”(Runtime Type).

静态类型和实际类型在程序中都可能会发生变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定.

不妨通过一段实际例子来解释,譬如有下面的代码:

// 实际类型变化
Human human = (new Random()).nextBoolean() ? new Man() : new Woman();

// 静态类型变化
sr.sayHello((Man) human)
sr.sayHello((Woman) human)

对象human的实际类型是可变的,编译期间它完全是个“薛定谔的人”,到底是Man还是Woman,必须等到程序运行到这行的时候才能确定。而human的静态类型是Human,也可以在使用时(如sayHello()方法中的强制转型)临时改变这个类型,但这个改变仍是在编译期是可知的,两次sayHello()方法的调用,在编译期完全可以明确转型的是Man还是Woman.

解释清楚了静态类型与实际类型的概念,再来看下上面的示例代码:

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

所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派。静态分派的最典型应用表现就是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的,这点也是为何一些资料选择把它归入“解析”而不是“分派”的原因。

动态分派和重写

了解了静态分派,我们接下来看一下Java语言里动态分派的实现过程,它与Java语言多态性的另外一个重要体现——重写(Override)有着很密切的关联

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(); // man, say hello
    woman.sayHello();// woman, say hello
  }
}

我们使用javap命令输出这段代码的字节码:

0 ~ 15 行的字节码是准备工作: 作用是建立man和woman的内存空间、调用Man和Woman类型的实例构造器,将这两个实例的引用存放在第1、2个局部变量表的变量槽中(astore_1, astore_2),这些动作实际对应了Java源码中的这两行:

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

接下来的16~21行是关键部分,16和20行的aload_1aload_2指令分别把刚刚创建的两个对象的引用压到操作数栈的顶部,这两个对象是将要执行的sayHello()方法的所有者,称为接收者(Receiver);17和21行是方法调用指令,这两条调用指令单从字节码角度来看,无论是指令(都是invokevirtual)还是参数(都是常量池中第22项的常量,注释显示了这个常量是Human.sayHello()的符号引用)都完全一样,但是这两句指令最终执行的目标方法并不相同。

那看来解决问题的关键还必须从invokevirtual指令本身入手,要弄清楚它是如何确定调用方法版本、如何实现多态查找来着手分析才行。

根据《Java虚拟机规范》,invokevirtual指令的运行时解析过程大致分为以下几步:


正是因为invokevirtual指令执行的第一步就是在运行期确定接收者的实际类型,所以两次调用中的invokevirtual指令并不是把常量池中方法的符号引用解析到直接引用上就结束了,还会根据方法接收者的实际类型来选择方法版本,这个过程就是Java语言中方法重写的本质。