1.概述
在 JVM 规范中制定了虚拟机字节码执行引擎的概念模型,这个概念模型成为了各个 JVM 发行厂商的统一外观。但是不同 JVM 实现中,在执行引擎执行字节码时,通常会有解释执行(通过解释器执行)和编译执行(通过即时编译器产生的本地代码执行)两种选择。也有可能两者兼备,或者同时包含几个不同级别的即时编译器一起工作的执行引擎。但从外观上看,所有的虚拟机执行引擎输入、输出时一致的:输入的是字节码二进制流,处理过程是字节码解析执行的等效过程,输出的是执行结果。
2.运行时栈帧结构
Java 虚拟机是以方法作为最基本的执行单元,栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它也是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧中存储了方法的局部变量表、操作数栈、动态链接和方法的返回地址信息。
在 Java 源码在编译时,就已经分析计算出栈帧中需要多大的局部变量表、多深的操作数栈,并写入到方法的 Code 属性中。换言之,一个栈帧需要分配多少内存不会受到程序运行期变量数据影响,而仅仅取决于程序源码和虚拟机实现的内存布局形式。
一个线程的方法调用链可能会很长,从 Java 程序角度来看,在同一条线程里,在调用堆栈的所有方法都处于执行状态。但是从执行引擎看,在当前线程中,只有位于栈顶的方法才是处于执行状态,位于栈顶的栈帧被称为当前栈帧,这个栈帧对应被执行的方法被称作当前方法。
2.1 局部变量表
局部变量表:是一组变量值的存储空间,用于存放方法参数和方法内部定义的局部变量。Java 代码被编译为 Class 文件后,方法中的 Code 属性的 max_locals 数据项确定了该方法所需分配的局部变量表的最大容量。
局部变量表的容量是以变量槽为最小单位,一个变量槽可以存储 32 位以内的数据类型,其中 boolean、byte、char、short、int、float、reference 和 returnAddress 这 8 种类型存储空间不超过 32 位。
其中 reference 至少能通过这个引用做到两件事情:一是根据引用直接或间接地查找到对象在 Java 堆中的数据存放的起始地址或索引,二是根据引用直接或间接地查找到对象所属数据类型在方法区中存储的类型信息。
对于 64 位数据类型,long 和 double,JVM 会以高位对齐的方式为它分配两个连续的变量槽空间。由于局部变量槽是线程私有的,所以不存在线程安全问题。
JVM 通过索引定位的方式来访问局部变量表,索引值的范围是 0 到 局部变量表变量槽数量最大值。对于 32 位数据类型,索引 N 就表示使用了第 N 个变量槽。对于 64 位数据类型,则会同时说明使用 N 和 N + 1 两个变量槽。对于 64 位数据类型,JVM 不允许单独访问其中任意一个变量槽。
当方法被调用时,JVM 会使用局部变量表来完成参数值到参数变量列表的传递过程,即实参到形参的传递。如果是实例方法,那局部变量表的第 0 位索引默认是对象方法所属对象实例的引用,在方法中可以通过 this 关键字访问到这个隐藏参数,其余的参数按照参数表顺序排序,参数表分配完成后,再根据方法内部定义的变量顺序和作用域分配其余的变量槽。
为了尽可能节省栈帧耗用的内存空间,局部变量表中的变量槽是可以重用的,方法体中定义的变量,其作用域不一定会覆盖整个方法体,如果当前字节码 PC 计数器的值已经超出某个变量的作用域。那么这个变量对应的变量槽就可以交给其他变量来重用。
在特殊的情况下,这种复用有一些副作用。比如当局部变量槽在分配给某个大的引用对象后,进行复用。在新的变量进入变量槽之前,进行垃圾回收,会导致这个 大对象不能被回收,因为局部变量表的变量槽对这个对象还有引用。因此在大对象分配之后,可以使用给该变量赋 null 值,来让局部变量表放弃对该对象的引用。从而避免不能被及时回收的问题。
另外,局部变量表必须要初始化了之后才能被使用。
2.2操作数栈
操作数栈是一个后入先出的栈,栈的最大深度也在编译时写入到 Code 属性的 max_stacks 数据项中。操作数栈的每一个元素都可以包括 long 和 double 在内的任意 Java 数据类型。32 位数据类型占栈容量为 1, 64 位数据类型占栈的容量为 2。
当一个方法开始时,操作数栈空的,在方法执行过程中,字节码指令会往操作数栈中写入和提取内容,也就是入栈和栈操作。比如 iadd 指令:先把操作数栈中栈顶的两个数据元素取出来相加,然后将结果再压入操作数栈。
在操作数栈的概念模型中,两个不同的栈帧是完全独立的,但是在大多数虚拟机的视线里都会做一些优化处理,令两个栈帧出现一部分重叠。这样不仅可以节约一些空间,也可以在方法调用时直接共用一部分数据。
2.3动态连接
每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用时为了支持方法调用过程中的动态连接。Class 文件的常量池中存在大量的符号引用,字节码中的方法调用指令就是以常量池里指向方法的符号引用作为参数。这些符号引用一部分会在类加载阶段或者第一次使用的时候被转化为直接引用,这种转化被称为静态解析。另外一部分将会在每一次运行期间都转化为直接引用,这部分称为动态连接。
2.4方法返回地址
当一个方法开始执行后,只有两种方式退出这个方法。第一种是执行引擎遇到任意一个方法返回字节码指令,这时有可能会有返回值传递给上层得方法调用着,这种退出方法称为正常调用完成。
另一种退出方式是在方法执行过程种遇到了异常,并且这个异常没有在方法体内得到妥善处理。无论是 JVM 内部产生的异常,还是代码种使用 athrow 字节码指令产生的异常,只要在本方法中没有搜索到匹配的异常处理器,就会导致方法退出。这种方式称为异常调用完成。异常完成出口不会给上层调用者提供任何返回值。
无论哪种方式退出,在方法退出之后,都必须返回到最初方法被调用的位置,程序才能继续执行。方法退出的过程实际上等同于把当前栈帧出栈,此时可能执行的操作有:恢复上层方法的局部变量表和操作数栈,把返回值压入调用者栈帧的操作数栈中,调整 PC 计数器,的值,以指向方法调用指令的后面的一条指令等。
3. 方法调用
方法调用不等同于方法中的代码被执行,方法调用阶段的唯一任务是确定被调用方法的二版本(即调用的哪一个方法,暂时还未涉及方法内部的具体运行过程。
3.1解析
方法调用的目标方法在 Class 文件中都是一个常量池中的符号引用,在类加载的解析阶段,会将其中的一部分符号引用转化未直接引用。这个解析成立的前提是:方法在程序真正运行之前就有一个可以确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用的目标方法在程序代码编写好、编译器编译后就已经确定了。这类方法的调用被成为解析。
满足前面的编译期确定,运行期不变的要求的方法,主要有静态方法和私有方法两大类。静态方法直接与类关联,私有方法在外部不能访问,因此它们不能通过继承或者其他方式重写,因此它们适合在类记载阶段进行解析。
调用不同类型的方法,字节码设计了不同的指令:
- invokestatic: 调用静态方法。
- invokespecial:用于调用实例构造器 () 方法、私有方法、父类中的方法。
- invokevirtual:调用虚方法。
- invokeinterface:调用接口方法,会在运行时再确定一个实现该接口的对象。
- invokedynamic:先再运行时动态解析出调用点限定符所引用的方法,然后再执行该方法。前面 4 条指令,分派逻辑都固化再 JVM 内部,而 invokedynamic 指令的分派逻辑是由用户设定的引导方法来决定的。
只要能被 invokestatic 和 invokespecial 指令调用的方法,都可以在解析阶段中确定唯一的调用版本,Java 中符合这个条件的方法有:静态方法、私有方法、实例构造器、父类方法 4 种。再加上被 final 修饰的方法(它们用 invokevirtual 调用),这 5 种方法调用会再类加载的时候就可以把符号引用解析为该方法的直接引用,这类方法称为:非虚方法。其他的方法被称为:虚方法。
3.2分派
3.2.1静态分派
首先我们来看一个例子:
public class StaticDispatch {
static abstract class Human{}
static class Man extends Human {}
static class Woman extends Human {}
public void sayHi(Human guy){
System.out.println("hello, guy!");
}
public void sayHi(Man guy){
System.out.println("hello, guy!");
}
public void sayHi(Woman guy){
System.out.println("hello, guy!");
}
public static void main(String[] args){
Human man = new Man();
Human woman = new Woman();
StaticDispatch sr = new StaticDispatch();
sr.sayHi(man);
sr.sayHi(woman);
}
/**
* 结果输出:
* hello,guy!
* hello,guy!
*/
}
上面的代码,我们需要回答的问题是,虚拟机为什么会选择执行参数类型为 Human 的重载版本呢? 在回答这个问题之前,我们需要先明白两个关键概念:
Human man = new Human(); 对于这个代码,我们将 Human 称为变量的"静态类型",或者叫做"外观类型",后面的 Man 则被称作"实际类型",或者叫做"运行时类型"。变量的静态类型是在编译期可知的,而实际类型是在运行期才可确定的,编译器在编译程序的时候并不知道一个对象的实际类型是什么。
对于前面的 sayHi() 方法的调用,使用哪个重载版本,完全取决于传入的参数的数量和数据类型。调用时使用的是两个静态类型相同,实际类型不同的变量,但是虚拟机在重载时是通过参数的静态类型而不是实际类型作为判定依据的。所以最终使用的是 Human 类型参数的重载方法。
所有依赖静态类型来决定方法执行版本的分派动作,都称为静态分派。静态分派最典型的应用表现就是方法重载。静态分派发生在编译阶段。
需要注意,编译器虽然能确定出方法的重载版本,但是很多情况下这个重载版本并不是唯一的,它往往只能确定一个相对更合适的版本。比如说我们直接使用字面量调用方法,由于字面量没有显式的静态类型,所以编译器只能通过语言、语法的规则取理解和推断。比如下面的例子:
public class Overload {
public static void sayHi(char arg) {
System.out.println("hello char");
}
public static void sayHi(int arg) {
System.out.println("hello int");
}
public static void sayHi(long arg) {
System.out.println("hello long");
}
public static void sayHi(Character arg) {
System.out.println("hello Character");
}
public static void sayHi(Serializable arg) {
System.out.println("hello Serializable");
}
public static void sayHi(Object arg) {
System.out.println("hello Object");
}
public static void sayHi(char... arg) {
System.out.println("hello char ...");
}
public static void main(String[] args) {
sayHi('a');
}
}
上面的代码会输出: hello char,如果我们注释掉 sayHi(char arg) 方法,则会输出 hello int, 如果我们继续注释掉 sayHi(int arg) 方法,则会输出 hello long。因为 'a' 是一个 char 类型,它同时也可以代表数字 97, 还可以自动转化成 97L,整个类型转化的顺序是:char > int > long > float > double 。但是不会匹配 byte 和 short 类型,因为 char 到 byte 或者 short 的类型转换是不安全的。继续注释对应的方法,后续依次会输出:hello Character ,这时发生了一次自动装箱,'a' 被封装为它的封装类型 Character。继续注释,会输出:hello Serializable。因为当一路都找不到对应的类型时,会发生自动装箱,但是装箱类型的方法还是找不到,就会找到装箱类所实现的接口,事实上 Character 实现了 Serializable 和 Comparable 接口,同时存在这两个类型的方法,编译器会提示"类型模糊"(Type Ambiguous),并拒绝编译,此时必须指明字面量的静态类型。继续注释会输出:Hello Object, 最后是:Hello char... 表示变长参数优先级最低。但是如果给 'a' 指定一个确定的静态类型,则不会发上面描述的情况。
另外,解析和分派这两者之间的关系不是二选一的排他关系,而是不同层次上取筛选、确定目标方法的过程。例如前面说的静态方法会在编译器确定、在类加载期进行解析,二静态方法显然也是可以重载的,选择重载版本的过程也是通过静态分派完成的。
3.2.2动态分派
静态分派与方法的重载密切相关,动态分派则与方法的重写密切相关。我们看下面的示例代码:
public class DynamicDispatch {
static abstract class Human {
protected abstract void sayHi();
}
static class Man extends Human {
@Override
protected void sayHi() {
System.out.println("man say hello");
}
}
static class Woman extends Human {
@Override
protected void sayHi() {
System.out.println("woman say hello");
}
}
public static void main(String[] args) {
Human man = new Man();
Human woman = new Woman();
man.sayHi();
woman.sayHi();
man = new Woman();
man.sayHi();
}
/**
* 代码输出:
* man say hello
* woman say hello
* woman say hello
*/
}
显然这里选择调用的方法版本不可能再根据静态类型来决定,因为静态类型都是 Human 的两个变量 man 和 woman 在调用 sayHi() 方法时产生的行为不同。这些不同的行为时这两个变量的实际类型不同而导致的。在 JVM 执行期间,使用 invokevirtual 调用目标方法时,目标方法的符号引用常量是 Human.sayHi(),所以如果确定版本是在 invokevirtual 指令内完成的。invokevirtual 运行时解析的过程大致分为:
- 找到操作数栈顶的第一个元素所指向的对象的实际类型。
- 如果在实际类型种找到了与常量中的描述符和简单名称都相符的方法,则进行权限校验,如果通过则返回这个方法的直接引用,查找结束,不通过则返回 IllegalAccessError 异常。
- 否则,按照继承关系从下往上依次对该实际类型的各个父类进行第二步的搜索和验证过程。
- 如果始终没有找到合适的方法,则抛出 AbstractMethodError 异常。
invokevirtual 指令执行的第一步就是在运行期确定接收者的实际类型,然后根据方法接收者的实际类型来选择方法版本,这个过程就是 Java 方法重写的本质,我们把这种运行期间根据实际类型确定方法执行版本的分派过程称为动态分派。
方法的多态性根源在于 invokevirtual 的执行逻辑,这种动态只对方法有效,对字段无效,因此在 Java 中字段不参与多态。如果父类和子类申明了相同的字段时,子类会遮蔽父类的同名字段。请看以下例子:
public class FieldHasNoPolymorphic {
static class Father {
public int money = 1;
public Father() {
money = 2;
showTheMoney();
}
public void showTheMoney() {
System.out.println("I am Father, i have $" + money);
}
}
static class Son extends Father {
public int money = 3;
public Son() {
money = 4;
showTheMoney();
}
@Override
public void showTheMoney() {
System.out.println("I am Son, i have $" + money);
}
}
public static void main(String[] args) {
Father gay = new Son();
System.out.println("This gay has $" + gay.money);
}
/**
* 输出为:
* I am Son, i have $0
* I am Son, i have $4
* This gay has $2
*/
}
因为在创建 Son 时,会隐式调用 Father 的构造函数,而 Father 的构造函数中调用的 showTheMoney() 实际上是 Son::showTheMoney() 方法,此时 Son 还没有初始化完成,所以 money 结果是 0 。然后 Son 创建,money 结果为 4。最后通过 Father 访问到 Father 的money 为 2。
3.2.3单分派与多分派
方法的接收者与方法的参数统称为方法的宗量。根据分派基于多少宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量堆目标方法进行选择,多分派则是根据多于一个宗量堆目标方法进行选择。
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 {
@Override
public void hardChoice(QQ arg) {
System.out.println("son choose qq");
}
@Override
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
*/
}
对于静态分派类型:首先要确定变量的静态类型是什么,还要根据参数确定具体执行哪个方法,所以需要两个宗量进行选择,所以 Java 的静态分派属于多分派类型。
对于动态分派过程:由于动态分派过程中,目标方法的签名已经确定,即参数类型已经确定,唯一需要确定的是该方法的接收者的实际类型,因此只有一个宗量作为选择条件。所以 Java 的动态分派属于单分派类型。
3.2.4虚拟机动态分派的实现
由于动态分派的执行非常频繁,而且动态分派的方法版本选择过程需要在方法的接收者的方法元数据中搜索合适的目标方法,因此,基于性能考虑,一般不会如此频繁的取搜索类型元数据。一般常见的优化是为类型在方法区中建立一个虚方法表,也称 vtable,相对应的在接口方法上也会有接口方法表,简称 itable。下面是虚方法表结构示例:
虚方法表中存放着各个方法的实际入口地址,如果某个方法在子类中没有被重写,那子类的虚方法表中的地址入口和父类相同的方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,则子类虚方法表中的地址也会被替换成指向子类实现版本的入口地址。虚方法表一般在类加载的连接阶段进行初始化,准备了类的变量初始值后,虚拟机会把该类的虚方法表也一同初始化完毕。
4. 动态类型语言支持
Java 虚拟机的字节码指令的数量从第一款虚拟机问世至今,只在 JDK 7 增加了一条指令:invokedynamic,这条指令是为了实现动态语言支持而进行的改进之一,也是为 JDK 8 实现 lambda 表达式而做的技术储备。
4.1动态类型语言
动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译器进行的,满足这个特征的语言有很多,比如:Groovy、JavaScript、Lisp、Lua、PHP、Python、Ruby 等。相对应的,在编译期进行类型检查过程的语言,比如:C++ 和 Java。
下面解释什么是运行时、编译期。 举个例子:
public static void main(String[] args) {
int[][][] array = new int[1][0][-1];
}
上面的代码能正常编译,但是运行时会出现 NegativeArraySizeException 异常。
int main(void) {
int i[ 1][0][-1];
// GCC 拒绝编译,报“size of array is negative” return 0;
}
上面的代码在编译时报错。
下面解释什么是类型检查:
例如:obj.println("hello world"); ,首先我们假设是在 Java 语言中,并且 obj 的类型是 PrintStream ,那么 变量 obj 的实际类型就必须是 PrintStream 的子类才合法。否则,哪怕 obj 属于一个确实包含有 println(String) 方法相同签名的类型,只要它与 PrintStream 接口没有继承关系,代码依然不能运行,因为类型检查不合法。
但是对于 JavaScript 则不一样,无论 obj 具体是何种类型,继承关系如何,只要这种类型的方法定义中确实包含有 println(String) 方法,能找到相同签名的方法,就能调用成功。
动态语言与 Java 有一个核心差异就是变量 obj 本身没有类型,变量 obj 的值才具有类型。所以编译器在编译时最多只能确定方法名称、参数、返回值这些信息,而不会去确定方法所在的具体类型,变量无类型而变量值才有类型这个特点也是动态语言的一个核心特征。而 Java 在编译的时候就能确定该方法所属的类、方法的名称、参数的顺序、参数的类型和方法的返回值等信息。
动态语言和静态语言的优劣势:静态语言能够在编译器确定变量类型,最显著的好处时编译器可以提供全面严谨的类型检查,这样与数据类型相关的潜在问题就能在编码时被及时发现,利于稳定性及让项目容易达到更大的规模。动态语言在运行期才确定类型,这可以为开发人员提供极大的灵活性,在某些静态语言中需要花大量臃肿代码来实现的功能,由动态语言做可能会很清晰简洁,清晰简洁通常也意味着开发效率的提升。
4.2 Java 与动态语言
在 JDK 7 以前,JVM 中的字节码指令中的 4 个方法调用指令对动态类型语言支持都不是很好,所以在 JDK 7 中增加了 invokedynamic 指令以及 java.lang.invoke 包。
4.3 java.lang.lnvoke 包
这个包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法之外,提供了一种新的动态确定目标方法的机制,称为方法句柄。
举例说明:在 C/C++ 中要实现带有排序时比较动作的排序方法,用函数指针来传递排序时的比较动作方法。void sort(int list[], const int size, int (*compare)(int, int)),但是在 Java 中没有办法单独把一个函数作为参数进行传递,普遍的做法是设计一个带有 compare() 方法的 Comparator 接口,把这个接口的实现对象作为参数传递给排序方法。例如 void sort(List list, Comparator c)。不过在拥有方法句柄之后,Java 也可以拥有类似函数指针或者委托的方法的别名这样的工具了。
下面的例子展示了无论 obj 是何种类型都可以调用到它的 println() 方法。obj 可以是 ClassA 或者 PrintStream 的实现类 System.out。
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() % 2 == 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);
}
}
在上面的 getPrintlnMH() 方法实际上是模拟了 invokevirtual 指令的执行过程,只不过它的分派逻辑并非固化在 Class 文件的字节码上,而是通过一个用户设计的方法来实现的,这个方法的返回值是 MethodHandle 对象,这个对象可以视为对最终调用方法的一个"引用"。
从上面的例子来看,相同事情在 Java 的反射中之前也实现过。如果仅仅站在 Java 语言的角度看,MethodHandle 与反射有很多相似之处,不过也有很多区别:
- 反射和 MethodHandle 机制在本质上都是在模拟方法调用,但是反射是在模拟 Java 代码层面的方法调用,而 MethodHandle 是在模拟字节码层面的方法调用。在 MethodHandles.Lookup 上的 3 个方法 findStatic()、findVirtual()、findSpecial()正是为了对 应于 invokestatic、invokevirtual(以及 invokeinterface)和 invokespecial 这几条字节码指 令的执行权限校验行为,而这些底层细节在使用 Reflection API 时是不需要关心的。
- 反射中的 java.lang.reflect.Method 对象比 MethodHandle 机制中的 java.lang.invoke.MethodHandle 对象所包含的信息多得多。前者是方法在 Java 端全面映像。包含了方法得签名、描述符以及方法属性表中各种属性的 Java 段表示方式,还包含执行权限等运行期信息,而后者只包含执行该方法的相关信息,即反射是重量级的,MethodHandle 是轻量级的。
- 由于 MethodHandle 是对字节码的方法指令调用的模拟,那理论上虚拟机在这方面做的各种优化,在 MethodHandle 上也应该可以采用类似的思路去支持,而通过反射区调用方法则几乎不可能区实时各类调用点优化措施。
- 最关键一点是:反射 API 的设计目标是为 Java 语言服务的,而 MethodHandle 则设计服务于所有 JVM 之上的语言。
4.4 invokedynamic 指令
invokedynamic 于前面的 MethodHandle 的作用是一样的,都是为了解决原有的 4 条 invoke* 指令方法分派规则完全固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体的用户代码之中。只是一个用上层代码和 API 来实现,另一个用字节码和 Class 中的其他属性、常量来完成。
每一处含有 invokedynamic 指令的位置都被称作动态调用点。这条指令的第一个参数不再是代表方法的符号引用的 CONSTANT_Methodref_info 常量,而是 JDK 7 新加入的 CONSTANT_InvokeDynamic_info 常量。从这个常量里可以得到三项信息:引导方法、方法类型、方法名称。引导方法是有固定参数的,并且返回值规定是 java.lang.invoke.CallSite 对象,这个对象代表了真正要执行的目标方法调用,虚拟机根据 CONSTANT_InvokeDynamic_info 常量找到并执行引导方法,从而获取到 CallSite 对象,并最终调用到要执行的目标方法。
4.5 实战:掌控方法分派规则
下面给出程序员可以掌控方法分派规则之后,我们能做什么。
class GrandFather {
void thinking() {
System.out.println("i am grandfather");
}
}
class Father extends GrandFather {
@Override
void thinking() {
System.out.println("i am father");
}
}
class Son extends Father {
@Override
void thinking() {
//在不改变 Father 和 GrandFather 的代码的情况下,
//实现在 Son 的 thinking() 中调用 GrandFather 的 thinking()方法
}
}
在 JDK 7 之前,在 Java 语言层面很难实现在 Son 中调用 GrandFather 的 thinking()。(使用 ASM 等字节码工具可以处理,但是已经不是在 Java 语言层面,而是字节码层级处理了)。但是在 JDK 9 之后,可以通过以下方式实现:
class Son extends Father {
@Override
void thinking() {
try {
MethodType mt = MethodType.methodType(void.class);
Field implLookup = MethodHandles.Lookup.class.getDeclaredField("IMPL_LOOKUP");
implLookup.setAccessible(true);
MethodHandle mh = ((Lookup) implLookup.get(null)).findSpecial(GrandFather.class, "thinking",mt, getClass());
mh.invoke(this);
}catch (Throwable xe){
}
}
}
运行以上代码,可以获得结果:i am grandfather
5基于栈的指令集与执行引擎
5.1 解释执行
在 JDK 1.0时代,Java 被称为解释执行语言,这是比较准确的。但是当主流的虚拟机中包含了即时编译器后, Class 文件到底会被解释执行还是编译执行,就成了只有虚拟机自己才能准确判断的事了,这时再笼统的说解释执行,对整个 Java 语言来说就成了几乎没有意义的概念。只有确定了谈论对象是某个具体的 Java 实现版本和执行引擎运行模式时,谈解释执行还是编译执行才会比较合理确切。
5.2 基于栈的指令集与基于寄存器的指令集
Javac 编译器输出的字节码指令流,基本上是一种基于栈的指令集架构,字节码指令流里面大部分都是零地址指令,它们依赖操作数栈进行工作。而基于寄存器的指令集依赖于寄存器进行工作。
举例说明这两者的工作过程,比如计算 1+1 的结果:
- 基于栈的指令集: iconst_1 iconst_1 iadd istore_0,两条 iconst_1 指令连续把两个常量 1 压入栈, iadd 指令把两个栈顶元素取出相加,然后放回栈顶,最后 istore_0 把栈顶的值放回到局部变量表的第 0 个变量槽。
- 基于寄存器的指令集:mov eax, 1 ; add eax, 1,首先 mov 指令把 eax 寄存器的值设为 1,然后 add 指令再把这个值加 1, 最后结果保存在 eax 寄存器里。
基于栈的指令集的优缺点:
-
基于栈的指令集的主要优点是可移植,因为寄存器由硬件提供,程序直接依赖寄存器不可避免的受到硬件的约束。
-
主要缺点是理论上执行速度相对来说会稍慢一些。但是如果经过即时编译输出成物理机上的汇编指令流,那就与虚拟机采用哪种指令集架构没有什么关系了。
-
在解释执行时,完成相同的功能所需的指令数量一般会比寄存器架构来得更多,因为出栈、入栈本身就会产生大量的指令。更重要的时栈实现在内存中,频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。