第八章 虚拟机字节码执行引擎
8.1 概述
执行引擎是Java 虚拟机最核心的组成部分之一。虚拟机的执行引擎则是由自己实现的,因此可以自行制定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式。
在不同的虚拟机实现里面,执行引擎在执行Java代码的时候可能会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择,也可能两者兼备,甚至还可能会包含几个不同级别的编译器执行引擎。
所有的Java虚拟机的执行引擎输入、输出都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果
8.2 运行时栈帧结构
栈帧( Stack Frame )是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈( Virtual Machine Stack )的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行完成的过程,都对应着一个栈帧在虚拟机栈里面从人栈到出榜的过程。
在编译程序代码的时候,栈帧中需要多大的局部变量表,多深的操作数钱都已经完全确定了,并且写入到方法表的Code 属性之中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机实现。
对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧(Current Stack Frame),与这个栈帧相关联的方法称为当前方法( Current Method )。典型的栈帧结构如下:
-
局部变量表
局部变量表( Local Variable Table )是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量。在Java 程序编译为Class 文件时,就在方法的Code 属性的max locals数据项中确定了该方法所需要分配的局部变量表的最大容量。
局部变量表的容量以变量槽( Variable Slot,下称Slot )为最小单位,虚拟机规范中说到每个Slot 都应该能存放一个 boolean 、byte 、char 、short 、int、float 、reference 和returnAddress 类型的数据,这 8 种数据类型都可以使用32 位或更小的物理内存来存放,但其允许Slot 的长度可以随着处理器、操作系统或虚拟机的不同而发生变化。
一个Slot 可以存放一个32 位以内的数据类型, Java 中占用32 位以内的数据类型有boolean 、byte 、char 、short 、int、float 、reference 和returnAddress 共 8 种类型。
- reference 类型表示对一个对象实例的引用,虚拟机规范既没有说明它的长度,也没有明确指出这种引用应有怎样的结构。但虚拟机实现至少都应当能通过这个引用做到两点,一是从此引用中直接或间接地查栈到对象在Java 堆中的数据存放的起始地址索引,二是此引用中直接或间接地查栈到对象所属数据类型在方法区中的存储的类型信息。
- returnAddress 类型目前已经很少见了,它是为字节码指令jsr、jsrw 和 ret 服务的,指向了一条字节码指令的地址,很古老的Java 虚拟机曾经使用这几条指令来实现异常处理,现在已经由异常表代替。
对于64 位的数据类型,虚拟机会以高位对齐的方式为其分配两个连续的Slot 空间。Java 语言中明确的( reference 类型则可能是32 位也可能是64 位)64 位的数据类型只有 long 和double 两种。由于局部变量表建立在线程的堆栈上,是线程私有的数据,无论读写两个连续的Slot 是否为原子操作,都不会引起数据安全问题。
虚拟机通过索引定位的方式使用局部变量表,索引值的范围是从0 开始至局部变量表最大的Slot 数量。如果访问的是32 位数据类型的变量,索引n 就代表了使用第n 个Slot , 如果是64 位数据类型的变量, 则说明会同时使用n 和 n+1 两个Slot (不允许采用任何方式单独访问其中的某一个Solt)。
在方法执行时,虚拟机是使用局部变量表完成参数值到参数变量列表的传递过程的,如果执行的是实例方法(非static 的方法〉,那局部变量表中第0 位索引的Slot 默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字 “this” 来访问到这个隐含的参数。其余参数则按照参数表顺序排列,占用从1 开始的局部变量Slot , 参数表分配完毕后,再根据方法体内部定义的变量顺序和作用域分配其余的S lot 。
为了尽可能节省栈帧空间,局部变量表中的Slot 是可以重用的方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC 计数器的值已经超出了某个变量的作用域,那这个变量对应的Slot 就可以交给其他变量使用。
public static void main(String[] args)() { { byte[] placeholder = new byte[64 * 1024 * 1024]; } System.gc(); } // 参数中加上 -verbose:gc 后的程序垃圾回收信息 // [GC 66846K -> 65888K(125632K), 0.0009397 secs] // [Full GC 65888K -> 65746K(125632K), 0.0051574 secs]
上述代码实现了向内存填充了64MB 的数据, 然后通知虚拟机进行垃圾收集的共功能,按照上方描述,此处 placeholder 已经不可能再被访问,因此分配的内存应该回收但是虚拟机确并没有回收这部分内存。
public static void main(String[] args)() { { byte[] placeholder = new byte[64 * 1024 * 1024]; } int a = 0; System.gc(); } // 参数中加上 -verbose:gc 后的程序垃圾回收信息 // [GC 66401K -> 65778K(125632K), 0.0035471 secs) // [Full GC 65778K -> 218K(125632K), 0.0140596 secs)
上执行上述修改后,为placeholder 分配的内存被回收了。
原因分析:placeholder 能否被回收的根本原因是:局部变量表中的 Slot 是否还存有关于placeholder 数组对象的引用。
- 第一个代码虽然已经离开了placeholder 的作用域,但在此之后,没有任何对局部变量表的读写操作, 但placeholder 原本所占用的Slot 还没有被其他变量所复用,所以作为GC Roots 一部分的局部变量表仍然保持着对它的关联。
- 这种关联没有被及时打断,在绝大部分情况下影响都很轻微。但如果遇到一个方法,其后面的代码有一些耗时很长的操作,而前面又定义了占用了大量内存、实际上已经不会再使用的变量,手动将其设置为null 值(用来代替那句
int a= 0
,把变量对应的局部变量表Slot 清空)便不见得是一个绝对无意义的操作,这种操作可以作为一种在极特殊情形(对象占用内存大、此方法的栈帧长时间不能被回收、方法调用次数达不到 JIT 的编译条件)下的“奇栈”来使用。
类变量有两次赋初始值的过程,一次在准备阶段,赋予系统初始值;另外一次在初始化阶段,赋予程序员定义的初始值,因此即使在初始化阶段程序员没有为类变量赋值也没有关系,类变量仍然具有一个确定的初始值。
一个局部变量定义了但没有赋初始值是不能使用的,不要认为Java 中任何情况下都存在诸如整型变量默认为0,布尔型变量默认为false 等这样的默认值(注意基本类型的数组方式是对象,因此具默认值)。
public static void main(String[] args) { // 错误,未赋值的局部变量 int a; System.out.println(a); } // The local variable a may not have been initialized
-
操作数核
操作数栈( Operand Stack )也常称为操作栈,它是一个后人先出( Last ln First Out,LIFO )栈。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到Code 属性的 max_stacks 数据项中。当一个方法刚刚开始执行的时候, 这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈/人栈操作。
- 操作数栈的每一个元素可以是任意的Java 数据类型,包括long 和double。
- 32 位数据类型所占的栈容量为1, 64 位数据类型所占的栈容量为2。
- 在方法执行的任何时候,操作数栈的深度都不会超过在 max_stacks 数据项中设定的最大值。
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器要严格保证这一点,在类栈验阶段的数据流分析中还要再次验证这一点。
在概念模型中,两个栈帧作为虚拟机拢的元素,是完全相互独立的。但在大多虚拟机的实现里都会做一些优化处理,令两个梳帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样在进行方法调用时就可以共用一部分数据,无须进行额外的参数复制传递。
Java 虚拟机的解释执行引擎称为“基于栈的执行引擎”,其中所指的“栈”就是操作数栈。
-
动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接( Dynamic Linking ) 。
Class文件的常量池中存有大量的符号引用,字节码中的方法调用指令就以常量池中指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候就转化为直接引用,这种转化称为静态解析。另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接。
-
方法返回地址
当一个方法开始执行后,只有两种方式可以退出这个方法。
-
执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定,这种退出方法的方式称为正常完成出口( Normal Method Invocation Completion )。
-
在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java 虚拟机内部产生的异常,还是代码中使用 athrow 字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口( Abrupt Method Invocation Completion )。
一个方法使用异常完成出口的方式退出,是不会给它的上层调用者产生任何返回值的
无论采用何种退出方式,在方法退出之后,都需要返回到方法被调用的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层方法的执行状态。
- 方法正常退出时,调用者的PC 计数器的值可以作为返回地址,栈帧中很可能会保存这个计数器值。
- 方法异常退出时,返回地址是要通过异常处理器表来确定的,栈帧中一般不会保存这部分信息
方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有: 恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC 计数器的值以指向方法调用指令后面的一条指令等。
-
-
附加信患
虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试相关的信息,这部分信息完全取决于具体的虚拟机实现。在实际开发中,一般会把动态连接、方法返回地址与其他附加信息全部归为一类,称为核帧信息。
8.3 方法调用
方法调用并不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本( 即调用哪一个方法),暂时还不涉及方法内部的具体运行过程。在程序运行时,进行方法调用是最普遍、最频繁的操作,一切方法调用在 Class 文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的人口地址(相当于之前说的直接引用) 。
-
解析
所有方法调用中的目标方法在Class 文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的,即调用目标在程序代码写好、编译器进行
编译时就必须确定下来。这类方法的调用称为解析( Resolution ) 。 在Java 语言中符合“编译期可知,运行期不可变”这个要求的方法,主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问。对应的在Java 虚拟机里面提供了5 条方法调用字节码指令:
- invokestatic :调用静态方法。
- invokespecial :调用实例构造器<init> 方法、私有方法和父类方法。
- invokevirtual :调用所有的虚方法。
- invokeinterface :调用接口方法,会在运行时再确定一个实现此接口的对象。
- invokedynamic :先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的4 条调用指令,分派逻辑是固化在Java 虚拟机内部的,而invokedynamic 指令的分派逻辑是由用户所设定的引导方法决定的。
只要能被invokestatic 和invokespecial 指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法、私有方法、实例构造器、父类方法4 类它们在类加载的时候就会把符号引用解析为该方法的直接引用。这些方法可以称为非虚方法,与之相反,其他方法称为虚方法(除去final 方法)。
// 方法静态解析演示 public class StaticResolution { // 没有任何手段可以覆盖或隐藏静态方法 sayHello 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 方法是一种非虚方法。
解析调用一定是个静态的过程,在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为可确定的直接引用z 不会延迟到运行期再去完成。
-
分派
Java 是一门面向对象的程序语言(具备特征:继承、封装和多态)。
1)静态分派
{collapse}
{collapse-item label=“方法静态分派演示” }// 方法静态分派演示 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); // 输出:hello,guy! sr.sayHello(woman); // 输出:hello,guy! } }
代码中的“ Human ”称为变量的静态类型( Static Type ),或者叫做的外观类型( Apparent Type ),后面的“ Man ”则称为变量的实际类型( Actual Type )。
静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生, 变量本身的静态类型不会被改变,井且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。
main() 里面的两次
sayHello()
方法调用,在方法接收者已经确定是对象“ sr ”后,使用哪个重载版本,就完全取决于传人参数的数量和数据类型。虚拟机(准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。并且静态类型是编译期可知的,因此,在编译阶段, Javac 编译器会根据参数的静态类型决定使用哪个重载版本,所以选择了sayHello(Human)
作为调用目标。 所有依赖静态类型来定位方法执行版本的分派动作称为静态分派。静态分派的典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。编译器虽然能确定出方法的重载版本,但在很多情况下这个重载版本并不是“唯一的”,往往只能确定一个“更加合适的”版本。
{collapse}
{collapse-item label=“重载方法匹配优先级” }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
注释掉
sayHello(char arg)
方法,输出:hello int
, 发生发生了一次自动类型转换a ’除了可以代表一个字符串,还可以代表数字97 (字符 ‘a’ 的Unicode 数值为十进制数字97 )。 继续注释掉
sayHello(int arg)
方法,输出:hello long
,发生了两次自动类型转换, ‘a’ 转型为整数97 之后,进一步转型为长整数97L【按照 char->int->long->float->double 的顺序转型进行】 继续注释掉
sayHello(long arg)
方法,输出:hello Character
,发生了一次自动装箱。 继续注释掉
sayHello(Character arg)
方法,输出:hello Serializable
,自动装箱之后,找到了装箱类实现了的接口类型(java.lang.Serializable),所以又发生一次自动转型。 继续注释掉
sayHello(Serializable arg)
方法,输出:hello Object
,char 装箱后转型为父类,若有多个父类,那将在继承关系中从下往上开始搜索,越接近上层的优先级越低。 继续注释掉
sayHello(Object arg)
方法,输出:hello char ...
, 可见变长参数的重载优先级是最低的,这时候字符 ‘a’ 被当做了一个数组元素。 分派这两者之间的关系并不是二选一的排他关系,它们是在不同层次上去筛选、确定目标方法的过程。如,静态方法会在类加载期就进行解析,而静态方法显然也是可以拥有重载版本的,选择重载版本的过程也是通过静态分派完成的。
2)动态分派
{collapse}
{collapse-item label=“方法动态分派演示” }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 man = new Woman(); man.sayHello(); // 输出:woman say hello } }
使用 javap -verbose DynamicDispatch 可以看到以下输出:
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 #2 // class DynamicDispatch$Man 3: dup 4: invokespecial #3 // Method DynamicDispatch$Man."<init>":()V 7: astore_1 8: new #4 // class DynamicDispatch$Woman 11: dup 12: invokespecial #5 // Method DynamicDispatch$Woman."<init>":()V 15: astore_2 16: aload_1 17: invokevirtual #6 // Method DynamicDispatch$Human.sayHello:()V 20: aload_2 21: invokevirtual #6 // Method DynamicDispatch$Human.sayHello:()V 24: new #4 // class DynamicDispatch$Woman 27: dup 28: invokespecial #5 // Method DynamicDispatch$Woman."<init>":()V 31: astore_1 32: aload_1 33: invokevirtual #6 // Method DynamicDispatch$Human.sayHello:()V 36: return }
在上述输出中16 、20(文中) 两句分别把刚刚创建的两个对象的引用压到栈顶,这两个对象是将要执行的sayHello()方法的所有者,称为接收者( Receiver ) : 17 和21句是方法调用指令,这两条调用指令单从字节码角度来看,无论是指令(都是
invokevirtual
)还是参数(都是常量池中第22 项的常量,注释显示了这个常量是Human.sayHello()
的符号引用)完全一样的,但是这两句指令最终执行的目标方法并不相同。invokevirtual 指令的运行时解析过程:
- 找到操作数栈顶的第一个元素所指向的对象的实际类型,记作C 。
- 如果在类型C 中找到与常量中的描述符和简单名称都相符的方法, 则进行访问权限栈验,如果通过则返回这个方法的直接引用, 查找过程结束;如果不通过,则返回 java.lang.IIlegalAccessError 异常。
- 否则,按照继承关系从下往上依次对C 的各个父类进行第2 步的搜索和验证过程。
- 如果始终没有找到合适的方法, 则抛出 java.lang.AbstractMethodError 异常。
由于invo kevirtual 指令执行的第一步就是在运行期确定接收者的实际类型,所以折叠面板中中所说的压到栈顶的两次调用中的 invokevirtual 指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是Java 语言中方法重写的本质。这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派。
3)单分派与多分派
方法的接收者与方法的参数统称为方法的宗量,根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择, 多分派则是根据多于一个宗量对目标方法进行选择。
{collapse}
{collapse-item label=“单分派和多分派(实例分析)” }// 单分派、多分派演示 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()); // 输出:father choose 360 son.hardChoice(new QQ()); // 输出:son choose qq } }
静态分派的过程:这时选择目标方法的依据有两点:一是静态类型是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 语言是一门静态多分派、动态单分派的语言。
4)虚拟机动态分派的实现
由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正地进行如此频繁的搜索。
最常用的“稳定优化”手段就是为类在方法区中建立一个虚方法表( Vritual Method Table ,也称为vtable,与此对应的,在invokeinterface 执行时也会用到接口方法表一Inteface Method Table ,简称itable),使用虚方法表索引来代替元数据查找以提高性能。
单分派、多分派演示演示代码的虚方法表结构如下:
虚方法表中存放着各个方法的实际人口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址人口是一致的,都指向父类的实现人口。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。
为了程序实现上的方便,具有相同签名的方法,在父类、子类的虚方法表中都应当具有一样的索引序号,这样当类型变换时,仅需要变更查找的方法表,就可以从不同的虚方法表中按索引转换出所需的人口地址。
方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的方法表也初始化完毕。
-
动态类型语言支持
invokedynamic 指令是JDK 7 实现“动态类型语言”(Dynamically Typed Language )支持而进行的改进之一,也是为JDK8 可以顺利实现Lambda表达式的栈术准备。
1)动态类型语言
动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期,满足这个特征的语言有很多,常用的包括: JavaScript、PHP 、Python 、Ruby 等。在编译期就进行类型检查过程的语言(如 C++ 和 Java 等)就是最常用的静态类型语言。
out.println("Hello Word!");
假设上述代码是在Java 语言中,并且变量obj 的静态类型为
java.io.PrintStream
,name变量 obj 的实际类型就必须是 PrintStream 的子类才是合法的,否则不会运行成功,因为类型检查不合格。 假设上述代码是在 JavaScript 语言中,结果就不一样了,无论obj 具体是何种类型,只要这种类型的定义中确实包含有println(String)方法,那方法调用便可成功。
这种差别产生的原因是Java 语言在编译期间已将println(String)方法完整的符号引用生成出来,作为方法调用指令的参数存储到Class 文件中。这个符号引用包含了此方法定义在哪个具体类型之中、方法的名字以及参数顺序、参数类型和方法返回值等信息,通过这个符号引用,虚拟机可以翻译出这个方法的直接引用。
而在ECMAScript 等动态类型语言中,变量obj 本身是没有类型的,变量obj 的值才具有类型,编译时最多只能确定方法名称、参数、返回值这些信息,而不会去确定方法所在的具体类型(即方法接收者不固定)。“变量元类型而变量值才有类型”这个特点也是动态类型语言的一个重要特征。
动态语言和静态语言的优点
- 静态类型语言在编译期确定类型,最显著的好处是编译器可以提供严谨的类型检查,这样与类型相关的问题能在编码的时候就及时发现,利于稳定性及代码达到更大规模。
- 动态类型语言在运行期确定类型,这可以为开发人员提供更大的灵活性,某些在静态类型语言中需用大量“脆肿”代码来实现的功能,由动态、类型语言来实现可能会更加清晰和简洁,清晰和简洁通常也就意味着开发效率的提升。
2)JDK1.7 与动态类型
目前确实已经有许多动态类型语言运行于Java 虚拟机之上了,如Clojure 、Groovy 、Jython 和JRuby 等。
Java 虚拟机层面对动态类型语言的支持一直都有所欠缺,在Java虚拟机上实现的动态类型语言就不得不使用其他方式(如编译时留个占位符类型, 运行时动态生成字节码实现具体类型到占位符类型的适配〉来实现,这样势必让动态类型语言实现的复杂度增加,也可能带来额外的性能或者内存开销。因此在Java 虚拟机层面上提供动态类型的直接支持就成为了Java 平台的发展趋势之一。
3)java.lang.invoke 包
JDK 1.7 实现了JSR-292 ,新加入的 java.lang.invoke 包这个包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这种方式以外,提(共一种新的动态确定目标方法的机制,称为MethodHandle 。
实现一个带谓词的排序函数,在CIC++ 中常用的做法是把谓词定义为函数,用函数指针把谓词传递到排序方法。
void sort(int list[], const int size , int (*compare)(int, int))
Java 语言没有办法单独地把一个函数作为参数进行传递。另一种方式设计一个带有compare()方法的Comparator 接口,以实现了这个接口的对象作为参数。如
Collections.sort()
的函数定义为void sort(List list, Comparator c)
,但在拥有Method Handle 之后, Java 语言也可以拥有类似于函数指针或者委托的方法别名的工具了,演示如下:// JSR 292 MethodHandle基础用法演示 public class MethodHandleTest { static class ClassA { public void println(String s) { System.out.println(s); } } public static void main(String[] args) throws Throwable { Object obj = System.currentTimeMillis() % 5 != 0 ? System.out : new ClassA(); // 无论obj最终是哪个实现类,下面这句都能正确调用到println方法。 getPrintlnMH(obj).invokeExact("icyfenix"); } private static MethodHandle getPrintlnMH(Object reveiver) throws Throwable { // 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); } }
此时,Java 就可以写出类似于C++函数指针的函数 :
void sort(List list, MethodHandle compare)
MethodHandle 的使用方法和效果与 Reflection(反射) 有众多相似之处,不过,它们还是有以下这些区别:
- 从本质上讲, Reflection 和MethodHandle 机制都是在模拟方法调用,但Reflection 是在模拟Java 代码层次的方法调用,而MethodHandle 是在模拟字节码层次的方法调用。在 MethodHandles. lookup 中的3 个方法:findStatic()、findVirtual() 、findSpecial() 正是为了对应于 invokestatic 、invokevirtual & invokeinterface 和 invokespecial 这几条字节码指令的执行权限栈验行为,而这些底层细节在使用 Reflection API 时是不需要关心的。
- Reflection 中的
java.lang.reftect.Method
对象远比MethodHandle 机制中的java.lang.invoke.MethodHandle
对象所包含的信息多。前者是方法在Java 一端的全面映像, 包含了方法的签名、描述符以及方法属性表中各种属性的Java 端表示方式,还包含执行权限等的运行期信息。而后者仅仅包含与执行该方法相关的信息。用通俗的话来讲, Reflection 是重量级,而 MethodHandle 是轻量级。 - 由于MethodHandle 是对字节码的方法指令调用的模拟,所以理论上虚拟机在这方面做的各种优化(如方法内联),在MethodHandle 上也应当可以采用类似思路去支持,而通过反射去调用方法则不行。
- Reflection API 的设计目标是只为 Java 语言服务的,而 MethodHandle 则设计成可服务于所有Java 虚拟机之上的语言,其中也包括 Java 语言。
-
invokedynamic 指令
invokedynamic 指令与MethodHandle 机制的作用是一样的,都是为了解决原有4 条“ invoke*”指令方法分派规则固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码之中,让用户( 包含其他语言的设计者)有更高的自由度。
每一处含有invokedynamic 指令的位置都称做“动态调用点”( Dynamic Call Site ),这条指令的第一个参数为 CONSTANT_InvokeDynamic_info 常量,从这个新常量中可以得到3 项信息:引导方法( Bootstrap Method , 此方法存放在新增的BootstrapMethods 属性中)、方法类型( MethodType ) 和名称。引导方法是有固定的参数,并且返回值是
java.lang.invoke.CallSite
对象,这个代表真正要执行的目标方法调用。 根据 CONSTANT_InvokeDynamic_info 常量中提供的信息,虚拟机可以找到并且执行引导方法,从而获得一个CallSite 对象,最终调用要执行的目标方法。
由于 invokedynamic 指令所面向的使用者并非Java 语言,而是其他Java 虚拟机之上的动态语言,因此仅依靠Java 语言的编译器Javac 没有办法生成带有 invokedynamic 指令的字节码
-
掌控方法分派规则
invokedynamic 指令与前面4 条“ invoke*”指令的最大差别就是它的分派逻辑不是由虚拟机决定的,而是由程序员决定。
在Java 程序中, 可以通过“ super”关键字很方便地调用到父类中的方法,但如果要访问祖类的方法则很难实现,但使用 MethodHandle 可以做到。
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); // 查询父类方法,需要使用 findSpecial lookup().findSpecial(GrandFather.class,"thinking", mt, getClass()).invoke(this); } catch (Throwable ignored) { } } } public static void main(String[] args) { (new Test().new Son()).thinking(); } }
8.4 基于栈的字节码解释执行引擎
Java 虚拟机的执行引擎在执行Java 代码的时候都有解释执行(通过解释器执行)和编译执行(通过即时编译器产生本地代码执行)两种选择。
-
解释执行
Java 语言经常被人们定位为“解释执行”的语言,在 Java 初生的JDK 1.0 时代,这种定义还算是比较准确的,但 Java也发展出了可以直接生成本地代码的编译器,此时只有确定了谈论对象是某种具体的Java实现版本和执行引擎运行模式时,谈解释执行还是编译执行才会比较确切。
大部分的程序代码到物理机的目标代码或虚拟机能执行的指令集之前都需要执行下方的编译过程。
基于物理机、Java 虚拟机,或者非 Java 的其他高级语言虚拟机(HLLVM )的语言,大多都会遵循基于上方的现代经典编译原理的思路,在执行前先对程序源码进行词法分析和语法分析处理,把游、码转化为抽象语法树(Abstract Syntax Tree,AST)。
对于一门具体语言的实现来说,词法分析、语法分析以至后面的优化器和目标代码生成器都可以选择独立于执行引擎, 形成一个完整意义的编译器去实现,这类代表是 C/C++ 语言。也可以选择把其中一部分步骤(如生成抽象语法树之前的步骤)实现为一个半独立的编译器,这类代表是Java语言。又或者把这些步骤和执行引擎全部集中封装在一个封闭的黑匣子之中,如大多数的 JavaScript 执行器。
Java 语言中, Javac 编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这一部分动作是在Java 虚拟机之外进行的,而解释器在虚拟机的内部,所以Java 程序的编译就是半独立的实现。
-
基于栈的指令集与基于寄存器的指令集
Java 编译器输出的指令流,基本上是一种基于栈的指令集架构(Instruction Set Architecture, ISA),指令流中的指令大部分都是零地址指令,它们依赖操作数战进行工作。与之相对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的就是x86 的二地址指令集,说得通俗一些,就是现在我们主流PC 机中直接支持的指令集架构, 这些指令依赖寄存器进行工作。
优点:
- 基于栈的指令集主要的优点就是可移植,寄存器由硬件直接提供气程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。
- 如果使用核架构的指令集, 用户程序不会直接使用这些寄存器,就可以由虚拟机实现来自行决定把一些访问最频繁的数据(程序计数器、校顶缓存等)放到寄存器中以获取尽量好的性能,这样实现起来也更加简单一些。
- 才去架构的指令集还有一些其他的优点,如代码相对更加紧凑(字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数)、编译器实现更加简单(不需要考虑空间分配的问题,所需空间都在栈上操作)等。
缺点:
- 栈架构指令集的主要缺点是执行速度相对来说会稍慢一些。所有主流物理机的指令集都是寄存器架构也从侧面印证了这一点。
- 虽然栈架构指令集的代码非常紧凑,但是完成相同功能所需的指令数量一般会比寄存器架构多,因为出栈、入校操作本身就产生了相当多的指令数量。
- 更重要的是,栈实现在内存之中,频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。
- 尽管虚拟机可以采取栈顶缓存的手段,把最常用的操作映射到寄存器中避免直接内存访问,但这也只能是优化措施而不是解决本质问题的方法。
- 由于指令数量和内存访问的原因,所以导致了栈架构指令集的执行速度会相对较慢。
-
基于栈的解释器执行过程
源代码:
public int calc(){ int a = 100; int b = 200; int c = 300; return (a + b) * c; }
字节码表示如下,Javap 提示这段代码需要深度为 2 的操作数战和4 个Slot 的局部变量空间:
public int calc(); descriptor: ()I flags: ACC_PUBLIC 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
代码、操作数校和局部变量表的变化情况如下:
实际情况会和上面描述的概念模型差距非常大,这种差距产生的原因是虚拟机中解析器和即时编译器都会对输入的字节码进行优化。从这段程序的执行中也可以看出战结构指令集的一般运行过程,整个运算过程的中间变量都以操作数校的出栈、入栈为信息交换途径,符合我们在前面分析的特点。
8.5 小结
本章中,我们分析了虚拟机在执行代码时,如何找到正确的方法、如何执行方法内的字节码,以及执行代码时涉及的内存结构。
评论区