jdk源码解析(八)——Java虚拟机字节码执行引擎

在前面我们了解了jvm运行时数据区,那个jvm图中有执行引擎,那么今天就解释一下Java虚拟机字节码执行引擎。

1 定义


Java虚拟机字节码执行引擎是jvm最核心的组成部分之一,“虚拟机” 是一个相对于 “物理机” 的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上的,而虚拟机的执行引擎则是由自己实现的,因此可以自行制定指令集与执行引擎的结构体系,并且能够执行哪些不被硬件直接支持的指令集格式。

在 Java 虚拟机规范中制定了虚拟机字节码执行引擎的概念模型,这个概念模型称为各种虚拟机执行引擎的统一外观(Facade)。在不同的虚拟机实现里面,执行引擎在执行 Java 代码的时候可能会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备,甚至还可能会包含几个不同级别的编译器执行引擎。

但从外观上看起来,所有的 Java 虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果,下面将主要从概念模型的角度来讲解虚拟机的方法调用字节码执行


2 执行引擎的解释和作用(有什么用?什么时候用?)


类加载器将字节码载入内存之后,执行引擎以Java 字节码指令为单元,读取Java字节码。问题是,现在的java字节码机器是读不懂的,因此还必须想办法将字节码转化成平台相关的机器码(也就是系统能识别的0和1)。这个过程可以由解释器来执行,也可以有即时编译器(JIT Compiler)来完成。如图:

执行引擎的在全局的作用

执行引擎内部包括如下:


总结:简单来看jvm需要怎么个流程如下图:


3 运行时栈帧结构(与执行引擎什么关系需要知道)


栈帧(Stack Frame)是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈(Virtual Machine Stack)的栈元素。栈帧存储了方法的局部变量表操作数栈动态连接方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全确定了,并且写入到方法表的 Code 属性之中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),与这个栈帧相关联的方法称为当前方法(Current Method)。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作,在概念模型上,典型的栈帧结构如图 8-1 所示。


接下来详细讲解一下栈帧中的局部变量表、操作数栈、动态连接、方法返回地址等各个部分的作用和数据结构。


2.1 局部变量表(在运行时数据区什么位置,是什么?有什么用?)

局部变量表(Local Variable Table) 是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在 Java 程序编译为 Class 文件时,就在方法的 Code 属性的 max_locals 数据项中确定了该方法所需要分配的局部变量表的最大容量。
局部变量表的容量以变量槽(Variable Slot,下称 Slot)为最小单位,虚拟机规范中并没有明确指明一个 Slot 应占用的内存空间大小,只是很有导向性地说到每个 Slot 都应该能存放一个 boolean、byte、char、short、int、float、reference (:Java 虚拟机规范中没有明确规定 reference 类型的长度,它的长度与实际使用 32 还是 64 位虚拟机有关,如果是 64 位虚拟机,还与是否开启某些对象指针压缩的优化有关,这里暂且只取 32 位虚拟机的 reference 长度)或 returnAddress 类型的数据,这 8 种数据类型,都可以使用 32 位或更小的物理内存来存放,但这种描述与明确指出 “每个 Slot 占用 32 位长度的内存空间” 是有一些差别的,它允许 Slot 的长度可以随着处理器、操作系统或虚拟机的不同而发送变化。只要保证即使在 64 位虚拟机中使用了 64 位的物理内存空间去实现一个 Slot,虚拟机仍要使用对齐和补白的手段让 Slot 在外观上看起来与 32 位虚拟机中的一致。
既然前面提到了 Java 虚拟机的数据类型,在此再简单介绍一下它们。一个 Slot 可以存放一个 32 位以内的数据类型,Java 中占用 32 位以内的数据类型有 boolean、byte、char、short、int、float、reference 和 returnAddress 8 种类型。前面 6 种不需要多加解释,读者可以按照 Java 语言中对应数据类型的概念去理解它们(仅是这样理解而已,Java 语言与 Java 虚拟机中的基本数据类型是存在本质差别的),而第 7 种 reference 类型表示对一个对象实例的引用,虚拟机规范既没有说明他的长度,也没有明确指出这种引用应有怎样的结构。但一般来说,虚拟机实现至少都应当能通过这个引用做到两点,一是从此引用中直接或间接地查找到对象在 Java 堆中的数据存放的起始地址索引,二是此引用中直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息,否则无法实现 Java 语言规范中定义的语法约束约束。第 8 种即 returnAddress 类型目前已经很少见了,它是为字节码指令 jsr、jsr_w 和 ret 服务的,指向了一条字节码指令的地址,很古老的 Java 虚拟机曾经使用这几条指令来实现异常处理,现在已经由异常表代替。
对于 64 位的数据类型,虚拟机会以高位对齐的方式为其分配两个连续的 Slot 空间。Java 语言中明确的(reference 类型则可能是 32 位也可能是 64 位)64 位的数据类型只有 long 和 double 两种。值得一提的是,这里把 long 和 double 数据类型分割存储的做法与 “long 和 double 非原子性协定” 中把一次 long 和 double 数据类型读写分割为两次 32 位读写的做法有些类似,读者阅读到 Java 内存模型时可以互相对比一下。不过,由于局部变量建立在线程的堆栈上,是线程私有的数据,无论读写两个连续的 Slot 是否为原子操作,都不会引起数据安全问题。
虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从 0 开始至局部变量表最大的 Slot 数量。如果访问的是 32 位数据类型的变量,索引 n 就代表了使用第 n 个 Slot,如果是 64 位数据类型的变量,则说明会同时使用 n 和 n+1 两个 Slot。对于两个相邻的共同存放一个 64 位数据的两个 Slot,不允许采用任何方式单独访问其中的某一个,Java 虚拟机规范中明确要求了如果遇到进行这种操作的字节码序列,虚拟机应该在类加载的校验阶段抛出异常。
在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果执行的是实例方法(非 static 的方法),那局部变量表中第 0 位索引的 Slot 默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字 “this” 来访问到这个隐含的参数。其余参数则按照参数表顺序排列,占用从 1 开始的局部变量 Slot,参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的 Slot。
为了尽可能节省栈帧空间,局部变量中的 Slot 是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码 PC 计数器的值已经超出了某个变量的作用域,那这个变量对应的 Slot 就可以交给其他变量使用。不过,这样的设计除了节省栈帧空间以外,还会伴随一些额外的副作用,例如,在某些情况下,Slot 的复用会直接影响到系统的垃圾收集行为,请看代码清单。

