第8章 虚拟机字节码执行引擎
8.1 概述
从外观上来看,所有的Java虚拟机的执行引擎输入、输出都是一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果。
8.2 运行时栈帧结构
Java虚拟机以方法作为最基本的执行单元,“栈帧”(Stack Frame)则是用于支持虚拟机进行方法调用和方法执行背后的数据结构,它也是虚拟机运行时数据区中的虚拟机栈(Virtual MachineStack)的栈元素。栈帧存储了方法的局部变量表、操作数栈、动态连接和方法返回地址等信息。每一个方法从调用开始至执行结束的过程,都对应着一个栈帧在虚拟机栈里面从入栈到出栈的过程。
每一个栈帧都包括了局部变量表、操作数栈、动态连接、方法返回地址和一些额外的附加信息。在编译Java程序源码的时候,栈帧中需要多大的局部变量表,需要多深的操作数栈就已经被分析计算出来,并且写入到方法表的Code属性之中。换言之,一个栈帧需要分配多少内存,并不会受到程序运行期变量数据的影响,而仅仅取决于程序源码和具体的虚拟机实现的栈内存布局形式。
而对于执行引擎来讲,在活动线程中,只有位于栈顶的方法才是在运行的,只有位于栈顶的栈帧才是生效的,其被称为“当前栈帧”(Current Stack Frame),与这个栈帧所关联的方法被称为“当前方法”(Current Method)。
8.2.1 局部变量表
局部变量表(Local Variables Table)是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。
局部变量表的容量以变量槽(Variable Slot)为最小单位,《Java虚拟机规范》中并没有明确指出一个变量槽应占用的内存空间大小,只是很有导向性地说到每个变量槽都应该能存放一个boolean、byte、char、short、int、float、reference或returnAddress类型的数据。
一般来说,虚拟机实现至少都应当能通过这个引用做到两件事情,一是从根据引用直接或间接地查找到对象在Java堆中的数据存放的起始地址或索引,二是根据引用直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息,否则将无法实现《Java语言规范》中定义的语法约定。
对于64位的数据类型,Java虚拟机会以高位对齐的方式为其分配两个连续的变量槽空间。由于局部变量表是建立在线程堆栈中的,属于线程私有的数据,无论读写两个连续的变量槽是否为原子操作,都不会引起数据竞争和线程安全问题。
当一个方法被调用时,Java虚拟机会使用局部变量表来完成参数值到参数变量列表的传递过程,即实参到形参的传递。
为了尽可能节省栈帧耗用的内存空间,局部变量表中的变量槽是可以重用的,方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC计数器的值已经超出了某个变量的作用域,那这个变量对应的变量槽就可以交给其他变量来重用。不过,在某些情况下变量槽的复用会直接影响到系统的垃圾收集行为。
在实际情况中,即时编译才是虚拟机执行代码的主要方式,赋null
值的操作在经过即时编译优化后几乎是一定会被当作无效操作消除掉的,这时候将变量设置为null
就是毫无意义的行为。
关于局部变量表,还有一点可能会对实际开发产生影响,就是局部变量不像前面介绍的类变量那样存在“准备阶段”。即使在初始化阶段程序员没有为类变量赋值也没有关系,类变量仍然具有一个确定的初始值,不会产生歧义。但局部变量就不一样了,如果一个局部变量定义了但没有赋初始值,那它是完全不能使用的。
8.2.2 操作数栈
操作数栈(Operand Stack)也常被称为操作栈,它是一个后入先出(Last In First Out,LIFO)栈。同局部变量表一样,操作数栈的最大深度也在编译的时候被写入到Code属性的max_stacks
数据项之中。
当一个方法刚刚开始执行的时候,这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作。
操作数栈中元素的数据类型必须与字节码指令的序列严格匹配,在编译程序代码的时候,编译器必须要严格保证这一点,在类校验阶段的数据流分析中还要再次验证这一点。
另外在概念模型中,两个不同栈帧作为不同方法的虚拟机栈的元素,是完全相互独立的。但是在大多虚拟机的实现里都会进行一些优化处理,令两个栈帧出现一部分重叠。让下面栈帧的部分操作数栈与上面栈帧的部分局部变量表重叠在一起,这样做不仅节约了一些空间,更重要的是在进行方法调用时就可以直接共用一部分数据,无须进行额外的参数复制传递了。
8.2.3 动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接(Dynamic Linking)。
8.2.4 方法返回地址
当一个方法开始执行后,只有两种方式退出这个方法。第一种方式是执行引擎遇到任意一个方法返回的字节码指令;另外一种退出方式是在方法执行的过程中遇到了异常,并且这个异常没有在方法体内得到妥善处理。
无论采用何种退出方式,在方法退出之后,都必须返回到最初方法被调用时的位置,程序才能继续执行,方法返回时可能需要在栈帧中保存一些信息,用来帮助恢复它的上层主调方法的执行状态。
方法退出的过程实际上等同于把当前栈帧出栈,因此退出时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值(如果有的话)压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等。
8.3 方法调用
方法调用并不等同于方法中的代码被执行,方法调用阶段唯一的任务就是确定被调用方法的版本(即调用哪一个方法),暂时还未涉及方法内部的具体运行过程。
8.3.1 解析
所有方法调用的目标方法在Class文件里面都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化为直接引用,这种解析能够成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好、编译器进行编译那一刻就已经确定下来。这类方法的调用被称为解析(Resolution)。
在Java语言中符合“编译期可知,运行期不可变”这个要求的方法,主要有静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写出其他版本,因此它们都适合在类加载阶段进行解析。
调用不同类型的方法,字节码指令集里设计了不同的指令。在Java虚拟机支持以下5条方法调用字节码指令,分别是:
- invokestatic。用于调用静态方法。
- invokespecial。用于调用实例构造器<init>()方法、私有方法和父类中的方法。
- invokevirtual。用于调用所有的虚方法。
- invokeinterface。用于调用接口方法,会在运行时再确定一个实现该接口的对象。
- invokedynamic。先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。前面4条调用指令,分派逻辑都固化在Java虚拟机内部,而invokedynamic指令的分派逻辑是由用户设定的引
导方法来决定的。
只要能被invokestatic
和invokespecial
指令调用的方法,都可以在解析阶段中确定唯一的调用版本,Java语言里符合这个条件的方法共有静态方法、私有方法、实例构造器、父类方法4种,再加上被final
修饰的方法(尽管它使用invokevirtual
指令调用),这5种方法调用会在类加载的时候就可以把符号引用解析为该方法的直接引用。这些方法统称为“非虚方法”(Non-Virtual Method),与之相反,其他方法就被称为“虚方法”(Virtual Method)。
解析调用一定是个静态的过程,在编译期间就完全确定,在类加载的解析阶段就会把涉及的符号引用全部转变为明确的直接引用,不必延迟到运行期再去完成。而另一种主要的方法调用形式:分派(Dispatch)调用则要复杂许多,它可能是静态的也可能是动态的,按照分派依据的宗量数可分为单分派和多分派。
8.3.2 分派
1.静态分派
为了解释静态分派和重载(Overload),笔者准备了一段经常出现在面试题中的程序代码,读者不妨先看一遍,想一下程序的输出结果是什么。后面的话题将围绕这个类的方法来编写重载代码,以分析虚拟机和编译器确定方法版本的过程。
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)或者叫“运行时类型”(Runtime Type)。
静态类型和实际类型在程序中都可能会发生变化,区别是静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。
笔者猜想上面这段话读者大概会不太好理解,那不妨通过一段实际例子来解释,譬如有下面的代码:
// 实际类型变化
Human human = (new Random()).nextBoolean() ? new Man() : new Woman();
// 静态类型变化
sr.sayHello((Man) human)
sr.sayHello((Woman) human)
对象human的实际类型是可变的,编译期间它完全是个“薛定谔的人”,到底是Man还是Woman,必须等到程序运行到这行的时候才能确定。而human的静态类型是Human,也可以在使用时(如sayHello()方法中的强制转型)临时改变这个类型,但这个改变仍是在编译期是可知的,两次sayHello()方法的调用,在编译期完全可以明确转型的是Man还是Woman。
解释清楚了静态类型与实际类型的概念,我们就把话题再转回到代码清单的样例代码中。main()里面的两次sayHello()方法调用,在方法接收者已经确定是对象“sr”的前提下,使用哪个重载版本,就完全取决于传入参数的数量和数据类型。代码中故意定义了两个静态类型相同,而实际类型不同的变量,但**虚拟机(或者准确地说是编译器)在重载时是通过参数的静态类型而不是实际类型作为判定依据的。**由于静态类型在编译期可知,所以在编译阶段,Javac编译器就根据参数的静态类型决定了会使用哪个重载版本,因此选择了sayHello(Human)作为调用目标,并把这个方法的符号引用写到main()方法里的两条invokevirtual指令的参数中。
上述代码编译后的字节码如下:
public static void main(java.lang.String[]);
Code:
stack=2, locals=4, args_size=1
0: new #25 // class staticDispatcher/StaticDispatch$Man
3: dup
4: invokespecial #27 // Method staticDispatcher/StaticDispatch$Man."<init>":()V
7: astore_1
8: new #28 // class staticDispatcher/StaticDispatch$Woman
11: dup
12: invokespecial #30 // Method staticDispatcher/StaticDispatch$Woman."<init>":()V
15: astore_2
16: new #31 // class staticDispatcher/StaticDispatch
19: dup
20: invokespecial #33 // Method "<init>":()V
23: astore_3
24: aload_3
25: aload_1
26: invokevirtual #34 // Method sayHello:(LstaticDispatcher/StaticDispatch$Human;)V
29: aload_3
30: aload_2
31: invokevirtual #34 // Method sayHello:(LstaticDispatcher/StaticDispatch$Human;)V
34: return
将字节码文件反编译后的代码如下:
public class StaticDispatch {
public StaticDispatch() {
}
public void sayHello(StaticDispatch.Human guy) {
System.out.println("hello guy!");
}
public void sayHello(StaticDispatch.Man guy) {
System.out.println("hello gentleman!");
}
public void sayHello(StaticDispatch.Woman guy) {
System.out.println("hello lady!");
}
public static void main(String[] args) {
StaticDispatch.Human man = new StaticDispatch.Man();
StaticDispatch.Human woman = new StaticDispatch.Woman();
StaticDispatch sr = new StaticDispatch();
sr.sayHello((StaticDispatch.Human)man);
sr.sayHello((StaticDispatch.Human)woman);
}
static class Man extends StaticDispatch.Human {
Man() {
}
}
static class Woman extends StaticDispatch.Human {
Woman() {
}
}
abstract static class Human {
Human() {
}
}
}
所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派。静态分派的最典型应用表现就是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的。
2.动态分派
了解了静态分派,我们接下来看一下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();
}
}
运行结果:
man say hello
woman say hello
woman say hello
显然这里选择调用的方法版本是不可能再根据静态类型来决定的,因为静态类型同样都是Human的两个变量man和woman在调用sayHello()方法时产生了不同的行为,甚至变量man在两次调用中还执行了两个不同的方法。导致这个现象的原因很明显,是因为这两个变量的实际类型不同,Java虚拟机是如何根据实际类型来分派方法执行版本的呢?我们使用javap命令输出这段代码的字节码,尝试从中寻找答案,输出结果如以下代码清单所示。
public static void main(java.lang.String[]);
Code:
stack=2, locals=3, args_size=1
0: new #7 // class dispatchTest/DynamicDispatch$Man
3: dup
4: invokespecial #9 // Method dispatchTest/DynamicDispatch$Man."<init>":()V
7: astore_1
8: new #10 // class dispatchTest/DynamicDispatch$Woman
11: dup
12: invokespecial #12 // Method dispatchTest/DynamicDispatch$Woman."<init>":()V
15: astore_2
16: aload_1
17: invokevirtual #13 // Method dispatchTest/DynamicDispatch$Human.sayHello:()V
20: aload_2
21: invokevirtual #13 // Method dispatchTest/DynamicDispatch$Human.sayHello:()V
24: new #10 // class dispatchTest/DynamicDispatch$Woman
27: dup
28: invokespecial #12 // Method dispatchTest/DynamicDispatch$Woman."<init>":()V
31: astore_1
32: aload_1
33: invokevirtual #13 // Method dispatchTest/DynamicDispatch$Human.sayHello:()V
36: return
0~15行的字节码是准备动作,作用是建立man和woman的内存空间、调用Man和Woman类型的实例构造器,将这两个实例的引用存放在第1、2个局部变量表的变量槽中,这些动作实际对应了Java源码中的这两行:
Human man = new Man();
Human woman = new Woman();
接下来的16~21行是关键部分,16和20行的aload指令分别把刚刚创建的两个对象的引用压到栈顶,这两个对象是将要执行的sayHello()方法的所有者,称为接收者(Receiver);17和21行是方法调用指令,这两条调用指令单从字节码角度来看,无论是指令(都是invokevirtual)还是参数(都是常量池中第22项的常量,注释显示了这个常量是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, I have $0
I am Son, I have $4
This guy has $2
输出两句都是“I am Son”,这是因为Son类在创建的时候,首先隐式调用了Father的构造函数,而Father构造函数中对showMeTheMoney()
的调用是一次虚方法调用,实际执行的版本是Son::showMeTheMoney()
方法,所以输出的是“I am Son”,这点经过前面的分析相信读者是没有疑问的了。而这时候虽然父类的money字段已经被初始化成2了,但Son::showMeTheMoney()
方法中访问的却是子类的money字段,这时候结果自然还是0,因为它要到子类的构造函数执行时才会被初始化。main()的最后一句通过静态类型访问到了父类中的money,输出了2。
3.单分派与多分派
方法的接收者与方法的参数统称为方法的宗量,这个定义最早应该来源于著名的《Java与模式》一书。根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。
/**
* 单分派、多分派演示
*/
public class SingleMultiDispatch {
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());
}
}
运行结果:
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 12和预览版的Java 13)的Java语言是一门静态多分派、动态单分派的语言。
var是在编译时根据声明语句中赋值符右侧的表达式类型来静态地推断类型,这本质是一种语法糖;而dynamic(C#中)在编译时完全不关心类型是什么,等到运行的时候再进行类型判断。
4.虚拟机动态分派的实现
动态分派是执行非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在接收者类型的方法元数据中搜索合适的目标方法,因此,Java虚拟机实现基于执行性能的考虑,真正运行时一般不会如此频繁地去反复搜索类型元数据。面对这种情况,一种基础而且常见的优化手段是为类型在方法区中建立一个虚方法表(Virtual Method Table,也称为vtable,与此对应的,在invokeinterface执行时也会用到接口方法表——Interface Method Table,简称itable),使用虚方法表索引来代替元数据查找以提高性能。我们先看看“单分派、多分派演示”的代码清单所对应的虚方法表结构示例,如下图所示。
这里的“提高性能”是相对于直接搜索元数据来说的,实际上在HotSpot虚拟机的实现中,直接去查itable和vtable已经算是最慢的一种分派,只在解释执行状态时使用,在即时编译执行时,会有更多的性能优化措施,具体可常见第11章关于方法内联的内容。
虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表中的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类虚方法表中的地址也会被替换为指向子类实现版本的入口地址。在上图中,Son重写了来自Father的全部方法,因此Son的方法表没有指向Father类型数据的箭头。但是Son和Father都没有重写来自Object的方法,所以它们的方法表中所有从Object继承来的方法都指向了Object的数据类型。
虚方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的虚方法表也一同初始化完毕。
8.4 动态类型语言支持
8.4.1 动态类型语言
动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期进行的。
8.4.3 java.lang.invoke包
JDK 7时新加入的java.lang.invoke
包是JSR 292的一个重要组成部分,这个包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这条路之外,提供一种新的动态确定目标方法的机制,称为“方法句柄”(Method Handle)。
仅站在Java语言的角度看,MethodHandle在使用方法和效果上与Reflection有众多相似之处。不过,它们也有以下这些区别:
- Reflection和MethodHandle机制本质上都是在模拟方法调用,但是Reflection是在模拟Java代码层次的方法调用,而MethodHandle是在模拟字节码层次的方法调用。在MethodHandles.Lookup上的3个方法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在这里并不是主角。
8.4.4 invokedynamic指令
某种意义上可以说invokedynamic指令与MethodHandle机制的作用是一样的,都是为了解决原有4条“invoke*”指令方法分派规则完全固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码之中,让用户(广义的用户,包含其他程序语言的设计者)有更高的自由度。
每一处含有invokedynamic指令的位置都被称作“动态调用点(Dynamically-Computed Call Site)”,这条指令的第一个参数不再是代表方法符号引用的CONSTANT_Methodref_info常量,而是变为JDK 7时新加入的CONSTANT_InvokeDynamic_info常量,从这个新常量中可以得到3项信息:引导方法(Bootstrap Method,该方法存放在新增的BootstrapMethods属性中)、方法类型(MethodType)和名称。
8.4.5 实战:掌控方法分派规则
invokedynamic指令与此前4条传统的“invoke*”指令的最大区别就是它的分派逻辑不是由虚拟机决
定的,而是由程序员决定。
8.5 基于栈的字节码解释执行引擎
无论是解释还是编译,也无论是物理机还是虚拟机,对于应用程序,机器都不可能如人那样阅读、理解,然后获得执行能力。大部分的程序代码转换成物理机的目标代码或虚拟机能执行的指令集之前,都需要经过下图中的各个步骤。
8.5.2 基于栈的指令集与基于寄存器的指令集
Javac编译器输出的字节码指令流,基本上是一种基于栈的指令集架构(Instruction Set Architecture,ISA),字节码指令流里面的指令大部分都是零地址指令,它们依赖操作数栈进行工作。与之相对的另外一套常用的指令集架构是基于寄存器的指令集,最典型的就是x86的二地址指令集。
使用“基本上”,是因为部分字节码指令会带有参数,而纯粹基于栈的指令集架构中应当全部都是零地址指令,也就是都不存在显式的参数。Java这样实现主要是考虑了代码的可校验性。
基于栈的指令集主要优点是可移植,因为寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地要受到硬件的约束。栈架构的指令集还有一些其他的优点,如代码相对更加紧凑(字节码中每个字节就对应一条指令,而多地址指令集中还需要存放参数)、编译器实现更加简单(不需要考虑空间分配的问题,所需空间都在栈上操作)等。
这里说的是物理机器上的寄存器。
栈架构指令集的主要缺点是理论上执行速度相对来说会稍慢一些,所有主流物理机的指令集都是寄存器架构也从侧面印证了这点。不过这里的执行速度是要局限在解释执行的状态下,如果经过即时编译器输出成物理机上的汇编指令流,那就与虚拟机采用哪种指令集架构没有什么关系了。
在解释执行时,栈架构指令集的代码虽然紧凑,但是完成相同功能所需的指令数量一般会比寄存器架构来得更多,因为出栈、入栈操作本身就产生了相当大量的指令。更重要的是栈实现在内存中,频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。
评论区