jvm(8)-虚拟机字节码执行引擎

时间:2022-01-07 18:10:45
【0】README
0.1)本文转自 “深入理解jvm”,旨在学习 虚拟机字节码执行引擎 的基础知识;

【1】概述
1)物理机和虚拟机的执行引擎: 物理机的执行引擎是直接建立在处理器,硬件,指令集和操作系统层面上的,而虚拟机的执行引擎则是由自己实现的,因此可以自行制定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令集格式;
2)在不同的虚拟机实现里面:执行引擎在执行java代码的时候可能会有解释执行和编译执行两种选择,甚至还可能会包含几个不同级别的编译器执行引擎。
3)所有的java虚拟机的执行引擎都是一致的:输入的是字节码文件,处理过程是字节码解析的等效过程,输出的是执行结果,本章将主要从概念模型的角度来讲解虚拟机的方法调用和字节码执行;

【2】运行时栈帧结构
1)栈帧:栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,它是虚拟机运行时数据区中的虚拟机栈的栈元素。(干货——栈帧定义)(review——虚拟机栈的作用,每个方法在执行的时候都会创建一个栈帧,用于存储局部变量表,操作数栈,动态链接,方法出口等信息
2)当前栈帧和当前方法:对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法;
3)执行引擎运行的所有字节码指令都只针对当前栈帧进行操作,栈帧结构如下图所示:
jvm(8)-虚拟机字节码执行引擎
【2.1】局部变量表
1)局部变量表定义:是一组变量值存储空间,用于存放方法参数和方法内部定义的局部变量;在 java程序编译为Class 文件时,就在方法的Code属性的max_locals 数据项中确定了该方法所需要分配的局部变量表的最大容量;(干货——局部变量表定义)
2)局部变量表容量:以变量槽(Slot)为最小单位,虚拟机规范中并没有明确指明一个Slot 应占用的内存空间大小,只是很有导向性地说到每个Slot都应该能够存放一个 boolean,byte,char,short,int,float,reference,returnAddress 类型的数据,这8种数据类型,都可以使用32位或更小的物理内存来存放;(干货——引入最小存储单位——变量槽的概念)
3)一个Slot可以存放一个32位以内的数据类型:java中占用32位以内的数据类型有 boolean,byte,char,short,int,float,reference和returnAddress 8种类型。
  • 3.1)第7种reference类型:表示对一个对象实例的引用,虚拟机实现至少都应当能通过这个引用做到两点:一是从此引用中直接或间接地查找到对象在java堆中的数据存放的起始地址索引;二是此引用中直接或间接地查找到对象所属数据类型在方法区中的存储的类型信息,否则无法实现java语言规范中定义的语法约束;
  • 3.2)第8种即returnAddress 类型:它是为字节码指令 jsr, jsr_w 和 ret服务的,指向了一条字节码指令的地址;
4)对于64位的数据类型,虚拟机会以高位对齐的方式为其分配两个连续 的Slot 空间,且64位的数据类型只有long和double两种;
5)虚拟机通过索引定位的方式使用局部变量表,索引值范围从0~最大的Slot数量;
  • 5.1)如果访问的是32位数据类型的变量,索引n代表了第n个Slot;
  • 5.2)如果访问的是64位数据类型的变量,索引n代表了第n个和n+1个Slot;
6)方法执行时:虚拟机使用局部变量表完成参数值到参数变量列表的传递过程的,局部变量表中第0位索引的Slot默认是用于传递方法所属对象实例的引用,在方法中可以通过关键字this来访问到这个隐含的参数;(干货——通过关键字this来访问到这个隐含的参数
7)局部变量表中的Slot是可重用的:方法体中定义的变量,其作用域并不一定会覆盖整个方法体,如果当前字节码PC 计数器的值已经超出了某个变量的作用域,那这个变量对应的Slot 就可以交给其他变量使用了。(干货——Slot的可重用性)
7.1)不过,Slot的可重用性会带来一些副作用,如会直接影响到系统的垃圾收集行为,如下面3个荔枝所示:
jvm(8)-虚拟机字节码执行引擎
//通知jvm 进行垃圾收集;
  // 虚拟机运行参数中加上 "-verbose:gc" 来看看垃圾收集过程;发现在System.gc()运行后并没有回收这64M 的内存;
  // 没有回收的原因:因为在执行System.gc() 时,变量placeholder 还处于作用域内,虚拟机自然不会回收其内存;</span>
