IT数码 购物 网址 头条 软件 日历 阅读 图书馆
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
图片批量下载器
↓批量下载图片,美女图库↓
图片自动播放器
↓图片自动播放器↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁
 
   -> Java知识库 -> 虚拟机字节码执行引擎 -> 正文阅读

[Java知识库]虚拟机字节码执行引擎

虚拟机字节码执行引擎

概述

执行引擎是Java虚拟机核心的组成部分之一。虚拟机一个相对于物理机的概念,这两种都有执行代码的能力,其区别是,物理机的执行引擎是建立在处理器,缓存,指令集和操作系统层面上的,而虚拟机的执行引擎则是软件自行实现的,所以可以不受物理条件约束,自定义指令集和执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。

在《Java虚拟机规范》中制定了Java虚拟机字节码执行引擎的概念模型,这个概念模型成为各大开发商的Java虚拟机执行引擎的统一外观 Facade 。在不同的虚拟机实现中,执行引擎在执行字节码的时候,通常会有解释执行(同过解释器执行)和编译器执行(通过即时编译器产生本地代码执行)两种选择,也有可能两者兼备,还有可能会有同时包含几个不同级别的即时编译器一起工作的执行引擎。但从外观上来看,所有的Java虚拟机的执行引擎输入、输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果,本文将主要从概念模型的角度来讲解虚拟机的方法调用和字节码执行。

运行时栈帧结构

Java虚拟机以方法作为最基本的执行单元, 栈帧(Stack Frame) 则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的 虚拟机栈(Virtual Machine Stack) 的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行结束的过程,都对应着一个栈帧在虚拟机栈里从入栈到出栈的过程。

每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。在编译Java程序源码的时候,栈帧中需要多大的局部变量表,需要多深的操作数栈就已经被计算出来,并且写入到方法表的Code属性之中了。所以,一个栈帧需要分配多少内存,并不会收到程序运行期变量数据的影响,而仅仅取决于程序源码和具体的虚拟机实现的栈内存布局形式。

一个线程中的方法调用链可能会很长,以Java程序的角度看,同一时刻,同一条线程里面,在调用堆栈的所有方法都同时处于执行状态。而对于执行引擎来讲,在活动线程中,只有位于栈顶的方法才是在运行的,只有位于栈顶的栈帧才是生效的,其被称为 当前方法(Current Method) 。执行引擎所运行的所有字节码指令都只针对当前栈帧进行操作,在概念模型上,典型的栈帧结构如下图所示:

上图就是虚拟机栈和栈帧的总体结构,接下来将会详细介绍栈帧中的局部变量表、操作数栈、动态连接、方法返回等各个部分的作用和数据结构。

局部变量表

局部变量表(Local Variables Table) 是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。在Java程序被编译为Class文件时,就在方法的Code属性的max_locals数据项中确定了该方法所需分配的局部变量表的最大容量。

局部变量表的容量以 变量槽(Variable Slot) 为最小单位,《Java虚拟机规范》中并没有明确指出一个变量槽应占用的内存空间大小,只是很有向导性的说道每个变量槽都应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据,这八种数据类型,都可以使用32位或更小的物理内存来存储,但这种描述与明确指出“每个变量槽应占用32位长度的内存空间”是有本质差别的,它允许变量槽长度可以随着处理器、操作系统或虚拟机实现的不同而发生变化,保证了即使在64位虚拟机中使用了64位的物理内存空间去实现一个变量槽,虚拟机仍要使用对齐和补百的手段让变量槽在外观上看起来与32位虚拟机中的一致。

既然前面提到了Java虚拟机的数据类型,在此对它们再简单介绍一下。一个变量槽可以存放一个32位以内的数据类型,Java中占用不超过32位存储空间的数据类型有:boolean、byte、char
、short、int、float、reference(其实Java虚拟机中没有明确规定reference类型的长度,它的长度与实际使用32位还是64位虚拟机有关,如果是64位虚拟机,还与是否开启某些对象指针压缩的优化有关,这里我们暂且只取32位虚拟机的reference长度)和returnAddress这八种类型。前面六种不需要多加解释,而第七种reference类型表示对一个对象实例的引用,《Java虚拟机规范》即没有说明它的长度,也没有明确指出这种引用应有怎样的结构。但是一般来说,虚拟机实现至少都应当能通过这个引用做到两件事情,一是从根据引用直接或间接查找到对象在Java堆中的数据存放的起始地址或索引,二是根据引用直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息,否则将无法实现《JAVA语言规范》。第八种returnAddres类型目前已经很少见了,它是为字节码指令jsr、jsr_w和ret服务的,指向了一条字节码指令的地址,某些很古老的Java虚拟机曾经使用这几条指令来实现异常处理时的跳转,但现在也已经全部改为采用异常表来替代了。

对于64位的数据类型,Java虚拟机会以高位对齐的方式为其分配两个连续的变量槽空间。JAVA语言中明确的64位的数据类型只有long和double两种。这里把long和double数据类型分割存储的做法与 long和double的非原子性协定 中允许把一次long和double数据类型读写分割为两次32位读写的做法有点类似。不过,由于局部变量表是建立在线程堆栈中的,属于线程私有的数据,无论读写两个连续的变量槽是否为原子操作,都不会引起数据竞争和线程安全问题。

Java虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0开始至局部变量表最大的变量槽数量。如果访问的是32位数据类型的变量,索引N就代表了使用第N个变量槽,如果访问的是一个64位数据类型的变量,则说明会同时使用第N和N+1两个变量槽。对于两个相邻的共同存放一个64位数据的变量槽,虚拟机不允许采用任何方式单独访问其中的某一个,《Java虚拟机规范》中明确要求了如果遇到进行这种操作的任何字节码序列,虚拟机就应该在类加载阶段中抛出异常。

当一个方法被调用时,Java虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程,即实参到形参的传递。如果执行的是实例方法(没有被static修饰的方法),那局部变量表中第0位索引的变量槽默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字this里访问到这个隐含的参数。其余参数则按照参数表顺序排列,占用从1开始的局部变量槽,参数表分配完毕后,在根据方法体内部定义的变量顺序和作用域分配其余的变量槽。