package com.clazz;
/**
 * jvm 参数设置为:-verbose:gc 
 * @author mch
 *
 */
public class TestClzz {
	public static void main(String[] args) {  
	    byte[] placeholder = new byte[64 * 1024 * 1024];  
	    System.gc();  
	}  
}
上面的代码很简单:即向内存填充了 64 MB 的数据,然后通知虚拟机进行垃圾收集。我们在虚拟机运行参数中加上“-verbose:gc” 来看看垃圾收集的过程,发现在 System.gc() 运行后并没有回收这 64 MB 的内存,下面是运行的结果:

[GC 68516K->66144K(186880K), 0.0014354 secs]
[Full GC 66144K->66008K(186880K), 0.0127933 secs]
没有回收 placeholder 所占的内存能说得过去(full Gc回收之后的内存占用66008k所以说明没有被回收),因为在执行 System.gc() 时,变量 placeholder 还处于作用域之内,虚拟机自然不敢回收 placeholder 的内存。那我们把代码修改一下,如下:

package com.clazz;
/**
 * jvm 参数设置为:-verbose:gc 
 * @author mch
 *
 */
public class TestClzz {
	public static void main(String[] args) {  
		{
			byte[] placeholder = new byte[64 * 1024 * 1024];  
		}
	    System.gc();  
	}  
}

结果:

[GC 68516K->66120K(186880K), 0.0011906 secs]
[Full GC 66120K->66008K(186880K), 0.0093979 secs]

加入了花括号之后,placeholder 的作用域被限制在花括号之内,从代码逻辑上讲,在执行 System.gc() 的时候,placeholder 已经不可能再被访问了,但执行一下这段程序,会发现运行结果如下,还是有 64MB 的内存没有被回收(gc之后使用内存为6608k说明内存未被回收),这又是为什么呢?
在解释为什么之前,我们先对这段代码进行第二次修改,在调用 System.gc() 之前加入一行 “int a = 0;”,变成代码清单如下:
package com.clazz;
/**
 * jvm 参数设置为:-verbose:gc 
 * @author mch
 *
 */
public class TestClzz {
	public static void main(String[] args) {  
		{
			byte[] placeholder = new byte[64 * 1024 * 1024];  
		}
		int a =0;
	    System.gc();  
	}  
}
运行结果:

[GC 68516K->66176K(186880K), 0.0012137 secs]
[Full GC 66176K->472K(186880K), 0.0095775 secs]
这个修改看起来很莫名其妙,但运行一下程序,却发现这次内存真的被正确回收了(gc之后使用内存变为472k明显小于64m)。

在代码清单1 ~ 代码清单 3 中,placeholder 能否被回收的根本原因是:局部变量中的 Slot 是否还存在关于 placeholder 数组对象的引用。第一次修改中,代码虽然已经离开了 placeholder 的作用域,但在此之后,没有任何局部变量表的读写操作,placeholder 原本占用的 Slot 还没有被其他变量所复用,所以作为 GC Roots 一部分的局部变量表仍然保持着对它的关联。这种关联没有被及时打断,在绝大部分情况下影响都很轻微。但如果遇到一个方法,其后面的代码有一些耗时很长的操作,而前面又定义了占用了大量的内存、实际上已经不会再使用的变量,手动将其设置为 null 值(用来代替那句 int a=0,把变量对应的局部变量表 Slot 清空)便不见得是一个绝对无意义的操作,这种操作可以作为一种在极特殊情形(对象占用内存大、此方法的栈帧长时间不能被回收、方法调用次数达不到 JIT 的编译条件)下的 “奇技” 来使用。Java 语言的一本著名书籍《Practical Java》中把 “不使用的对象应手动赋值为 null” 作为一条推荐的编码规则
虽然代码清单 1 ~ 代码清单 3 的代码示例说明了赋 null 值的操作在某些情况下确实是有用的,但笔者的观点是不应当对赋 null 值的操作又过多的依赖,更没有必要把它当做一个普遍的编码规则来推广。原因有两点,从编码角度讲,以恰当的变量作用域来控制变量回收时间才是最优雅的解决方法,如代码清单 3 那样的场景并不多见。更关键的是,从执行角度来将,使用赋 null 值的操作来优化内存回收是建立在对字节码执行引擎概念模型的理解之上的,而概念模型与实际执行过程是外部看起来等效,内部看上去则可以完全不同。在虚拟机使用解释器执行时,通常与概念模型还比较接近,但经过 JIT 编译器后,才是虚拟机执行代码的主要方式,赋 null 值的操作在经过 JIT 编译优化后就会被消除掉,这时候将变量设置为 null 就是没有意义的。字节码被编译为本地代码后,对 GC Roots 的枚举也与解释执行时期有巨大差别,以前面例子来看,代码清单 2 在经过 JIT 编译后,System.gc() 执行时就可以正确回收掉内存,无须写成代码清单 3 的样子。
关于局部变量表,还有一点可能会对实际开发产生影响,就是局部变量不像前面介绍的类变量那样存在 “准备阶段”。通过之前的讲解,我们已经知道类变量有两次赋初始值的过程,一次在准备阶段,赋予系统初始化;另外一次在初始化阶段,赋予程序员定义的初始值。因此,即使在初始化阶段程序没有为类变量赋值也没有关系,类变量仍然具有一个确定的初始值。但局部变量就不一样,如果一个局部变量定义了但没有赋初始值是不能使用的,不要认为 Java 中任何情况下都存在诸如整型变量默认为 0,布尔型变量默认为 false 等这样的默认值。如代码清单 所示,

package com.clazz;
public class TestClzz {
	public static void main(String[] args) {  
	int a;
	System.out.println(a);
	}  
}

这段代码其实并不能运行,还好编译器能在编译期间就检查到并提示这一点,即便编译能通过或者手动生成字节码的方式制造出下面代码的效果,字节码校验的时候也会被虚拟机发现而导致类加载失败。


2.2 操作数栈

操作数栈(Operand Stack)也常称为操作栈,它是一个后入先出(Last In First Out,LIFO)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到 Code 属性的 max_stacks 数据项中。操作数栈的每一个元素可以是任意的 Java 数据类型,包括 long 和 double。32 位数据类型所占的栈容量为 1,64 位数据类型所占的栈容量为 2。在方法执行的任何时候,操作数栈的深度都不会超过在 max_stacks 数据项中设定的最大值。
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈 / 入栈操作。例如,在做算术运算的时候是通过操作数栈来进行的,又或者再调用其他方法的时候是通过操作数栈来进行参数传递的。
举个例子,整数加法的字节码指令 iadd 在运行的时候操作数栈中最接近栈顶的两个元素已经存入了两个 int 型的数值,当执行这个指令时,会将这两个 int 值出栈并相加,然后将相加的结果入栈。
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。再以上面的 iadd 指令为例,这个指令用于整型数加法,它执行时,最接近栈顶的两个元素的数据类型必须为 int 型,不能出现一个 long 和一个 float 使用 iadd 命令相加的情况。
另外,在概念模型中,两个栈帧作为虚拟机栈的元素,是完全相互独立的。但在大多虚拟机的实现里都会做一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用时就可以共用一部分数据,无须进行额外的参数复制传递,重叠的过程如图 8-2 所示。