jvm(8)-虚拟机字节码执行引擎jvm(8)-虚拟机字节码执行引擎

对以上荔枝的分析(Analysis):
  • A1) placeholder能否被回收的根本原因是:局部变量表中的Slot 是否还存有关于 placeholder 数组对象的引用。第一次修改中(SlotReuse2 代码),代码虽然已经离开了placeholder的作用域,但在此之后,没有任何对局部变量表的读写操作,placeholder原本所占用的Slot还没有被其他变量所复用,所以作为 GC Roots 一部分的局部变量仍然保持着对它的关联;(干货——变量所占Slot被回收的原因)
  • A2)推荐手动将变量设置为null值:该操作可以作为一种极特殊情形(对象占用内存大,此方法的栈帧长时间不能被回收,方法调用次数达不到JIT的编译条件)下的奇技来使用;《practical java》中把“不使用对象应手动赋值为null”作为一条推荐的编码规则;
8)但笔者(本书作者)的观点是没有必要把置null 当做一个普遍的编码规则来推广。原因有两点:(干货——作者认为没有必要把置null 当做一个普遍的编码规则来推广)
  • 8.1)原因一:从编码角度讲,以恰当的变量作用域来控制变量回收时间才是最优雅的解决方法;
  • 8.2)原因二:从执行角度讲,使用赋null值的操作来优化内存回收是建立在对字节码执行引擎概念模型的理解之上的;而赋null值的操作在经过 JIT 编译优化后就会被消除掉,这时候将变量设置为null就是没有意义的;
9)局部变量不存在准备阶段(干货——类变量有两次赋初始值过程,而局部变量只有一次赋初始值过程)
  • 9.1)类变量有两次赋初始值的过程:一次在准备阶段,赋予系统初始值;另外一次在初始化阶段,赋予程序员定义的初始值;所以在初始化阶段没有为类变量赋值也没有关系,因为它有一个确定的初始值;
  • 9.2)局部变量不一样:如果一个局部变量定义了但没有赋初始值是不能使用的,不要认为java中任何情况下都存在诸如整型变量默认为0,布尔变量默认为false等默认值;
  • 9.3)看个荔枝:(下面这段代码并不能运行,因为局部变量没有赋初始值)
  • jvm(8)-虚拟机字节码执行引擎
【2.2】操作数栈
1)操作数栈:也称为操作栈,它是一个后入先出栈。操作数栈的最大深度在编译的时候写入到 Code属性的max_stacks 数据项中;
2)当一个方法刚刚开始执行的时候:这个方法的操作数栈是空的,在方法的执行过程中,会有各种字节码指令往操作数栈中写入和提取内容,也就是出栈和入栈操作;
3)两个栈帧之间的数据共享:令两个栈帧出现一部分重叠,下面的栈帧的部分操作数栈与上面栈帧的部*部变量表重叠在一起,这样在进行方法调用时就可以共用一部分数据,无需进行额外的参数复制传递,重叠过程如下图所示:(干货——两个栈帧之间的数据共享
jvm(8)-虚拟机字节码执行引擎
【2.3】动态连接
1)每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接;
2)静态解析: 这些符号引用一部分会在类加载阶段或第一次使用的时候就转化为直接引用,这种转化称为静态解析;
3)动态连接:另外一部分将在每一次运行期间转化为直接引用,这部分称为动态连接;(干货——静态解析和动态连接的定义)
【2.4】方法返回地址
1)方法执行后,有两种方式退出这个方法:
  • way1)正常完成出口:执行引擎遇到任意一个方法返回的字节码指令,这种退出方法称为正常完成出口;
  • way2)异常完成出口:在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是java虚拟机内部产生的异常,还是代码中使用athrow 字节码指令产生的异常;只要在本方法的异常表中没有搜索到匹配的异常处理器,方法就会退出;这种方式称为异常完成出口;
