JVM十一:虚拟机字节码执行引擎(2)

解析

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

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

与之相对应的是,在 Java 虚拟机里面提供了 5 条方法调用字节码指令,分别如下。

  • invokestatic:调用静态方法。
  • invokespecial:调用实例构造器 <init> 方法、私有方法和父类方法。
  • invokevirtual::调用所有的虚方法。
  • invokeinterface:调用接口方法,会在运行时再确定一个实现此接口的对象。
  • invokedynamic:先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,再次之前的 4 条调用指令,分派逻辑是固化在 Java 虚拟机内部的,而 invokedynamic 指令的分配逻辑是由用户所设定的引导方法决定的。

        只要能被 invokestatic 和 invokespecial 指令调用的方法,都可以在解析阶段确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法 4 类,它们在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法可以称为非虚方法,与之相反,其他方法称为虚方法(除去 final 方法,后文会提到)。下演示了一个最常见的解析调用的例子,此样例中,静态方法 sayHello() 只可能属于类型 StaticResolution,没有任何手段可以覆盖或隐藏这个方法。

/**
 * 方法静态解析演示
 *
 */
public class StaticResolution {
 
	public static void sayHello() {
		System.out.println("hello world");
	}
	
	public static void main(String[] args) {
		StaticResolution.sayHello();
	}
}

    Java 中的非虚方法除了使用 invokestatic、invokespecial 调用的方法之外还有一种,就是被 final 修饰的方法。虽然 final 方法是使用 invokevirtual 指令来调用的,但是由于它无法被覆盖,没有其他版本,所以也无须对方法接收者进行多态选择,又或者说多态选择的结果肯定是唯一的。在 Java 语言规范中明确说明了 final 方法是一种非虚方法。

    解析调用一定是个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。而分配(Dispatch)调用则可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派和多分派。这两类分派方式的两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派 4 种分派组合情况。

1.静态分派

        在开始讲解静态分派(严格来说,Dispatch 这个词一般不用再静态环境中,英文技术文档的称呼是 “Method Overload Resolution”,但国内的各种资料都普遍将这种行为翻译成 “静态分派”,特此说明)前,笔者准备了一段经常出现在面试题中的程序代码,读者不妨先看一遍,想一下程序的输出结果是什么。后面我们的话题将围绕这个类的方法来重载(Overload)代码,以分析虚拟机和编译器确定方法版本的过程。方法静态分派如代码清单 8-6 所示。

代码清单 8-6  方法静态分派演示

package org.fenixsoft.polymorphic;

/**

* 方法静态分派演示

*

*/

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);

}

}

        运行结果:

 

        代码清单 8-6 中的代码实际上是在考验阅读者对重载的理解程度,相信对 Java 编程稍有经验的程序员看完程序后都能得出正确的运行结果,但为什么会选择执行参数类型为 Human 的重载呢?在解决这个问题之前,我们先按如下代码定义两个重要的的概念。

 

Human man = new Man();


        我们把上面代码中的 “Human” 称为变量的静态类型(Static Type),或者叫做外观类型(Apparent Type),后面的 “Man” 则称为变量的实际类型(Actual Type),静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。例如下面的代码:

// 实际类型变化

Human man = new Man();

man = new Woman();

// 静态类型变化

sr.sayHello((Man) man);

sr.sayHello((Woman) man);


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

        所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。另外,编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是 “唯一的”,往往只能确定一个 “更加适合的” 版本。这种模糊的结论在由 0 和 1 构成的计算机世界中算是比较 “稀罕” 的事情,产生这种模糊结论的主要原因是字面量不需要定义,所以字面量没有显式的静态类型,它的静态类型只能通过语言上的规则去理解和推断。代码清单 8-7 演示了何为 “更加适合的” 版本。

代码清单 8-7  重载方法匹配优先级

package org.fenixsoft.polymorphic;

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');

}

}

        上面的代码运行后会输出:

 

hello char


        这很好理解,'a' 是一个 char 类型的数据,自然会寻找参数类型为 char 的重载方法,如果注释掉 sayHello(char arg) 方法,那输出会变为:

hello int


        这时发生了一次自动类型转换,'a' 除了可以代表一个字符串,还可以代表数字 97(字符 'a' 的 Unicode 数值为十进制数字 97),因此参数类型为 int 的重载也是合适的。我们继续注释掉 sayHello(int arg) 方法,那输出会变为:

 

hello long


        这时发生了两次自动类型转换,'a' 转型为整型 97 之后,进一步转型为长整数 97L,匹配了参数类型为 long 的重载。笔者在代码中没有写其他的类型如 float、double 等的重载,不过实际上自动转型还能继续发生多次,按照 char -> int -> long -> float -> double 的顺序转型进行匹配。但不会匹配到 byte 和 short 类型的重载,因为 char 到 byte 或 short 的转型是不安全的。我们继续注释掉 sayHello(long arg) 方法,那输出会变为:

hello Character


        这时发生了一次自动装箱,'a' 被包装为它的封装类型 java.lang.Character,所以匹配到了参数类型为 Character 的重载,继续注释掉 sayHello(Character arg) 方法,那输出会变为:

hello Serializable


        这个输出可能会让人感觉摸不着头脑,一个字符或数字与序列化有什么关系?出现 hello Serializable,是因为 java.lang.Serializable 是 java.lang.Character 类实现的一个接口,当自动装箱之后发现还是找不到装箱类,但是找到了装箱类实现了的接口类型,所以紧接着又发生一次自动转型。char 可以转型成 int,但是 Character 是绝对不会转型为 Integer 的,它只能安全地转型为它实现的接口或父类。Character 还实现了另外一个接口 java.lang.Comparable<Character>,如果同时出现两个参数分别为 Serializable 和 Comparable<Character> 的重载方法,那它们在此时的优先级是一样的。编译器无法确定要自动转型为哪种类型,会提示类型模糊,拒绝编译。程序必须在调用时显式地指定字面量的静态类型,如:sayHello((Comparable<Character>'a'),才能编译通过。下面继续注释掉 sayHello(Serializable arg) 方法,输出会变为:

hello Object


        这时是 char 装箱后转型为父类了,如果有多个父类,那将在继承关系中从下往上开始搜索,越接近上层的优先级越低。即使方法调用传入的才参数值为 null 时,这个规则仍然适用。我们把 sayHello(Object arg) 也注释掉,输出将会变为:

hello char ...


        7 个重载方法已经被注释得只剩一个了,可见变长参数的重载优先级是最低的,这时候字符 'a' 被当做了一个数组元素。笔者适用的是 char 类型的变长参数,读者在验证时还可以选择 int 类型、Character 类型、Object 类型等变长参数重载来把上面的过程重新演示一遍。

        代码清单 8-7 演示了编译期间选择静态分派目标的过程,这个过程也是 Java 语言实现方法重载的本质。演示所用的这段程序属于很极端的例子,除了用做面试题为难求职者以外,在实际工作中几乎不能有实际用途。笔者拿来做演示仅仅是用于讲解重载时目标方法选择的过程,大部分情况下进行这样极端的重载都可算是真正的 “关于茴香豆的茴有几种写法的研究”。无论对重载的认识有多么深刻,一个合格的程序员都不应该再实际应用中写出如此极端的重载代码。

        另外还有一点读者可能比较容易混淆:笔者讲述的解析与分派这两者之间的关系并不是二选一的排他关系,它们是不同层次上去筛选、确定目标方法的过程。例如,前面说过,静态方法会在类加载期就进行解析,而静态方法显然也是可以拥有重载版本的,选择重载版本的过程也是通过静态分派完成的。

2.动态分派

        了解了静态分派,我们接下来看一下动态分派的过程,它和动态性的另外一个重要体现(注:有一种观点认为:因为重载是静态的,重写是动态的,所以只有重写算是多态性的体现,重载不算多态。笔者认为这种整理没有意义,概念仅仅是说明问题的一种工具而已)——重写(Override)有着密切的关联。我们还是用前面的 Man 和 Woman 一起 sayHello 的例子来讲解动态分派,请看代码清单 8-8 中所示的代码。

代码清单 8-8  方法动态分派演示

package org.fenixsoft.polymorphic;

/**

* 方法动态分派演示

*

*/

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

        这个运行结果相信不会出乎任何人的意料,对于习惯了面向对象思维的 Java 程序员会觉得这是完全理所当然的。现在的问题还是和前面的一样,虚拟机是如何知道要调用哪个方法的?

        虽然这里不可能再根据静态类型来决定,因为静态类型同样都是 Human 的两个变量 man 和 woman 在调用 sayHello() 方法时执行了不同的行为,并且变量 man 在两次调用中执行了不同的方法。导致这个现象的原因很明显,是这两个变量的实际类型不同,Java 虚拟机是如何根据实际类型来分派方法执行版本的呢?我们使用 javap 命令输出这段代码的字节码,尝试从中寻找答案,输出结果如代码清单 8-9 所示。代码清单 8-9  main() 方法的字节码
        0 ~ 15 行的字节码是准备动作,作用是建立 man 和 woman 的内存空间、调用 Man 和 Woman 类型的实例构造器,将这两个实例的引用存放在第 1、2 个局部变量表 Slot 之中,这个动作也就对应了代码中的这两句:

 

 

 
  1. Human man = new Man();

  2. Human woman = new Woman();


        接下来的 16 ~ 21 句是关键部分、16、20 两句分别把刚刚创建好的两个对象的引用压入到栈顶,这两个对象是将要执行的sayHello() 方法的所有者,称为接收者 (Receiver);17 和 21 句是方法调用指令,这两条调用指令但从字节码角度来看,无论是指令(都是 invokevirtual)还是参数(都是常量池中第 22 项的常量,注释显示了这个常量是 Human.sayHello() 的符号引用)完全一样的,但是这两句指令最终执行的目标方法并不相同。原因就需要从 invokevirtual 指令的多态查找过程开始说起,invokevirtual 指令的运行时解析过程大致分为以几个步骤:

 

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

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

3.单分派与多分派

        方法的接收者与方法的参数统称为方法的宗量,这个定义最早应该来源于《Java 与模式》一书。根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选中,多分派则根据多于一个宗量对目标方法进行选择。

        单分派和多分派的定义读起来拗口,从字面上看也比较抽象,不过对照实例看就不难理解了。代码清单 8-10 中列举了一个 Father 和 Son 一起来做出 “一个艰难的决定” 的例子。

代码清单 8-10  单分派和多分派

    public static void main(java.lang.String[]);

    flags: ACC_PUBLIC, ACC_STATIC

    Code:

    stack=2, locals=3, args_size=1

    0: new #16 // class org/fenixsoft/polymorphic/DynamicDispatch$Man

    3: dup

    4: invokespecial #18 // Method org/fenixsoft/polymorphic/DynamicDispatch$Man."<init>":()V

    7: astore_1

    8: new #19 // class org/fenixsoft/polymorphic/DynamicDispatch$Woman

    11: dup

    12: invokespecial #21 // Method org/fenixsoft/polymorphic/DynamicDispatch$Woman."<init>":()V

    15: astore_2

    16: aload_1

    17: invokevirtual #22 // Method org/fenixsoft/polymorphic/DynamicDispatch$Human.sayHello:()V

    20: aload_2

    21: invokevirtual #22 // Method org/fenixsoft/polymorphic/DynamicDispatch$Human.sayHello:()V

    24: new #19 // class org/fenixsoft/polymorphic/DynamicDispatch$Woman

    27: dup

    28: invokespecial #21 // Method org/fenixsoft/polymorphic/DynamicDispatch$Woman."<init>":()V

    31: astore_1

    32: aload_1

    33: invokevirtual #22 // Method org/fenixsoft/polymorphic/DynamicDispatch$Human.sayHello:()V

    36: return

/**

* 单分派、多分派演示

*

*/

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 harChoice(_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 harChoice(_360 arg) {

System.out.println("son choose 360");

}

}

public static void main(String[] args) {

Father father = new Father();

Father son = new Son();

father.harChoice(new _360());

son.hardChoice(new QQ());

}

}


        运行结果:

  1. father choose 360

  2. son choose qq

        在 main 函数中调用了两次 hardChoice() 方法,这两次 hardChoice() 方法的选择结果在程序输出中已经显示得很清楚了。

        我们来看看编译阶段编译器的选择过程,也就是静态分派的过程。这时选择目标方法的依据有两点:一是静态类型是 Father 还是 Son,而是方法参数是 QQ 还是 360。这次选择结果的最终产物是产生了两条 invokevirtual 指令,两条指令的参数分别为常量池中指向 Father.hardChoice(360) 及 Father.hardChoice(QQ) 方法的符号引用。因为是根据两个宗量进行选择,所以Java 语言的静态分派属于多分派类型。

        再看看运行阶段虚拟机的选择,也就是动态分派的过程。在执行 “son.hardChoice(new QQ())” 这句代码时,更准确地说,是在执行这句代码所对应的 invokevirtual 指令时,由于编译期已经决定目标方法的签名必须为 hardChoice(QQ),虚拟机此时不会关心传递过来的参数 “QQ” 到底是 “腾讯QQ” 还是 “奇瑞QQ”,因为这时参数的静态类型、实际类型都对方法的选择不会构成任何影响,唯一可以影响虚拟机选择的因素只有此方法的接收者的实际类型是 Father 还是 Son。因为只有一个宗量作为选择依据,所以 Java 语言的动态分派属于单分派类型。

        根据上述论证的结果,我们可以总结依据:今天的 Java 语言是一门静态多分派、动态单分派的语言。强调 “今天的 Java 语言” 是因为这个结论未必会恒久不变,C# 在 3.0 及之前的版本与 Java 一眼的动态单分派语言,但在 C# 4.0 中引入了 dynamic 类型后,就可以很方便地实现动态多分派。

        按照目前 Java 语言的发展趋势,它并没有直接变为动态语言的迹象,而是通过内置动态语言(如 JavaScript)执行引擎的方式来满足动态性的需求。但是 Java 虚拟机层面上则不是如此,在 JDK 1.7 中实现的 JSR-292 里面就已经开始提供对动态语言的支持了,JDK 1.7 中新增的 invokedynamic 指令也成为了最复杂的一条方法调用的字节码指令。

  • 0
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值