为了尽可能节省栈帧耗用的内存空间,局部变量表中的变量槽是可以重用的,方法体中定义的变量,其作用域不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的变量槽就可以交给其他变量来重用。不过,这样的设计除了节省栈帧空间以外,还会伴随有少量额外的副作用,例如某些情况下变量槽的复用会直接影响到系统的垃圾收集行为,请看如下代码:

public static void main(String[] args) {
        byte [] placeholder = new byte[64 * 1024 * 1024];
        System.gc();
    }

上面的代码很简单,向内存填充了64MB的数据,然后通知虚拟机进行垃圾收集。我们在虚拟机运行参数中加上 -verbose:gc 就可以看到垃圾收集的过程,发现在System.gc()后并没有回收掉这64MB数据,下面是运行的结果:

可见上面代码并没有回收掉placeholder所占的内存,这能说的过去,因为在执行垃圾回收的方法时,它还在方法的作用域内,虚拟机自然不敢回收掉placeholder的内存。我们把代码修改一下:

public static void main(String[] args) {
        {
            byte[] placeholder = new byte[64 * 1024 * 1024];
        }
        System.gc();
    }

加了花括号之后,placeholder的作用域已经被限制在了花括号以内,按道理来说是可以被回收掉的,但执行一下程序,可以看到其实并没有回收掉它的内存:

在解释为什么这样之前,我们先对这段代码进行二次修改:

public static void main(String[] args) {
        {
            byte[] placeholder = new byte[64 * 1024 * 1024];
        }
        int a = 0;
        System.gc();
    }

这个修改看起来很莫名其妙,但运行一下程序,却发现这次内存真的被正确回收了:


上面代码中,placeholder能否被回收的根本原因就是:局部变量表中的变量槽是否还存有关于placeholder数组对象的引用。第一次修改中,代码虽然离开了placeholder的作用域,但在此之后,在没有发生过任何对局部变量表的读写操作,placeholder原本所占的变量槽还没有被其他变量所复用,所以作为GC Roots一部分的局部变量表仍然保持着对它的关联。这种关联还没有被及时打断,绝大部分情况下影响都很轻微。但是如果遇到一个方法,其后面的代码有一些耗时很长的操作,而前面又定义了占用了大量内存但实际上已经不会再使用的变量,手动将其设置为null值(用来代替那句int a = 0,吧变量对应的局部变量槽清空)便不见得是一个绝对无意义的操作,这种操作可以作为一种在极特殊情形下(对象内存占用大,此方法的栈帧长时间不能被回收、方法调用次数达不到即时编译器的编译条件)下的奇技来使用。Java语言的一本非常著名的书籍《Practical Java》 中,把 “不使用的对象应手动赋值为null” 作为一条推荐的编码规则,但是没有解释具体原因,很长一段时间里都有读者对条规则感到疑惑。

虽然上面的代码说明了赋null操作在某些极端环境下确实是有用的,但还是不应当对赋null值操作有什么特别的依赖,更没有必要把它当做一个编码规则来推广,这在绝大部分情况下都是很多余的。从编码角度来讲,以恰当的作用域来控制变量回收时间才是最优雅的解决办法,如上面代码那样的场景除了做实验外几乎毫无用处。更关键的是,从执行角度来讲,使用赋null操作来优化内存回收是建立在对字节码执行引擎概念模型的理解之上的,在书的第六章介绍完字节码后,作者在末尾还撰写了一个小结 公有设计,私有实现 来强调概念模型与实际执行过程中,外部看起来是等效的,但内部则可能完全不一样。但虚拟机使用解释器执行时,通常与概念模型还会比较接近,但经过即时编译器施加了各种编译优化措施后,两者的差异将会非常的大,只保证程序执行的结果与概念一致。在实际情况中,即时编译才是虚拟机执行代码的主要方式,赋null值的操作在经过即时编译后几乎是一定会被当做无效操作消除掉的,这时候将变量设置为null的操作将是无意义的行为。字节码被即时编译为本地代码后,对GC Roots的枚举也与解释执行时期有显著差别,以前面的例子来看,经过第一次的修改代码在经过及时编译后,System.gc()执行时就可以正确地回收内存,根本不需要再增加一个赋值操作。

关于局部变量表,还有一点可能会对实际开发产生影响,就是局部变量不像前面介绍的类变量那样存在 “准备阶段” 。通过之前的学习,我们知道类的字段变量有两次赋初始值的过程,一次在准备阶段,赋予系统初始值;另外一次在初始化阶段,赋予程序员定义的初始值。因此即使在初始化阶段程序员没有为变量赋初始值,类变量仍然具有一个确定的初始值,不会产生歧义。但是局部变量不一样了,如果一个局部变量定义了但没有赋初始值,那它是完全不能使用的(比如在类中定义一个字段但不赋值,其他类实例化对象后可以引用到这个字段为null,但是在方法中定义一个变量不赋值引用却会报异常)。所以不要认为Java中任何情况下都存在诸如整型变量默认为0、布尔变量默认为false这样的默认值规则。如下代码所示:

public static void main(String[] args) {
        int a;
        System.out.println(a);
    }

这段代码就诠释了上面所说的话,这句代码在编译器就会检查到并提示出来,即便能通过编译或手动生成字节码的方式制造出下面代码的效果,在字节码校验的时候也会被虚拟机发现而导致类加载失败。

操作数栈

操作数栈(Operand Stack) 也被陈伟操作栈,它是一个 后入先出(Last In First Out Lifo) 栈。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks数据项中。操作数栈的每一个元素都可以是包括long和double在内的任意Java数据类型。32位数据类型所占的栈容量为1,64位数据类型所占的栈容量为2。Javac编译器的数据流分析工作保证了在方法执行的任何时候,操作数栈的深度都不会超过在max_stacks数据项中设定的最大值。

当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是入栈和出栈操作。譬如在做算术运算的时候是通过将运算涉及的操作数栈来进行方法参数的传递。举个例子,例如整数加法的自驾吗指令iadd,这条指令在运行的时候要求操作数栈中最接近栈顶的两个元素已经存入了两个int类型,当执行这个指令时,会把这两个int值出栈并相加,然后将相加的结果重新入栈。