2)方法退出的过程实际上就等同于把当前栈帧出栈,因此退出时可能执行的操作有(operations):
  • operation1)恢复上层方法的局部变量表和操作数栈;
  • operation2)把返回值压入调用者栈帧的操作数栈中,调整PC计数器的值以指向方法调用指令后面的一条指令等;
【2.5】 附加信息
1)虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,如与调试相关的信息;
2)栈帧信息:一般会把动态连接,方法返回地址与其他附加信息全部归为一类,称为栈帧信息;

【3】方法调用
1)方法调用不等于方法执行:方法调用阶段唯一的任务就是确定被调用方法的版本,暂时还不涉及方法内部的具体运行过程;(干货——方法调用阶段的唯一任务)
2)一切方法调用在Class文件里面存储的都只是符号引用,而不是方法在实际运行时内存布局中的入口地址;
【3.1】解析
1)所有方法调用的目标方法在Class文件里面都是一个常量池中的符号引用:在类加载的解析阶段,会将其中一部分符号引用转化为直接引用;
2)解析定义:这种解析能成立的前提是:方法在程序真正运行前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好,编译器进行编译时就必须确定下来。这类方法的调用称为解析;(干货——解析的定义)
3)在java语言中符合“编译期可知,运行期不可变”这个要求的方法:主要包括静态方法和私有方法两大类;前者与类直接相关,后者在外部不可被访问,这两种方法各自的特点决定了它们都不可能通过继承或别的方式重写其他版本,因此它们都适合在类加载阶段进行解析;(干货——引入编译期可知,运行期不可变的概念
4)与之对应的是,在jvm 中提供了5条方法调用字节码指令,分别如下:(干货——5条方法调用字节码指令)
  • 4.1)invokestatic:调用静态方法;
  • 4.2)invokeespecial:调用实例构造器<init>方法,私有方法和父类方法;
  • 4.3)invokevirtual:调用所有的虚方法;
  • 4.4)invokeinterface:调用接口方法,会在运行时再确定另一个实现此接口的对象;
  • 4.5)invokedynamic先在运行时动态解析出调用点限定符所引用的方法,然后再执行该方法,在此之前的4条调用指令,分派逻辑是固化在java虚拟机内部 的,而invokedynamic 指令的分派逻辑是由用户所设定的引导方法决定的;
5)非虚方法与虚方法:(干货——非虚方法与虚方法定义
  • 5.1)非虚方法:只要能被invokestatic 和 invokespecial 指令调用的方法,都可以在解析阶段中确定唯一的调用版本,符合这个条件的有静态方法,私有方法,实例构造器,父类方法4类,他们在类加载的时候就会把符号引用解析为对该方法的直接引用。这些方法称为非虚方法;
  • 5.2)虚方法:其他的方法称为虚方法(除去final方法);