Java 虚拟机的解释执行引擎称为 “基于栈的执行引擎”,其中所指的 “栈” 就是操作数栈。(基于栈的代码过程稍后在讲解)


2.3  动态链接

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


2.4 方法返回地址

当一个方法开始执行后,只有两种方式可以退出这个方法第一种方式是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口(Normal Method Invocatino Completion)。
另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是 Java 虚拟机内部产生的异常,还是代码中使用 athrow 字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口(Abrupt Method Invocation Completion)。一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的。
无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。一般来说,方法正常退出时,调用者的 PC 计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。而方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息。
方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作又:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整 PC 计数器的值以指向方法调用指令后面的一条指令等


2.5 附加信息

虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试相关的信息,这部分信息完全取决于具体的虚拟机实现。在实际开发中,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为栈帧信息。


4 方法调用

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


4.1 解析

继续前面关于方法调用的话题,所有方法调用中的目标方法在 Class 文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法的调用称为解析(Resolution)。
在 Java 语言中符合 “编译期可知,运行期不可变” 这个要求的方法,主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析。
与之相对应的是,在 Java 虚拟机里面提供了 5 条方法调用字节码指令,分别如下。

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

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

package com.clazz;
/** 
 * 方法静态解析演示 
 * 
 */  
public class StaticResolution {  
  
    public static void sayHello() {  
        System.out.println("hello world");  
    }  
      
    public static void main(String[] args) {  
        StaticResolution.sayHello();  
    }  
}  
使用 javap 命令查看这段程序的字节码,会发现的确是通过 invokestatic 命令来调用 sayHello() 方法的。

Java 中的非虚方法除了使用 invokestatic、invokespecial 调用的方法之外还有一种,就是被 final 修饰的方法。虽然 final 方法是使用 invokevirtual 指令来调用的,但是由于它无法被覆盖,没有其他版本,所以也无须对方法接收者进行多态选择,又或者说多态选择的结果肯定是唯一的。在 Java 语言规范中明确说明了 final 方法是一种非虚方法。
解析调用一定是个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用,不会延迟到运行期再去完成。而分配(Dispatch)调用则可能是静态的也可能是动态的,根据分派依据的宗量数可分为单分派和多分派。这两类分派方式的两两组合就构成了静态单分派、静态多分派、动态单分派、动态多分派 4 种分派组合情况。下面我们讲解分派


4.2 分派

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


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

package com.clazz;

/** 
 * 方法静态分派演示 
 * 
 */  
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 的重载呢?在解决这个问题之前,我们先按如下代码定义两个重要的的概念。
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); 
解释;  上面的实际类型变化——>将 new Man()改变成了 new Woman()那么使用的时候 man 实际是new Woman()

 上面的静态类型变化(Man)man  实际是类型的强转,所以静态类型不是 Human了而是强转之后的Man类型作为静态类型传递过去了,(Woman)man 同理。