操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器必须要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。再以上面的iadd指令为例,这个指令只能用于整数型的加法,它在执行时,最接近栈顶的两个元素必须为int类型,不能出现一个long和一个float使用iadd命令相加的情况。

另外在概念模型中,两个不同的栈帧作为不同方法的虚拟机栈的元素,是完全相互独立的。但是在大多虚拟机的实现里都会进行一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样做不仅节约了一些空间,更重要的是在进行方法调用时就可以直接共用一部分数据,无需进行额外的参数复制传递了,重叠的过程如下图所示:

Java虚拟机的解释执行引擎被称为 基于栈的执行引擎 ,里面的 “栈”就是操作数栈。后面会对基于栈的代码执行过程进行更详细的讲解,介绍它与更常见的基于寄存器的执行引擎有哪些区别。

动态连接

每个栈帧都包含一个指向运行时常量池中该帧栈所属方法的引用,持有这个引用是为了支持方法调用过程中的 动态连接(Dynamic Linking) 。通过之前的了解,我们知道Class文件里的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池里指向发方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就被转化为直接引用,这种转化被称为静态解析。另一部分将在每一次运行期间都转化为直接引用,这部分就被称为动态连接。关于这两个转化过程的具体内容,将在后面详细讲解。

方法返回地址

当一个方法开始执行后,只有两种方式退出这个方法。第一种是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者或主调方法),方法是否有返回值以及返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为 正常调用完成(Normal Method Invocation Completion)

另一种退出方式是在方法执行时遇到了异常,并且这个异常没有被捕获到。无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方式被称为 异常调用完成(Abrupt Method Invocation Completion) 。一个方法使用异常完成出口的方式退出,是没有返回值的。

无论采用何种退出方式,在方法退出后,都必须返回到最初方法被调用时的位置,程序才能正确执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层主调方法的执行状态。一般来说,方法正常退出后,主调方法的PC计数器的值就可以作为返回地址,栈帧中很可能就会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中就一般不会保存这部分信息。

方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把方法值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。这里写“可能”是由于这是基于概念模型的讨论,只有具体到某一款Java虚拟机的实现,会执行那些操作才能确定下来。

附加信息

《Java虚拟机规范》允许虚拟机实现增加一些规范里没有描述的信息到栈帧中,例如与调试、性能收集相关的信息,这部分信息完全取决于具体的虚拟机实现,这里不再详述。在讨论概念时,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息帧栈

方法调用

方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还未涉及到方法内部的具体运行过程。在程序运行时,方法调用是最普遍,最频繁的操作之一,但是之前说过,Class文件的编译过程不包含传统程序语言编译的连接步骤,一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址(也就是之前说的直接引用)。这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法调用过程变得相对复杂,某些调用需要在类加载期间,甚至到运行期间才能确定目标方法的直接引用。

解析

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

在Java语言中符合 编译器可知,运行期不可变 这个要求的方法,主要有静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法都适合在类加载阶段进行解析。

调用不类型的方法,字节码指令集里设计了不同的指令。在Java虚拟机支持下5条方法调用字节码指令,分别是:

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

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

下面代码演示了常见的解析调用的例子,例子中,静态方法sayHello()只可能属于类型StaticResolution,没有任何途径可以覆盖或隐藏这个方法。

/**
 * 方法静态解析演示
 * @author lin
 * @date 2021/7/6 22:19
 **/
public class StaticResolution {

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

    public static void main(String[] args) {
        StaticResolution.sayHello();
    }
}

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

而#5对应的则是常量池的

Java中的非虚方法除了使用invokestatic、invokespecial调用的方法之外还有一种,就是被final修饰的实例方法。虽然由于历史设计的原因,final方法是用invokevirtual指令调用的,但是因为它也无法被覆盖,没有其他版本的可能,所以也无需对方法接受者进行多态选择,又或者说多态选择的结果肯定是唯一性的。在《JAVA语言规范》中明确定义了被final修饰的方法是一种非虚方法。

解析调用一定是一个静态的过程,在编译期就完全确定,在类加载的阶段就会把涉及的符号引用全部转变为明确的直接引用,不必延迟到运行期再去完成。而另一种主要的方法调用形式: 分派(Dispatch) 调用则要复杂很多,它可能是静态的也可能是动态的,按照分派依据的宗量数可分为单分派和多分派。这两类分派方式两两组合就构成了静态单分派,静态多分派,动态单分派,动态多分派四种分派组合情况,下面我们来看看虚拟机中的方法分派是如何进行的。

分派

众所周知,Java是一门面向对象的程序语言,因为Java具备面向对象的三个基本特征:封装,继承,多态。本节讲解的分派调用过程将会揭示多态性特征的一些最基本的体现,如 重载重写 在Java虚拟机之中是如何实现的,这里的实现当然不是语法上该如何写,我们关心的依然是虚拟机如何确定正确的目标方法。

静态分派

分派(Dispatch) 这个词本身就具有动态性,一般不应用在静态语境中,这部分原本在英文原版的《Java虚拟机规范》和《JAVA语言规范》里的说法都是 Method Overload Resolution ,即应该归入上面 解析 里去讲解,但部分其他外文资料和国内翻译的许多中文资料都将这种行为成为 静态分派 ,所以在此特地说明一下,以免在大家阅读英文资料时遇到这两种说法产生疑惑。

为了解释静态分派和重载,不妨先看一下,下面代码的输出结果是什么。后面的话题将围绕这个类的方法来编写重载代码,以分析虚拟机和编译器确定方法版本的过程。代码如下:

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

可以看到运行结果为:

但是为什么虚拟机会选择执行参数类型为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)man);
sr.sayHello((Woman)woman);

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

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

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

需要注意Javac编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是唯一的,往往只能确定一个 相对更合适的 版本。 这种模糊的结论在由 0 和 1 构成的计算机世界中算是一个比较稀罕的事件,产生这种模糊结论的主要原因是字面量天数的模糊性,它不需要定义,所以字面量就没有显式的静态类型,它的静态类型只能通过语法、语法的规则去理解和推断。下面代码演示了何为 “更加合适的”版本:

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

而代码则会输出:

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

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

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