6)看个荔枝:静态方法sayHello()只可能属于类型 StaticResolution,没有任何手段可以覆盖或隐藏这个方法;
jvm(8)-虚拟机字节码执行引擎
7)java中的非虚方法补充:被final修饰的方法,final方法无法被覆盖,没有其他版本,所以也无需对方法接收者进行多态选择,又或者说多态选择的结果肯定是唯一的;
8)解析调用一定是个静态的过程:在编译期间就完全确定,在类装载的解析阶段就会把涉及的符号引用全部转变为 可确定的直接引用,不会延迟到运行期再去完成;
9)分派调用可能是静态的也可能是动态的:根据分配依据的宗量数可分为单分派和多分派。这两类分派方式的两两组合就构成了静态单分派,静态多分派,动态单分派,动态多分派4种分派组合情况;(干货——4种分派调用方式)
【3.2】分派
1)静态分派
  • 1.1)看个荔枝:
  • 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 sd = new StaticDispatch();
    		sd.sayHello(man);
    		sd.sayHello(woman);
    	}
    	/**
    	 * output:
    	 * hello, guy!
    	 * hello, guy!
    	 */
    }
  • 1.2)静态类型+实际类型:上面代码中的Human 称为变量的静态类型或叫做外观类型;后面的Man 则称为变量的实际类型;(干货——外观类型和实际类型定义)
  • 1.3)静态类型和实际类型的区别:静态类型的变化仅仅在使用时发生,变量本身的静态类型不会被改变,并且最终的静态类型是在编译器可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。
  • 1.4)静态分派定义:所有依赖静态类型来定位方法执行版本的分派动作称为静态分派;静态分派的典型应用是方法重载;静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机来执行的;(干货——静态分派的典型应用是方法重载)
  • 1.5)看个荔枝:(干货荔枝)
  • // 一个方法重载的荔枝
    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');// output: hello char
    	}
    }
    • case0)output为 hello char;
  • case1)若注释掉sayHello(char arg),output为 hello int:这时发生了一次自动类型转换,'a'除了可以代表一个字符串,还可以代表数字97,因此参数类型为int 的重载也是合适的;
  • case2)若注释掉sayHello(int arg),output为 hello long:这时发生了两次自动类型转换,'a' 转型为整数97之后,进一步转换为为长整数97L,匹配参数类型为long的重载;
  • case3)实际上自动转型还能继续发生多次:按照char->int->long->float->double 的顺序转型进行匹配。但不会匹配到byte和short类型的重载,因为char 到 byte 或short 的转型是不安全的。
  • case4)若注释掉sayHello(long arg),output为 hello Character:这时发生了一次自动装箱,'a'被包装为它的封装类型java.lang.Character,所以匹配到参数类型为 Character的重载;
  • case5)若注释掉sayHello(Character arg),output为 hello Serializable: 因为java.lang.Serializable 是 java.lang.Character类实现的一个接口,当自动装箱之后还是找不到装箱类,但是找到了装箱类实现了的接口类型,所以紧接着又发生了一次自动转型。char 可以转型为int,但Character是绝对不会转型为Integer的,它只能安全地转型为它实现的接口或父类;
    • case5.1)Character还实现了另外一个接口 java.lang.Comparable<Character> , 如果同时出现两个参数分别为 Serializable 和 Comparable<Character> 的重载方法,那它们在此时的优先级是一样的。编译器无法确定要自动转型为哪种类型,会提示类型模糊,拒绝编译;编译时,需要显式指定字面量的静态类型,如:sayHello(Comparable<Character>'a'),才能编译通过; 
  • case6)若注释掉sayHello(Serializable arg),output为 hello object;
  • case7)若注释掉sayHello(Object arg),output为 hello char...;
Conclusion)
  • C1)7个重载方法已经被注释得只剩一个了,可见变长参数的重载优先级是最低的,这时候字符'a' 被当做了一个数组元素;
  • C2)笔者讲述的解析与分派这两者之间的关系并不是二选一的排他关系:他们是在不同层次上去筛选,确定目标方法的过程。例如,前面说过,静态方法会在类加载器就进行解析,而静态方法显然也是可以拥有重载版本的,选择重载版本的过程也是通过静态分派完成 的;(干货——请注意解析和分派的区别,解析是和方法重载相关(同方法名,不同方法参数列表),而分派是与方法重写相关,子类父类的方法重写,确定调用方法的super version 还是 sub version)

2)动态分派(干货——动态分派的与方法重写有密切关联)
2.1)动态分派和重写有着很密切的关联;
2.2)看个荔枝:
// 方法动态分派演示
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!
	}
}
对以上输出结果的分析(Analysis):
  • A1)java虚拟机是如何根据实际类型来分派方法执行版本的呢?
  • A2)invokevirtual指令的运行时解析过程大致分为以下几个steps:
    • step1)找到操作数栈顶的第一个元素所指向的对象的实际类型,记做C;
    • step2)如果在类型C中找到与常量中的描述符和简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回 java.lang.IllegalAccessError 异常;
    • step3)否则,按照继承关系及从下往上依次对C 的各个父类进行第2步的搜索和验证过程;
    • step4)如果始终没有找到合适的方法,则抛出 java.lang.AbstractMethodError异常;