解释了这两个概念,再回到代码清单 8-6 的样例代码中。main() 里面的两次 sayHello() 方法调用,在方法接收者已经确定是对象 “sr” 的前提下,使用哪个重载版本,就完全取决于传入擦你数的数量和数据类型。代码中刻意地定义了两个静态类型相同但实际类型不同的变量,但虚拟机(准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译期可知的,因此,在编译阶段,javac 编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了 sayHello(Human) 作为调用目标,并把这个方法的符号引用写到 main() 方法里的两条 invokevirtual 指令的参数中。
所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。另外,编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是 “唯一的”,往往只能确定一个 “更加适合的” 版本。这种模糊的结论在由 0 和 1 构成的计算机世界中算是比较 “稀罕” 的事情,产生这种模糊结论的主要原因是字面量不需要定义,所以字面量没有显式的静态类型,它的静态类型只能通过语言上的规则去理解和推断。代码清单 8-7 演示了何为 “更加适合的” 版本。

package com.clazz;
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 语言实现方法重载的本质。演示所用的这段程序属于很极端的例子,除了用做面试题为难求职者以外,在实际工作中几乎不能有实际用途。笔者拿来做演示仅仅是用于讲解重载时目标方法选择的过程,大部分情况下进行这样极端的重载都可算是真正的 “关于茴香豆的茴有几种写法的研究”。无论对重载的认识有多么深刻,一个合格的程序员都不应该再实际应用中写出如此极端的重载代码。
另外还有一点读者可能比较容易混淆:笔者讲述的解析与分派这两者之间的关系并不是二选一的排他关系,它们是不同层次上去筛选、确定目标方法的过程。例如,前面说过,静态方法会在类加载期就进行解析,而静态方法显然也是可以拥有重载版本的,选择重载版本的过程也是通过静态分派完成的。

注意:好好看一下这一块,能帮助我们更好的理解方法重载,方法重载也是有匹配优先级的,最先是精确匹配,然后是向上转型匹配,实现的接口,。。Object ,最后是可变参数,在确定方法版本的时候也会存在模糊匹配的情况。前提是不能精确匹配到。


4.2.2 动态分派

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

package com.clazz;
/** 
 * 方法动态分派演示 
 * 
 */  
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 命令输出这段代码的字节码,尝试从中寻找答案,输出结果如下图 所示。

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

Human man = new Man();  
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 语言中方法重写的本质。我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。

4.2.3 单分派与多分派

方法的接收者与方法的参数统称为方法的宗量,这个定义最早应该来源于《Java 与模式》一书。根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选中,多分派则根据多于一个宗量对目标方法进行选择。
单分派和多分派的定义读起来拗口,从字面上看也比较抽象,不过对照实例看就不难理解了。代码清单 8-10 中列举了一个 Father 和 Son 一起来做出 “一个艰难的决定” 的例子。

package com.clazz;

/** 
 * 单分派、多分派演示 
 * 
 */  
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());  
    }  
}  
运行结果:
father choose 360
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 指令也成为了最复杂的一条方法调用的字节码指令。
4.2.4 虚拟机动态分派的实现

前面介绍的分派过程,作为对虚拟机概念模型的解析基本上已经足够了,它已经解决了虚拟机在分派中 “会做什么” 这个问题。但是虚拟机 “具体是如何做到的”,可能各种虚拟机的实现都会有些差别。
由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正地进行如此频繁的搜索。面对这种情况,最常用的 “稳定优化” 手段就是为类在方法区中建立一个虚方法表(Virtual Method Table,也称为 vtable,与此对应的,在 invokeinterface 执行时也会用到接口方法表——Interface Method Table,简称 itable),使用虚方法表索引来代替元数据查找以提高性能。我们先看看但清单 8-10 所对应的虚方法表结构示例,如图 8-3 所示。


虚方法表中存放着各个方法的实际入口地址如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的;都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。图 8-3 中,Son 重写了来自 Father 的全部方法,因此 Son 的方法表没有指向 Father 类型数据的箭头。但是 Son 和 Father 都没有重写来自 Object 的方法,所以它们的方法表中所有从 Object 继承来的方法都指向了 Object 的数据类型。
为了程序实现上的方便,具有相同签名的方法,在父类、子类的虚方法表中都应该具有一样的索引序号,这样当类型变换时,仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需的入口地址。
方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。
上文中笔者说方法表是分派调用的 “稳定优化” 手段,虚拟机除了使用方法表之外,在条件允许的情况下,还会使用内联缓存(Inline Cache)和基于 “类型继承关系分析”(Class Hierarchy Analysis,CHA)技术的守护内联(Guarded Inlining)两种非稳定的 “激进优化” 手段来获得更高的性能。

4.3 动态类型语言支持

java 虚拟机的字节码指令集的数量从 Sun 公司的第一款 Java 虚拟机问世至 JDK 7 来临之前的十余年时间里,一直没有发生任何变化。随着 JDK 7 的发布,字节码指令集终于迎来了第一位新成员——invokedynamic 指令。这条新增加的指令是 JDK 7 实现 “动态类型语言” (Dynamically Typed Language)支持而进行的改进之一,也是为 JDK 8 可以顺序实现 Lambda 表达式做技术准备。在本节中,我们将详细讲解 JDK 7 这项新特性出现的前因后果和它的深远意义。