这个输出可能会让人摸不着头脑,一个字符与序列化有什么关系?出现 hello serializable 是因为,java.lang.Serializable是java.lang.Character类实现的一个接口,当自动装箱后还是找不到所属类型,但是找到了装箱类所实现的接口类型,所以紧接着又发生一次自动转换。char可以转换成int,但是Character是绝对不会转换为Integer的,它只能安全的转换为它实现的接口或者父类。Character还实现了另外一个接口java.lang.Comparable<Character`>,如果同时出现两个参数都是该类的实现的父类或接口时,由于此时它们的优先级是相同的,编译器无法确定要自动转换为哪种类型,会提示 类型模糊(Type Ambiguous) 并拒绝编译。程序必须在调用时显示地指定字面量的静态类型,如sayHello((Comparable<Character·>) ‘a’),才能编译通过。但如果大家愿意花费一点时间,绕过Javac编译器,自己去构造出表达相同语义的字节码,将会发现这是能够通过Java虚拟机的类加载校验,而且能够被Java虚拟机正常执行的,但是会选择Serializable还是Comparable<Character·>的重载方法并不能事先确定,这是《Java虚拟机规范》所允许的,在之前的文章介绍接口方法解析过程是曾经提到过。

下面继续注释掉sayHello(Serializable arg),输出会变为:

这时是char装箱后转换为父类了,如果有多个父类,那将在继承关系中从下往上开始搜索,越接近上层的优先级越低。即使方法调用传入的参数值为null时,这个规则依然适用。

我们把sayHello(Object arg)也注释掉,输出将会变为:

七个重载方法已经被注释得只剩一个了,可见变长参数的重载优先级是最低的,这时候字符’a’被当做了一个char[]数组的元素。这里使用的是char类型的变长参数,大家在验证时还可以选择int、Character、Object等类型的变长参数来把上面的过程重新折腾一遍。但是要注意的是,有一些在单个参数中能成立的自动转换,如char转换为int,在变长参数中是不成立的(重载中选择最合适方法的过程,可参见《JAVA语言规范》15.12.2节的相关内容)。

上面代码演示了编译期间选择静态分派目标的过程,这个过程也是JAVA语言实现方法重载的本质。演示所用的这段程序无疑是很极端的例子,除了用做面试题为难求职者以外,在实际工作中几乎不可能存在任何有价值的用途,这里拿来演示仅仅是用于讲解重载时目标方法选择的过程,对绝大多数下进行这样的重载都可算作真正的 孔乙己问鲁迅的话《茴香豆的茴有几种写法》的研究 。无论对重载的认识有多么深刻,一个合格的程序员都不应该在实际应用中写这种晦涩难懂的重载代码。

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

动态分派

接下来我们来看一下Java语言里动态分派的实现过程,它与Java语言多态性的另外一个重要体现—— 重写(Override)(重写肯定是多态性的体现,但对于重载算不算多台,有一些概念上的争议,有观点认为必须是多个不同类对象对同一签名方法做出不同响应才算多态,也有观点认为只要使用同一形式的接口去实现不同类的行为就算多态。)有着很密切的关联。我们还是用前面的Man和Woman一起sayHello的例子来讲解动态分派,请看如下代码:

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

运行结果:

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

显然这里选择调用的方法版本是不可能再根据静态类型来决定的,因为静态类型同样都是Human的两个变量man和woman在调用sayHello()方法时产生了不同的行为,甚至变量man在两次调用中还执行了两个不同的方法。导致这个现象的原因很明显,是因为这两个的实际类型不同,Java虚拟机是如何根据实际类型来分派方法执行版本的呢?我们使用javap命令输出这段代码的字节码,输出结果如下:

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

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

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

那看来解决问题的关键还必须从invokevirtual指令本身入手,要弄清楚它是如何确定调用方法版本、如何实现多态查找来着手分析才行。根据《Java虚拟机规范》,invokevirtual指令的运行时解析过程(普通方法的解析过程,有一些特殊情况(签名多态性方法)的解析过程会稍有区别,但这是用于支持动态语言调用的,与本节话题关系不大)大致分为以下几步:

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

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

既然这种多态性的根源在于虚方法的调用指令invokevirtual的执行逻辑,那自然我们得出的结论只会对方法有效,对字段是无效的,因为字段不使用这条指令,事实上,在Java里面只有虚方法存在,字段永远不可能是虚的,换句话说,字段永远不参与多态,哪个类的方法访问某个名字的字段时,虽然在子类的内存中两个字段都会存在,但是子类的字段会遮蔽父类的同名字段。为了加深理解,大家来思考看看下面代码运行后会输出什么结果:

public class FieldHasNoPolymorphic {
    static class Father{
        public int money = 1;

        public Father(){
            money = 2;
            showMeTheMoney();
        }

        public void showMeTheMoney(){
            System.out.println("I am Father,i have $"+money);
        }
    }
    static class Son extends Father{
        public int money = 3;

        public Son (){
            money = 4;
            showMeTheMoney();
        }

        public void showMeTheMoney(){
            System.out.println("I am Son,i have $"+money);
        }
    }

    public static void main(String[] args) {
        Father guy = new Son();
        System.out.println("This guy has $"+guy.money);
    }
}

输出结果为:


输出两句都是 I am Son ,这是因为Son类在创建的时候,首先隐式调用了Father的构造函数,而Father构造函数中对showMeTheMoney()的调用时一次虚方法调用,实际执行的版本是Son::showMeTheMoney()方法,所以输出的的是 I am Son ,这点经过前面的分析相信大家是没有疑问的了。而这时候虽然父类的money字段已经被初始化成2了,但Son类的money字段却没有被初始化,它得等到自己的构造函数执行的时候才会初始化自己的money字段。而最后一句诗通过静态类型访问到了父类中的money,输出了2.

单分派与多分派

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

单分派和多分派的定义读起来拗口,从字面上看也比较抽象,不过对照着实例看并不难立即其含义,下面代码中举了一个Father和Son一起来做出 一个艰难的决定(具体可参考链接)的例子:

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

运行结果:

在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语言(书中指出是Java13之前)是一门静态多分派、动态单分派的语言。强调现在是因为这个结论未必会恒久不变,C#在3.0之前的版本与Java一样是动态单分派语言,但在C#4.0中引入了dynamic类型后,就可以很方便地实现动态多分派。JDK10时Java语法中出现var关键字,但请大家切勿将C#中的dynamic类型混淆,事实上Java的var与C#的var才是相对应的特性,它们与dynamic有着本质区别:var是在编译时根据声明中赋值符右侧的表达式类型来静态的推断类型,这本质是一种语法糖;而dynamic在编译时完全不关心类型是什么,等到运行的时候再进行类型判断。 Java语言中与C#的dynamic类型功能相对接近(只是接近,不是对等的)应该是在JDK9是通过JEP276引入的jdk.ydnalink模块,使用jdk.ydnalink可以实现在表达式中使用动态类型,Javac编译器会将这些动态类型的操作翻译为invokedynamic指令的调用点。

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

虚拟机动态分派的实现

前面介绍的分派过程,作为对Java虚拟机概念模型的基本解释上已经足够了,它已经解决了虚拟机在分派中 “会做什么”这个问题。但如果问Java虚拟机 “具体如何做到”的,答案则可能因各种虚拟机的实现不同会有些差别。

动态分派是执行非常频繁的动作,而且动态分派的方法版本选择过程需要在运行时在接收者类型的方法元数据中搜索合适的目标方法,因此,Java虚拟机实现基于执行性能的考虑,真正运行时一般不会如此频繁地去反复搜索类型元数据。面对这种情况,一种基础而常见的优化手段是为类型在方法区中建立一个 虚方法表(Virtual Method Table,也被称为vtable,与此对应的,在invokeinterface执行时也会用到接口方法表——Interface Method Table,简称itable) ,使用虚方法表索引来替代元数据查找以提高性能(这是相对于直接搜索元数据来说的,实际上在HotSpot虚拟机的实现中,直接去查itable和vtable已经算是最慢的一种分派,只在解释执行状态时使用,在即时编译时,会有更多的性能优化措施)。我们先看看上面QQ和360的代码所对应的虚方法表结构示例,如下图:

虚方法表中存放着各个方法的实际入口地址。如果某个方法字子类中没有被重写,那子类的虚方法表中的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类虚方法表中的地址也会被替换为指向子类实现版本的入口地址。在上图中,Son重写了来自Father的全部方法,因此Son的方法表没有指向Father类型数据的箭头。但是Son和Father都没有重写来自Object的方法,所以它们的方法表中所有从Object继承来的方法都指向了Object的数据类型。

为了程序实现方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序号,这样当类型变换时,仅需要变更查找的虚方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。虚方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的虚方法表也一同初始化完毕。

上面提到了查虚方法表是分派调用的一种优化手段,由于Java对象里面的方法默认(即不使用final修饰)就是虚方法,虚拟机除了使用虚方法表之外,为了进一步提高性能,还会使用 类型继承关系分析(Class Hierarchy Analysis,CHA)守护内联(Guarded Inlining)内联缓存(Inline Cache) 等多种非稳定的激进优化来争取更大的性能空间,关于这几种优化技术的原理和运作过程,将在以后讲解。

动态类型语言支持

Java虚拟机的字节码指令集的数量自从Sun公司的第一款Java虚拟机问世至今,二十余年间只新增过一条指令,它就是JDK7发布的字节码首位新成员——invokedynamic指令。这条新增加的指令时JDK7的项目目标:实现 动态类型语言(Dynamically Typed Language) 支持而进行的改进之一,也是为JDK8里可以顺利实现Lambda表达式而做的技术储备。

在本节中,我们将详细了解动态语言支持这项特性出现的前因后果和它的价值与意义。

动态类型语言

在介绍Java虚拟机的动态类型语言支持之前,我们要先弄明白动态类型语言是什么?它与Java语言、Java虚拟机有什么关系?了解Java虚拟机提供动态类型语言支持的技术背景,对理解这个语言特性是非常有必要的。

何谓动态类型语言?动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期进行的,满足这个特征的语言有很多,例如:JavaScript、Lua、PHP、Python等,那相对地,在编译期就进行类型检查过程的语言,譬如C++和Java等就是最常用的静态类型语言。

如果大家觉得上面的定义过于概念化,那我们通过两个例子以最浅显的方式来说明什么是“类型检查”和什么叫“在编译期还是在运行期进行”。首先看下面这段简单的Java代码,思考一下它是否能正常编译和运行?

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

上面这段Java代码能够正常编译,但运行的时候回出现NegativeArraySizeException异常。在《Java虚拟机规范》中明确规定了这个异常是一个 运行时异常(Runtime Exception) ,只要运行时不执行到这行代码就不会出现问题。 与运行时异常相对应的概念是连接时异常,例如很常见的NoClassDefFoundError便属于连接时异常,即使导致连接时异常的代码放在一条根本无法被执行到的路径分支上,类加载时也照样会抛出异常。

不过,在C语言里,语义相同的代码就会在编译期就直接报错,而不是等到运行时才出现异常:

int main(void){
	//GCC拒绝编译,报“size of array is negative”
	int i[1][0][-1];
	return 0;
}

由此看来,一门语言的哪一种经检查行为要在运行期进行,哪一种检查要在编译期进行并没有什么必然的因果逻辑关系,关键是在语言规范中人为设定的约定。

解答了什么是连接时、运行时,再来举一个列子解释什么是类型检查,例如下面这一句代码:

obj.println("hello world");

虽然大家都能看懂这行代码要做什么,但对于计算机来说,这一行没头没尾的代码是无法执行的,它需要一个具体的上下文中(譬如程序语言是什么、obj是什么类型)才有讨论的意义。

先假设这行代码是在Java语言中,并且变量obj的静态类型为java.io.PrintStream,那变量obj的实际类型就必须是PrintStream的子类(实现了PrintStream接口的类)才是合法的。否则,哪怕obj属于一个确实包含有print(String) 方法相同签名方法的类型,但只要它与PrintStream接口没有继承关系,代码依然不可能运行——因为类型检查不合法。

但是相同的代码在JavaScript中则不一样,无论obj具体是什么类型,继承关系如何,只要这种类型里确实含有这个方法,就能调用成功。

产生这种差别的根本原因是Java语言在编译器见就已将println(String)方法完整的符号引用生成出来,并作为方法调用的指令参数存储到Class文件中,例如下面这个样子:

invokevirtual #4 //Method java/io/PrintStream.println:(Ljava/lang/String;)V

这个符号引用包含了该方法定义在那个具体类型中、方法的名字以及参数顺序、参数类型和方法返回值,通过这个符号引用,Java虚拟机就可以翻译出该方法的直接引用。而JavaScript等动态类型语言与Java有一个核心的差异就是变量obj本身并没有类型,变量obj的值才具有类型,所以编译器在编译时最多只能确定方法名称、参数返回值这些信息,而不会去确定方法所在的具体类型(即方法接收者不固定)。“变量无类型而变量值才有类型”这个特点也是动态类型语言的一个核心特征。

了解了动态类型和静态类型语言的区别后,也许大家会有这两个语言谁更好的问题,个人认为这两个语言都有自己的优点。静态类型语言是能够在编译期确定变量类型,最显著的好处是编译器可以提供全面严谨的类型检查,这样与数据类型相关的潜在问题就能在编码时被及时发现,利于稳定性及让项目更容易达到更大的规模。而动态类型语言在运行期才确定类型,这可以为开发人员提供极大的灵活性,某些在静态类型语言中要花费大量臃肿代码来实现的功能,由动态类型语言去做可能会很清晰简洁,清晰简洁也就意味着开发效率的提升。

Java与动态类型

现在我们回到本节的主题,看看Java语言、Java、虚拟机与动态类型语言之间有什么关系。Java虚拟机毫无疑问是Java语言的运行平台,但它的使命并不限于此,早在1997年出版的《Java虚拟机规范》第一版就规划了这样一个愿景:“在未来,我们会对Java虚拟机进行适当的拓展,以便更好地支持其他语言运行与Java虚拟机之上。” 而目前确实有许多动态类型语言运行与Java虚拟机之上了,如Clojure、Groovy、Jython和Jruby等,能在同一个虚拟机之上可以实现静态类型语言的严谨与动态类型语言的灵活,这的确是一件很美妙的事情。

但遗憾的是Java虚拟机层面对动态类型语言的支持一直都还有所欠缺,主要表现在方法调用方面:JDk7以前的字节码指令集中,4条方法调用指令(invokevirtual、invokespecial、invokestatic、invokeinterface)的第一个参数都是被调用的方法的符号引用(CONSTANT_Methodref_info或者CONSTANT_InterfaceMethodref_info常量),前面已经提到过,方法的符号引用在编译时产生,而动态类型语言只有在运行期才能确定方法的接收者。只有,在Java虚拟机上实现的动态类型语言就不得不使用曲线救国的方式(如编译时留个占位符类型,运行时动态生成字节码实现具体类型到占位符类型的适配)来实现,但这样势必会让动态类型语言实现的复杂度增加,也会带来额外的性能和内存开销。内存开销是很显而易见的,方法调用产生的那一大堆的动态类就摆在那里。而其中最严重的性能瓶颈是在于动态类型方法调用时,由于无法确定调用对象的静态类型,而导致的方法内联无法有效进行。在之后的文章里我们会讲到方法内联的重要性,它是其他优化措施的基础,也可以说是最重要的一项优化。尽管也可以想一些办法(譬如调用点缓存)尽量缓解支持动态语言而导致的性能下降,但这种改善毕竟不是本质的。譬如有类似以下代码:

var arrays = {"abc",new ObjectX(),123,Dog,Cat,Car...}
for(item in arrays){
	item.sayHello();
}

在动态类型语言下这样的代码是没有问题,但由于在运行时array中的元素可以是任意类型,即使它们的类型中都有sayHello()方法,也肯定无法在编译优化的时候就确定具体sayHello()的代码在哪里,编译器只能不停地编译它所遇见的每一个sayHello()方法,并缓存起来供执行时选择、调用和内联,如果array数组中不同类型的对象很多,就势必会对内联缓存产生很大的压力,缓存的大小总是有限的,类型信息的不确定性导致了缓存内容不断被失效和更新,先优化过的方法也可能被不断替换而无法重复使用。所以这种动态类型方法调用的底层问题终归是在Java虚拟机层次上解决才最合适。因此,在Java虚拟机层面上提供动态类型的支持就成了Java平台发展必须解决的问题,这便是JSR-292提案中invokedynamic指令以及java.lang.invoke包出现的技术背景。

java.lang.invoke包

JDK7时新加入的java.lang.invoke包是JSR 292的一个重要组成部分,这个包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这条路之外,提供一种新的动态确定目标方法的机制,称为 方法句柄(Method Handle) 。可以把方法句柄与C/C++中的 函数指针(Method Handle) ,或者C#里的 委派(Delegate) 相互类比一下来理解。举个例子,如果我们要实现一个带谓词(谓词就是由外部传入的排序时比较大小的动作)的排序函数,在C/C++中的常用做法是把谓词定义为函数,用函数指针来把谓词传递到排序方法,像这样:

void sort(int list[],const int size,int(*compare)(int,int))

但在Java语言做不到这一点,没有办法单独把一个函数作为参数进行传递。普遍的做法是设计一个带有compare()方法的Compaator接口,以实现这个接口的对象作为参数,例如Java类库中的Collections::sore()方法就是这样定义的:
void sort(List list,Comparator c)
不过,在拥有方法句柄后,Java语言也可以拥有类似于函数指针或者委托的方法别名这样的工具了。下面代码演示了方法句柄的基本用法,无论obj是何种类型(临时定义的ClassA抑或是实现PrintStream接口的实现类System.out),都可以正确调用到println()方法:

public class MethodHandleTest {

    static class ClassA{
        public void println(String s){
            System.out.println(s);
        }
    }

    private static MethodHandle getPrintlnMH(Object reveiver) throws NoSuchMethodException, IllegalAccessException {
        //MethodType:代表方法类型,包含了方法的返回值(methodType()的第一个参数)和具体参数(methodType()第二个及以后的参数)
        MethodType mt = MethodType.methodType(void.class,String.class);
        //lookup()方法来自于MethodHandles.lookup,这句的作用是在指定类中查找符合给定的方法名称,方法类型,并且符合调用权限的方法句柄
        //因为这里调用的是一个虚方法,按照Java语言的规则,方法第一个参数是隐式的,代表该方法的接收者,也即this指向的对象,这个参数以前是放在参数列表中进行传递,现在提供了bindTo()方法来完成这件事情
        return lookup().findVirtual(reveiver.getClass(),"println",mt).bindTo(reveiver);
    }

    public static void main(String[] args) {
        Object obj = System.currentTimeMillis() % 2 == 0 ?System.out:new ClassA();
        try {
            //无论obj最终是哪个实现类,下面这句都能正确调用到println方法
            getPrintlnMH(obj).invokeExact("icyfenix");
        } catch (Throwable throwable) {
            throwable.printStackTrace();
        }
    }
}

方法getPrintlnMH()中实际上是模拟了invokevirtual指令的执行过程,只不过它的分派逻辑并非固化在Class文件的字节码上,而是通过一个由用户设计的Java方法来实现。而这个方法本身的返回值(MethodHandle对象),可以视为对最终调用方法的一个引用。以此为基础,有了MethodHandle就可以写出类似于C/C++那样的函数声明了:
void sort(List list,MethodHandle compare)

从上面的例子看来,使用MethodHandle并没有这么困难,不过看完它的用法之后,大家大概就会产生疑问,相同的事情,用反射不是早就可以实现了吗?

确实,仅站在Java语言的角度看,MethodHandle在使用方法和效果上与Reflection有很多相似之处。不过他们也有以下区别:

  • Reflection和MethodHandle机制本质上都是在模拟方法调用,但是Reflection是在模拟Java代码层次的方法调用,而MethodHandle是在模拟字节码层次的方法调用。在MethodHandles.Lookup上的三个方法findStatic()、findVirtual()、findSpecial()正是为了对应于invokestatic、invokevirtual(以及invokeinterface)和invokespecial这几条字节码指令的执行权限校验行为,而这些底层细节在使用Reflection API时是不用关心的
  • Reflection中的java.lang.reflect.Method对象远比MethodHandle机制中的java.lang.invoke.MethodHandle对象所包含的信息来得多。前者是方法在Java端的全面映象,包含了方法的签名、描述符以及方法属性表中各种属性的Java端表示方式,还包含执行权限等的运行期信息。而后者仅包含执行该方法的相关信息。用开发人员通俗的话讲,Reflection是重量级,MethodHandle是轻量级
  • 由于MethodHandle是对字节码的方法指令调用的模拟,那理论上虚拟机在这方面做的各种优化(如方法内联),在MethodHandle上也应当可以采用类似思路去支持(但目前实现还在继续完善中),而通过反射去调用方法则几乎不可能直接去实施各类调用点的优化措施。

MethodHandle与Reflection除了上面举例的区别外,最关键的一点还在于去掉前面讨论施加的前提 “仅站在Java语言的角度看” 之后:Reflection API的设计目标只是为Java语言服务的,而MethodHandle则设计为可服务于所有Java虚拟机之上的语言,其中也包括了Java语言而已,而且Java在这里并不是主角。

invokedynamic指令

JDK7为了更好的支持动态类型语言,引入了第五条方法调用的字节码指令invokedynamic,之后却一直没有再提起它,甚至把上面的示例代码反编译后也完全找不到invokedynamic的背影,这实在与invokedynamic作为Java诞生以来唯一一条新加入的字节码指令的地位不相符,那么invokedynamic到底有什么应用呢?

某种意义上说invokedynamic指令与MethodHandle机制的作用是一样的,都是为了解决原有四条 invoke* 指令方法分派规则完全固化在虚拟机之中的问题,把如何查找目标方法的决定权由虚拟机转嫁到具体用户代码中,让用户(广义的用户,包含其他语言的设计者)有更高的自由度。而且,它们两者的思路也是可类比的,都是为了达成同一个目的,只是一个用上层代码和API来实现,另一个用字节码和Class中其他属性、常量来完成。因此,如果签名MethodHandle的例子看懂了,相信大家理解invokedynamic指令并不困难。

每一处含有invokedynamic指令的位置都被称作 动态调用点(Dynamically-Computed Call Site) ,这条指令的第一个参数不再是代表方法符号引用的CONSTANT_Methodref_info常量,而是变为JDK7是新加入的CONSTANT_InvokeDynamic_info常量,从这个新常量中可以得到三项信息:引导方法(Bootstrap Method,该方法存放在新增的BootstrapMethods属性中),方法类型(MethodType)和名称 。引导方法是有固定的参数,并且返回值规定是java.lang.invoke.CallSite对象,这个对象代表了真正要执行的目标方法调用。根据 CONSTANT_InvokeDynamic_info常量中提供的信息,虚拟机可以找到并执行引导方法,从而获得一个CallSite对象,最终调用到执行的目标方法上。我们用一个实际的例子来解释这个过程:

public class InvokeDynamicTest {

    public static void main(String[] args) throws Throwable {
        INDY_BootstrapMethod().invokeExact("icyfenix");
    }

    public static void testMethod(String s){
        System.out.println("hello String:"+s);
    }

    public static CallSite BootstrapMethod(MethodHandles.Lookup lookup, String name, MethodType mt) throws NoSuchMethodException, IllegalAccessException {
        return new ConstantCallSite(lookup.findStatic(InvokeDynamicTest.class,name,mt));
    }

    private static MethodType MT_BootstrapMethod(){
        return MethodType.fromMethodDescriptorString("(Ljava/lang/invoke/MethodHandles$Lookup;Ljava/lang/String;Ljava/lang/invoke/MethodType;)Ljava/lang/invoke/CallSite;",null);
    }

    private static MethodHandle MH_BoostrapMethod() throws NoSuchMethodException, IllegalAccessException {
        return lookup().findStatic(InvokeDynamicTest.class,"BootstrapMethod",MT_BootstrapMethod());
    }

    private static MethodHandle INDY_BootstrapMethod() throws Throwable {
        CallSite cs = (CallSite) MH_BoostrapMethod().invokeWithArguments(lookup(),"testMethod",MethodType.fromMethodDescriptorString("(Ljava/lang/String;)V",null));
        return cs.dynamicInvoker();
    }
}

这段代码与前面MethodHandleTest的作用基本上都是一样的。这是为了方便编译器按照我的意愿来产生一段字节码。由于invokedynamic指令面向的主要服务对象并非Java语言,而是其他Java虚拟机之上的其他动态类型语言,因此,光靠Java语言的编译器Javac的话,在JDK7时甚至还完全没有办法生成带有invokedynamic指令的字节码(曾经有一个java.dyn.InvokeDynamic的语法糖可以实现,但后来被取消了),而到了JDK8引入了Lambda表达式和接口默认方法后,Java语言才算享受到了一点invokedynamic指令的好处,但用Lambda来解释invokedynamic指令运作就比较别扭,也无法与前面MethodHandle的例子对应类比,所以我采用一些变通的方法:John Rose(JSR 292的负责人,以前 Da Vinci Machine Project的Leader) 编写过一个把程序的字节码转换为使用invokedynamic的简单工具INDY来完成这件事,我们要使用这个工具来产生最终需要的字节码,因此上面代码中的方法名称不能随意改动,更不能把这几个方法合并到一起写,因为它们是要被INDY工具读取的。

把上面的代码编译,再使用INDY转换后重新生成的字节码字节码如下所示:

从main()方法的字节码中可见,原本的方法调用指令已经被替换为invokedynamic了,它的参数为第126项常量(第二个值为0的参数在虚拟机中不会直接用到,这与invokeinterface指令那个值为0的参数一样是占位用的,目的都是为了给常量池缓存留出足够的空间):

 2: invokedynamic #126,  0            // InvokeDynamic #0:testMethod:(Ljava/lang/String;)V

从常量池中可见,第126项常量显示 #126 = InvokeDynamic #0:#124 说明它是一项CONSTANT_InvokeDynamic_info类型常量,常量值中前面 #0 代表引导方法BootstrapMethods属性表的第0项(javap没有列出属性表的具体内容,不过示例中只有一个引导方法,即BootstrapMethods()),而后面的 #124 代表引用第124项类型为CONSTANT_NameAndType_info的常量,从这个常量中可以获取到方法名称和描述符,即后面输出的 testMethod:(Ljava/lang/String;)V

再看BootstrapMethod(),这个方法在Java源码中并不存在,是由INDY产生的,但是它的字节码很容易看懂,所有逻辑都是调用MethodHandles$Lookup的findStatic()方法,产生testMethod()方法的MethodHandle,然后用他创建一个ConstantCallSite对象。最后,这个对象返回给invokedynamic指令实现对testMethod()方法的调用,invokedynamic指令的调用过程就此宣告完成了。

掌控分派规则

invokedynamic指令与此前4条传统的invoke*指令最大的区别就是它的分派逻辑不是由虚拟机决定的,而是由程序员决定。下面一个简单的例子可以帮助大家理解掌控分派规则后,我们可以做到什么以前无法做到的事情(由于字数限制,下面的代码我将使用截图来展示):

在Java程序中,可以通过 super 关键字很方便的调用到父类中的方法,但如果要访问祖类的方法呢?

在拥有invokedynamic和java.lang.invoke包之前,使用纯粹的Java语言很难处理这个问题(使用ASM等字节码工具直接生成字节码当然还是可以处理的,但是这已经是在字节码而不是Java语言层面来解决问题了),原石是在Son类的thinking()方法中根本无法获取到一个实际类型是GrandFather的对象引用,而invokevirtual指令的分派逻辑是固定的,只能按照方法接受者的实际类型进行分派,这个逻辑完全固话在虚拟机中,程序员无法改变。

如果是JDK7 Update9之前,使用下面的代码就可以直接解决该问题:

这个逻辑在JDK7 Update9之后被视作一个潜在的安全性缺陷修正了,原因是必须保证findSpecial()查找方法版本时收到的访问约束(譬如对访问控制的限制、对参数类型的限制)应与invokespecial指令一样,两者必须保持精确对等,包括在上面的场景中它只能访问到其直接父类的方法版本。所以在JDK7 Update10修正之后,运行以上代码只能得到 i am father

而在新版本的JDK中,还是有解决办法的,查看MethodHandles.Lookup类的代码,就能发现需要进行哪些访问保护,在该API实现时是预留了后门的。访问保护是通过一个allowedModes的参数来控制,而且这个参数可以被设置成 TRUSTED 来绕开所有的保护措施。尽管这个参数只是在Java类库本身使用,没有开放给外部设置,但我们可以通过反射来轻易打破这种限制。由此,我们可以把Son类的thinking()方法改成下图所示:

运行代码则可以打印出正确结果:i am grandfather

由于篇幅原因,书中的8.5节将在后面展示。欢迎大家购买 周志明老师的《深入理解Java虚拟机》第三版!

  Java知识库 最新文章
计算距离春节还有多长时间
系统开发系列 之WebService(spring框架+ma
springBoot+Cache(自定义有效时间配置)
SpringBoot整合mybatis实现增删改查、分页查
spring教程
SpringBoot+Vue实现美食交流网站的设计与实
虚拟机内存结构以及虚拟机中销毁和新建对象
SpringMVC---原理
小李同学: Java如何按多个字段分组
打印票据--java
上一篇文章      下一篇文章      查看所有文章
加:2021-07-28 07:36:16  更:2021-07-28 07:38:29 
 
开发: C++知识库 Java知识库 JavaScript Python PHP知识库 人工智能 区块链 大数据 移动开发 嵌入式 开发工具 数据结构与算法 开发测试 游戏开发 网络协议 系统运维
教程: HTML教程 CSS教程 JavaScript教程 Go语言教程 JQuery教程 VUE教程 VUE3教程 Bootstrap教程 SQL数据库教程 C语言教程 C++教程 Java教程 Python教程 Python3教程 C#教程
数码: 电脑 笔记本 显卡 显示器 固态硬盘 硬盘 耳机 手机 iphone vivo oppo 小米 华为 单反 装机 图拉丁

360图书馆 购物 三丰科技 阅读网 日历 万年历 2024年5日历 -2024/5/9 3:02:45-

图片自动播放器
↓图片自动播放器↓
TxT小说阅读器
↓语音阅读,小说下载,古典文学↓
一键清除垃圾
↓轻轻一点,清除系统垃圾↓
图片批量下载器
↓批量下载图片,美女图库↓
  网站联系: qq:121756557 email:121756557@qq.com  IT数码