2.3)方法重写的本质:由于 invokevirtual指令执行的第一步就是 在运行期确定接收者的实际类型,所以两次调用中的invokevirtual 指令把常量池中的类方法符号引用解析到了不同的直接引用上,这个过程就是 java语言中方法重写的本质;(干货——方法重写的本质
2.4)动态分派定义:我们把这种在运行期根据实际类型确定方法执行版本的分派过程称为动态分派;
jvm(8)-虚拟机字节码执行引擎
3)单分派和多分派
3.1)宗量:方法的接收者与方法的参数统称为方法的宗量;
3.2)单分派和多分派:根据分派基于多少种宗量,可以将分派划分为单分派和多分派两种。单分派是根据一个宗量对目标方法进行选择,多分派是根据多于一个宗量对目标方法进行选择;
3.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 {
		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
	}
}
对以上代码的分析(Analysis):(看看编译阶段编译器的选择过程,也就是静态分派过程)
  • A1)选择目标方法的依据有两点:一是静态类型是Father还是Son,二是方法参数是QQ 还是360;
  • A2)这次选择结果的最终产物是产生两条invokevirtual指令:两条指令的参数分别是常量池中指向Father.hardChoice(360)以及Father.hardChoice(QQ) 方法的符号引用。因为是根据两个宗量进行选择,所以java语言的静态分派属于多分派类型;
  • A3)动态分派过程:在执行“son.hardChoice(new QQ())” 这句代码时,更正确地说,是在执行这句代码所对应的invokevirtual指令时,由于编译期已经决定目标方法的签名必须是 hardChoice(QQ);唯一可以影响虚拟机选择的因素只有此方法的接收者的实际类型是Father还是Son。因为只有一个宗量作为选择依据,所以java语言的动态分派属于单分派类型;
Conclusion)
  • C1)所以,java语言是一门静态多分派,动态单分派的语言;(干货——java语言是一门静态多分派,动态单分派的语言
  • C2)按照目前java语言的发展趋势,它并没有直接变为动态语言的迹象,而是通过内置动态语言(如JavaScript)执行引擎的方式来满足动态性的需求;
4)虚拟机动态分派的实现
4.1)由于动态分派是非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正地进行如此频繁的搜索。
4.2)面对这种情况,最常用的稳定优化手段就是为类在方法区中建立一个虚方法表,使用虚方法表索引来代替元数据查找以提高性能;
jvm(8)-虚拟机字节码执行引擎

补充-Complementary)虚拟机除了使用方法表之外,在条件允许的情况下,还会使用内联缓存(Inline Cache)和基于类型继承关系分析(Class Hierarchy Analysis, CHA)技术的守护内联(Guarded Inlining)两种非稳定的激进优化手段来获得更高的性能;

【3.3】动态类型语言支持(invokedynamic指令)
0)invokedynamic指令:随着jdk7 的发布,字节码指令集终于迎来了第一位新成员——invokedynamic指令。这条新增加的指令是 JDK7 实现“动态类型语言”支持而进行的改进之一,也是为JDK8 可以顺利实现 Lambda表达式做的准备;(干货——引入 invokedynamic指令
1)动态类型语言
1.1)定义:其关键特征是它的类型检查的主体过程是在运行期而不是编译期,满足这个特征的语言有很多,包括:APL,Groovy, JS, PHP, Python, Ruby等。相对的,在编译期就进行类型检查的语言(如C++或java)就是最常用的静态类型语言;(干货——引入了动态类型语言+静态类型语言)
1.2)看个荔枝
jvm(8)-虚拟机字节码执行引擎
对以上代码的分析(Analysis):
  • A1)java虚拟机规范中规定 java.lang.NegativeArraySizeException是一个运行时异常;
  • A2)运行时异常和连接时异常:运行时异常就是只要代码不运行到这一行就不会有问题,而连接时异常是指 即使会导致连接时异常的代码放在一条无法执行到的分支路径上,类加载时(java的连接过程不再编译阶段,而在类加载阶段)也照样会抛出异常;(干货——运行时异常和连接时异常的经典描述)