4.3.1 动态类型语言

在介绍 Java 虚拟机的动态类型语言支持之前,我们要先弄明白动态类型语言是什么?它与 Java 语言、Java 虚拟机有什么关系?了解 JDK 1.7 提供动态类型语言支持的技术背景,对理解这个语言特性是很有必要的。
什么是动态类型语言(注意:动态类型语言与动态语言、弱类型语言并不是一个概念,需要区别对待)?动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期,满足这个特征的语言有很多,常用的包括:APL、Clojure、Erlang、Groovy、JavaScript、Jython、Lisp、Lua、PHP、Prolog、Python、Ruby、Smalltalk 和 Tcl 等。相对的,在编译期就进行类型检查过程的语言(如 C++ 和 Java 等)就是最常用的静态类型语言。

4.3.2 JDK 1.7 与动态类型

目前确实已经有许多动态类型语言运行于 Java 虚拟机之上了,如 Clojure、Groovy、Jython 和 JRuby 等,能够在同一个虚拟机上可以达到静态类型语言的严谨性与动态类型语言的灵活性,这是一件很美妙的事情。
但遗憾的是,Java 虚拟机层面对动态类型语言的支持一直都有所欠缺,主要表现在方法调用方面:JDK 1.7 以前的字节码指令集中,4 条方法调用指令(invokevirtual、invokespecial、invokestatic、invokeinterface)的第一个参数都是被调用的方法的符号引用(CONSTANT_Methodref_info 或者 CONSTANT_InterfaceMethodref_info 常量),前面已经提到过,方法的符号引用在编译时产生,而动态类型语言只有在运行期才能确定接收者类型。这样,在 Java 虚拟机上实现的动态类型语言就不得不使用其他方式(如编译时留个占位符类型,运行时动态生成字节码实现具体类型到占位符类型的适配)来实现,这样势必让动态类型语言实现的复杂度增加,也可能带来额外的性能或者内存开销。尽管可以利用一些办法(如Call Site Caching)让这些开销尽量变小,但这种底层问题终归是应当在虚拟机层次上去解决才最合适,因此在 Java 虚拟机层面上提供动态类型的直接支持就成为了 Java 平台的发展趋势之一,这就是 JDK 1.7(JSR-292)中invokedynamic 指令以及 java.lang.invoke 包出现的技术背景。

4.3.3 java.lang.invoke 包

JDK 1.7 实现了 JSR-292,新加入的 java.lang.invoke 包(注:这个包在很长一段时间里称为 java.dyn,也曾经短暂更名为 java.lang.mh,如果在其他资料上看到这两个包名,可以把他们理解为 java.lang.invoke)就是 JSR-292 的一个重要组成部分,这个包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这种方式以外,提供一种新的动态确定目标方法的机制,称为 MethodHandle。这种表达方式也许不太好懂?那不妨把 MethodHandle 与 C/C++ 中的 Function Pointer,或者 C# 里面的 Delegate 类比一下。举个例子,如果我们要实现一个带谓词的排序函数,在 C/C++ 中常用的做法是把谓词定义为函数,用函数指针把谓词传递到排序方法,如下:

void sort(int list[], const int size, <strong>int (*compare)(int, int)</strong>)  
但 Java 语言做不到这一点,即没有办法把一个函数作为参数进行传递。普遍的做法是设计一个带有 compare() 方法的 Comparator 接口,以实现了这个接口的对象作为参数,例如 Collections.sort() 就是这样定义的:
void sort(List list, Comparator c) 
4.3.4  掌控方法分派规则
invokedynamic 指令与前面 4 条 “invoke*” 指令的最大差别就是它的分派逻辑不是由虚拟机决定的,而是由程序员决定。在介绍 Java 虚拟机语言支持的最后一个小结中,笔者通过一个简单例子(如代码清单 8-14 所示),帮助读者理解程序员在可以掌握方法分派规则之后,能做什么以前无法做到的事情。

package com.clazz;
/**
 * 掌控方法分派规则
 * @author mch
 *
 */
class GrandFather {  
    void thinking() {  
        System.out.println("i am grandfather");  
    }  
}  
  
class Father extends GrandFather {  
    void thinking() {  
        System.out.println("i am father");  
    }  
}  
  