1.3)在看个荔枝
jvm(8)-虚拟机字节码执行引擎
1.4)再看荔枝
1.4.1)java中的 println
public static void main(String[] args) throws FileNotFoundException {
		PrintStream obj = System.out;
		obj.println("hello world"); // output: hello world
	}
对以上代码的分析(Analysis):
  • A1)对于静态类型语言(如java):obj的实际类型必须是PrintStream的子类才是合法的;
  • A2)但是相同的代码在动态类型语言如JavaScript中情况则不一样:无论obj 具体是何种类型,只要这种类型的定义中确实包含有 println(String) 方法,那方法调用便可成功;
  • A3)这种差别的原因:java等静态类型语言在编译期间已经将 println(String) 方法完整的符号引用生成出来,作为方法调用指令的参数存储到 Class文件中,如下面这段代码:
  • invokevirtual #4; // Method java/io/PrintStream.println:(Ljava/lang/String;)V ;这个符号引用包含了此方法定义在哪个具体类型中, 方法的名字和参数顺序等信息,通过这个符号引用,虚拟机可以翻译出这个方法的直接引用。而在JavaScript等动态类型语言中,变量obj 本身是没有类型的,变量obj的值才具有类型;这也是动态类型语言的重要特征;(干货——动态类型语言(如JavaScript)的重要特征是,变量obj 本身是没有类型的,变量obj的值才具有类型)
2)jdk1.7 与动态类型
2.1)jdk1.7 中 invokedynamic指令以及 java.lang.invoke 包出现的技术背景: jdk1.7以前的字节码指令集中,4条方法调用指令的第一个参数都是被调用的方法的符号引用,前面已经提到过,方法的符号引用在编译时产生,而动态类型语言只有在运行期才能确定接收者类型,所以,在java虚拟机上实现的动态类型语言就不得不使用其他方式来实现。这个问题终归是应当在虚拟机层次上去解决才最合适,因此在java虚拟机层面上提供动态类型的直接支持就称为了java平台的发展趋势之一,这是jdk1.7 中 invokedynamic指令以及 java.lang.invoke 包出现的技术背景;
3)java.lang.invoke包
3.1)jdk1.7新加入的包,这个包的主要目的:在之前单纯依靠符号引用来确定调用的目标方法这种方式以外,提供了一种新的动态确定目标方法的机制,称为MethodHandle;(干货——引入动态确定目标方法的机制——MethodHandle)
3.2)看个荔枝:
  • 3.2.1)如我们用C或C++实现一个带谓词的排序函数,代码如下:void sort(int list[], const int size, int(*compare)(int,int));
  • 3.2.2)如我们用java实现一个排序函数,需要传入Comparatorc接口参数,代码如下:void sort(List list, Comparator c);
  • 3.2.3)下面的代码演示了 MethodHandle的基本用途: 无论obj是何种类型,都可以正确地调用到 println() 方法:
  • // 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() % 2 == 0 ? System.out : new ClassA();
    		// 无论obj最终是哪个实现类,下面这句都能正确调用到 println 方法;
    		getPrintlnMH(obj).invokeExact("xiao tangtang"); // output:xiao tangtang 
    		
    	}
    	private static MethodHandle getPrintlnMH(Object receiver) throws Throwable {		
    		// MethodType: 代表方法类型,包含方法的返回值和具体参数;
    		MethodType mt = MethodType.methodType(void.class, String.class);
    		/**
    		 * lookup() 方法的作用是在指定类中查找符合给定的方法名称,方法类型,并且符合调用权限的方法句柄;
    		 * 因为这里调用的是一个虚方法,按照java的规则,方法第一个参数是隐式的,代表该方法的接收者,也即是this指向的对象,
    		 * 这个参数以前是放在参数列表中进行传递的,而现在提供了bindTo() 方法来完成这件事情;
    		 */
    		return MethodHandles.lookup().findVirtual(receiver.getClass(), "println", mt).bindTo(receiver);
    	}
    }
3.3)MethodHandle的使用方法和效果与反射Reflection有众多相似之处,它们还是有一些差别(distinctness):(干货——MethodHandle与 Reflection的 差别)
  • d1)本质上讲:Reflection和 MethodHandle机制都是在模拟方法调用,但Reflection是在模拟 java代码层次的方法调用,而MethodHandle 是在模拟字节码层次的方法调用;MethodHandles.lookup中的3个方法——findStatic(), findVirtual(), findSpecial() 正是为了对应于 invokestatic, invokevirtual, invokespecial 这几条字节码指令的执行权限校验行为,而这些底层细节在使用 Reflection API 时是不需要关心的;
  • d2)Reflection中的 java.lang.reflect.Method 对象远比 MethodHandle 机制中的 java.lang.invoke.MethodHandle 对象所包含的信息多。前者是方法在java一端的全面映像,包含了方法签名,描述符以及方法属性表中各种属性的java端表示方式,还包含执行权限等的运行期信息;而后者仅仅包含与执行该方法相关的信息。更直接一点,Reflection是重量级,而 MethodHandle 是轻量级的;(干货——Reflection是重量级,而 MethodHandle 是轻量级的)
  • d3)由于 MethodHandle是对字节码的方法指令调用的模拟,所以理论上虚拟机在这方面做的各种优化(如方法内联),在MethodHandle 上也应当可以采用类似思路去支持。而通过反射去调用方法则不行;
  • d4)最关键的区别:Reflection API 的设计目标是只为 java语言服务的,而 MethodHandle 则设计成可服务于所有java虚拟机之上的语言,其中也包括 java语言;
4)invokedynamic 指令(干货——intro toinvokedynamic 指令
4.1)定义:jdk1.7 为了更好地支持动态类型语言,引入了第5条方法调用的字节码指令 invokedynamic指令;
4.2)invokedynamic指令与 MethodHandle机制的作用是一样的:都是为了解决原有4条 invoke* 指令方法分派规则固化在虚拟机之中的问题,把如何查找目标方法的决定权从虚拟机转嫁到具体用户代码中;
4.3)动态调用点
  • 4.3.1)定义:每一处含有 invokedynamic指令的位置都称作“动态调用点”,这条指令的第一个参数不再是代表方法符号引用的 CONSTANT_Methodref_info 常量,而是 CONSTANT_InvokeDynamic_info 常量,从这个常量中得到3项信息:引导方法, 方法类型和名称;
  • 4.3.2)根据 CONSTANT_InvokeDynamic_info 常量中提供的信息,虚拟机 可以找到并且执行引导方法,从而获得一个Callsite对象,最终调用要执行的目标方法,看个荔枝:
5)掌控方法分派规则
  • 5.1)定义:invokedynamic指令与前面4条“invoke*”指令的最大差别就是: 它的分配逻辑不是由虚拟机决定的,而是由程序员决定的。(干货——invokedynamic指令与前面4条“invoke*”指令的最大差别
  • 5.2)problem+solution: 
  • jvm(8)-虚拟机字节码执行引擎
    • problem)如何在类Son 中调用 祖类GrandFather 中的方法呢?
    • solution)通过MethodType 和 MethodHandle 来解决;
    • public class MethodInvokeTest {
      	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() {
      			// 这里如何调用 GrandFather.thinking 方法呢?
      		}
      	}
      	
      	class SonReSolution extends Father {
      		void thinking() {
      			try {
      				MethodType type = MethodType.methodType(void.class);
      				MethodHandle handle = MethodHandles.lookup().findSpecial(GrandFather.class, "thinking", type, getClass());
      				handle.invoke(this);
      			} catch (Exception e) {
      				e.printStackTrace();
      			} catch (Throwable e) {
      				e.printStackTrace();
      			}
      		}
      	}
      	
      	public static void main(String[] args) {
      		SonReSolution son = new MethodInvokeTest().new SonReSolution();
      		son.thinking();
      	}
      }