class Son extends Father {  
    void thinking() {  
        // 请读者在这里填入适当的代码 (不能修改其他地方的代码)  
        // 实现调用祖父类的 thinking() 方法,打印 "i am grandfather"  
    }  
}  
在 Java 程序中,可以通过 “super” 关键字很方便地调用到父类中的方法,但如果要访问祖类的方法呢?
在 JDK 1.7 之前,使用纯粹的 Java 语言很难处理这个问题(直接生成字节码就很简单,如使用 ASM 等字节码工具),原因是在 Son 类的 thinking() 方法中无法获取一个实际类型是 GrandFather 的对象引用,而 invokevirtual 指令的分派逻辑就是按照方法接收者的实际类型进行分派,这个逻辑是固化在虚拟机中的,程序员无法改变。在 JDK 1.7 中,可以使用代码清单 8-15 中的程序来解决这个问题。
package com.clazz;
import static java.lang.invoke.MethodHandles.lookup;  

import java.lang.invoke.MethodHandle;  
import java.lang.invoke.MethodType;  
  
public class Test {  
  
    class GrandFather {  
        void thinking() {  
            System.out.println("i am grandfather");  
        }  
    }  
      
    class Father extends GrandFather {  
        void thinking() {  
            System.out.println("i am father");  
        }  
    }  
      
    class Son extends Father {  
        void thinking() {  
            try {  
                MethodType mt = MethodType.methodType(void.class);  
                MethodHandle mh = lookup().findSpecial(GrandFather.class,   
                        "thinking", mt, getClass());  
                mh.invokeExact(this);  
            } catch (Throwable e) {  
            }  
        }  
    }  
      
    public static void main(String[] args) throws Throwable {  
        (new Test().new Son()).thinking();  
    }  
}  
我打印的是:i am father (不知道为啥)


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

虚拟机是如何调用方法的内容已经讲解完毕,从本节开始,我们来探讨虚拟机是如何执行方法中的字节码指令的。上文中提到过,许多 Java 虚拟机的执行引擎在执行 Java 代码的时候都有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,在下面,我们先来探讨一下在解释执行时,虚拟机执行引擎是如何工作的。

5.1 解释执行

Java 语言经常被人们定位为 “解释执行” 的语言,在 Java 初生的 JDK 1.0 时代,这种定义还算是比较准确的,但当主流的虚拟机中都包含了即时编译器后,Class 文件中的代码到底会被解释执行还是编译执行,就成了只有虚拟机自己才能准确判断的事情。再后来,Java 也发展处了可以直接生成本地代码的编译器【如 GCJ(GNU Compiler for the Java)】,而 C/C++ 语言也出现了通过解释器执行的版本(如 CINT),这时候再笼统地说 “解释执行”,对于整个 Java 语言来说就成了几乎是没有意义的概念,只有确定了谈论对象是某种具体的 Java 实现版本和执行引擎运行模式时,谈解释执行还是贬义执行才会比较确切。
不论是解释还是编译,也不论是物理机还是虚拟机,对于应用程序,机器都不可能如人那样阅读、理解,然后就获得了执行能力。大部分的程序代码到物理机的目标代码或虚拟机能执行的指令集之前,都需要经过图 8-4 中的各个步骤。如果读者对编译原理的相关课程还有印象的话,很容易就会发现图 8-4 中下面那条分支,就是传统编译原理中程序代码到目标机器代码的生成过程,而中间的那条分支,自然就是解释执行的过程。
如今,基于物理机、Java 虚拟机,或者非 Java 的其他高级语言虚拟机(HLLVM)的语言,大多都会遵循这种基于现代经典编译原理的思路,在执行前先对程序源码进行词法分析和语法分析处理,把源码转化为抽象语法树(Abstract Syntax Tree,AST)。对于一门具体语言的实现来说,词法分析、语法分析以至于后面的优化器和目标代码生成器都可以选择独立于执行引擎,形成一个完整意义的编译器去实现,这类代表是 C/C++ 语言。也可以选择把其中一部分步骤(如生成抽象语法树之前的步骤)实现为一个半独立的编译器,这类代表是 Java 语言。又或者把这些步骤和执行引擎全部集中封装在一个封闭的黑匣子之中,如大多数的 JavaScript 执行器。


Java 语言中,Javac 编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这一部分动作是在 Java 虚拟机之外进行的,而解释器在虚拟机的内部,所以 Java 程序的编译就是半独立的实现。

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