【4】基于栈的字节码解释执行引擎
【4.1】解释执行
1)大部分的程序代码到物理机的目标代码或虚拟机能执行的指令集之前,都需要经过如下steps:
jvm(8)-虚拟机字节码执行引擎
2)对于一门具体语言的实现来说
2.1)这类代表是C/C++语言:词法分析,语法分析以及后面的优化器和目标代码生成器都可以选择独立于执行引擎,形成一个完整意义的编译器去实现,这类代表是C/C++语言;
2.2)这类代表是java:也可以把其中一部分实现为一个半独立的编译器, 这类代表是java语言;
3)在java语言中:javac 编译器完成了程序代码经过词法分析,语法分析到抽象语法树,在遍历语法树生成线性的字节码指令流的过程。因为这一部分动作是在java虚拟机之外进行的,而解释器是在虚拟机的内部,所以java程序的 编译就是半独立的实现;

【4.2】 基于栈的指令集与基于寄存器的指令集
1)基于栈的指令集:这些指令集中的指令大部分都是零地址指令,依赖操作数栈进行工作;
2)基于寄存器的指令集:这些指令集依赖于寄存器进行工作;
3)基于栈的指令集和基于寄存器的指令集的区别(distinctness):(干货——分别用基于栈的指令集和基于寄存器的指令集计算1+1)
3.1)看个荔枝:(计算1+1)
3.1.1)基于栈的指令集会是这样:
iconst_1 // 
iconst_1 // 两条 iconst_1 指令连续把两个常量1 压入栈后;
iadd // 吧栈顶的两个值出栈,相加,然后吧结果放回到栈顶;
istore_0 // 最后 istore_0 把栈顶的值放到局部变量表的第0个Slot 中;</span>
3.1.2)基于寄存器的程序会是这样:
mov eax, 1
add eax, 1</span>
【4.3】基于栈的解释器执行过程
1)看个荔枝:
jvm(8)-虚拟机字节码执行引擎
对上图的分析(Analysis):
  • A0)javap :提示这段代码需要深度为2的操作数栈和4个Slot 的局部变量空间;
  • A1)执行偏移地址为0的指令的情况:bipush指令的作用是将单字节的整型常量值(-128~127)推入操作数栈顶,这里推送100;
    • jvm(8)-虚拟机字节码执行引擎
  • A2)执行偏移地址为2的指令的情况:istore_1指令的作用是将操作数栈顶的整型值出栈并存放到第1个局部变量Slot中。后续4条指令,都是做一样的事情,也就是在对应代码中把变量a, b, c 赋值为 100, 200, 300;
    • jvm(8)-虚拟机字节码执行引擎
  • A3)执行偏移地址为11的指令的情况:iload_1 指令的作用是 将局部变量表第1个Slot 中的整型值复制到操作数栈顶;
    • jvm(8)-虚拟机字节码执行引擎
  • A4)执行偏移地址为12的指令的情况:iload_2指令的执行过程与 iload_1 类似,把第2个Slot的整型值入栈。
    • jvm(8)-虚拟机字节码执行引擎
  • A5)执行偏移地址为13的指令的情况:iadd指令的作用是将操作数栈中头两个栈顶元素出栈,做整型加法,然后把结果重新入栈。在iadd 指令执行完毕后,栈中原有的100和200 出栈,他们的和300 重新入栈;
    • jvm(8)-虚拟机字节码执行引擎
  • A6)执行偏移地址为14的指令的情况:iload_3指令把存放在第3个局部变量Slot中的300 压入操作数栈中。这是操作数栈为两个整数300.下一条指令 imul 是将操作数栈中头两个栈顶元素出栈,做整型乘法,然后把结果重新入栈,与iadd完全类似;
    • jvm(8)-虚拟机字节码执行引擎
  • A7)执行偏移地址为16的指令的情况:ireturn指令是方法返回指令之一,它将结束方法执行并将操作数栈顶的整型值返回给此方法的调用者。
    • jvm(8)-虚拟机字节码执行引擎