Java 编译器输出的指令流,基本上(注:使用 “基本上”,是因为部分字节码指令会带有参数,而纯粹基于栈的指令集架构中应当全部都是零地址指令,也即是都不存在显式的参数。Java 这样实现主要是考虑了代码的可校验性)是一种基于栈的指令集架构(Instruction Set Architecture, ISA),指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作。与之相对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的就是 x86 的二地址指令集,说得通俗一些,就是现在我们主流 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 寄存器里面。
了解了基于栈的指令集与基于寄存器的指令集的区别后,读者可能会有进一步的疑问,这两套指令集谁更好一些呢?
应该这么说,既然两套指令集会同时并存和发展,那肯定是各有优势的,如果有一套指令集全面优于另外一套的话,就不会存在选择的问题了。
基于栈的指令集主要的有点就是可移植,寄存器由硬件直接提供(注:这里说的是物理机器上的寄存器,也有基于寄存器的虚拟机,如 Google Android 平台的 Dalvik VM。即使是基于寄存器的虚拟机,也希望把虚拟机寄存器尽量映射到物理寄存器上以获取尽可能高的性能),程序直接依赖硬件寄存器则不可避免地要受到硬件的约束。例如,现在 32 位 80x86 体系的处理器中提供了 8 个 32 位的寄存器,而 ARM 体系的 CPU(在当前的手机、PDA 中相当流行的一种处理器)则提供了 16 个 32 位的通用寄存器。如果使用栈架构的指令集,用户程序不会直接使用这些寄存器,就可以由虚拟机实现来自行决定把一些访问最频繁的数据(程序计数器、栈顶缓存等)放到寄存器中以获取尽量好的性能,这样实现起来也更加简单一些。栈架构的指令集还有一些其他的有点,如代码相对更加紧凑(字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数)、编译器实现更加简单(不需要考虑空间分配的问题,所需空间都在栈上操作)等。
栈架构指令集的主要缺点是执行速度相对来说会稍慢一些。所有主流物理机的指令集都是寄存器架构也从侧面印证了这一点。
虽然栈架构指令集的代码非常紧凑,但是完成相同功能所需的指令数量一般会比寄存器架构多,因为出栈、入栈操作本身就产生了相当多的指令数量。更重要的是,栈实现在内存之中,频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。尽管虚拟机可以采取栈顶缓存的手段,把最常用的操作映射到寄存器中避免直接内存访问,但这也只能是优化措施而不是解决本质问题的方法。由于指令数量和内存访问的原因,所以导致了栈架构指令集的执行速度会相对较慢。
5.3 基于栈的解释器执行过程

初步的理论知识已经讲解过了,本节准备了一段 Java 代码,看看在虚拟机中实际是如何执行的。前面曾经举过一个计算 “1+1” 的例子,这样的算术题目显然太过简单了,笔者准备了四则运算的例子,请看代码清单 8-16。

public int calc() {  
    int a = 100;  
    int b = 200;  
    int c = 300;  
    return (a + b) * c;  
}  
从 Java 语言的角度来看,这段代码没有任何解释的必要,可以直接使用 javap 命令看看它的字节码指令,如代码清单 8-17 所示
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  
javap 提示这段代码需要深度为 2 的操作数栈和 4 个 Slot 局部变量空间,笔者根据这些信息画了图 8-5 ~ 图 8-11 共 7 张图,用它们来描述代码清单 8-17 执行过程中的代码、操作数栈和局部变量表的变化情况。







上面的执行过程仅仅是一种概念模型,虚拟机最终会对执行过程做一些优化来提高性能,实际的运作过程不一定完全符合概念模型的描述……更准确地说,实际情况会和上面描述的概念模型差距非常大,这种差距产生的原因是虚拟机中解析器和即时编译器都会对输入的字节码进行优化,例如,在 HotSpot 虚拟机中,有很多以 “fast_” 开头的非标准字节码指令用于合并、替换输入的字节码以提升解释执行性能,而即时编译器的优化手段更加花样繁多。
不过,我们从这段程序的执行中也可以看出栈结构指令集的一般运行过程,整个运算过程的中间变量都以操作数栈的出栈、入栈为信息交换途径,符合我们在前面分析的特点。

总结:本次讲解,分析了虚拟机在执行代码时(执行引擎),如何找到正确的方法、如何执行方法内的字节码,以及执行代码涉及的内存结构。请仔细阅读并理解。



  • 3
    点赞
  • 15
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 1
    评论

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

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

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

敬业小码哥

你的鼓励是我的动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值