jvm 字节码执行 (二)动态类型支持与基于栈的字节码解释执行
动态类型语言
动态类型语言的关键特征是它的类型检查的主体过程是在运行期而不是编译期。
举例子解释“类型检查”,例如代码:
obj.println("hello world");
假如这行代码是在Java语言中,并且变量obj的静态类型为java.io.PrintStream,那么变量obj的实际类型就必须是PrintStream的子类才是合法。否则,obj属于一个确实
有println(String)方法,单与PrintStream接口没有继承关系,代码依然不可能运行——因为类型检查不合法。是相同的代码在ECMAScript中情况不一样,无论obj是何种类型
只要这种类型的定义中确实包含有println(String)方法,那方法调用便可成功。
这种差别产生的原因是Java语言在编译期已将println(String)方法完整的符号引用生成出来,作为方法调用指令参数存储到Class文件中,例如下面这段代码:
invokevirtual #4; //Method java/io/PrintStream.println:(Ljava/lang/String;)V
这个符号引用包含了此方法定义在哪个具体类型之中、方法的名字以及参数顺序、参数类型和方法返回值等信息,通过这个符号引用,虚拟机可以翻译出这个方法的
直接引用。而在ECMAScript等动态类型语言中,变量obj本身是没有类型的,变量obj的值才是具有类型,编译时最多只能确定方法名称、参数、返回值这些信息,而不会
去确认方法所在的具体类型。“变量无类型而变量值才有类型”这个特点也是动态类型语言的一个重要特征。
静态类型语言在编译期确定类型,最显著的好处是编译期可以提供严谨的类型检查,这样与类型相关的问题能在编码的时候就及时发现,利于稳定性及代码达到最大规模。
而动态类型语言在运行期确定类型,这可以为开发人员提供更大的灵活性,某些在静态类型语言中需要大量“臃肿”代码来实现的功能,由动态类型语言来实现可能会更加清晰
简洁,清晰和简洁通常也意味着开发效率的提升。
MethodHandle
JDK1.7以前的字节码命令指令集中,4条方法调用指令(invokevirtual、invokespecial、invokestatic、invokeinterface)的第一个参数都是被调用的方法的符号引用,而
方法的符号引用在编译时产生,而动态类型语言只有在运行期。这样在Java虚拟机上实现的动态语言类型语言就不得不使用其他方式来实现。
java.lang.invoke包的主要目的是在之前单纯依靠符号引用来确定调用的目标方法这种方式以外,提供一种新的动态确定目标方法的机制,称为MethodHandle。
在拥有了Method Handle之后,Java语言也可以拥有类似函数指针或者委托的方法别名的工具了。
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 MethodHandles.lookup().findVirtual(reveiver.getClass(), "println", mt).bindTo(reveiver);
}
}
方法getPrintlnMH()模拟了invokevirtual指令的执行过程,只不过它的分派过程逻辑并非固话在Class文件的字节码上,而是通过一个方法来实现。这个方法本身的返回值(MethodHandle)
对象,可以视为对最终调用方法的一个“引用”。
Java语言角度来看,MethodHandle的使用方法和效果与Reflection有相似之处,他们还是有区别:
- Reflection 和 MtehodHandle机制都是在模拟方法调用,但Reflection是在模拟java代码层次的方法调用,而MethodHandle是在模拟字节码层次的方法调用
- Reflection API的设计目标是只为Java语言服务的,而MehtodHandle则设计成客服务与所有Java虚拟机上的语言。其中包括Java语言。
invokedynamic指令
invokedynamin指令与MethodHandle机制的作用是一样的,都是为了解决原有4条“invoke*”指令方法分派规则固化在虚拟机之中的问题,如何查找目标方法的决定权从虚拟机转嫁到具体用户
代码之中,让用户有更高的自由度。两者的思路是可类比的,可以把他们想象称为达成同一个目的,一个采用上层java代码和API来实现,另一个用字节码和Class中其他属性、常量来完成。
每一处含有invokedynamic指令的位置都称作“动态调用点”,这条指令的第一个参数不再是代表方法引用的CONSTANT_Methodref_info常量,而是新加入的CONSTANT_InvokeDynamc_info
常量,从这个常量可以得到3项信息:引导方法、方法类型和名称。引导方法是固有参数,并且返回值是java.lang.invoke.CallSite对象,这个代表真正要执行的目标方法调用。根据
CONSTANT_InvokeDynamic_info常量提供的信息,虚拟机可找到并且执行引导方法,从而获得一个CallSite对象,最终调用要执行的目标方法。
方法分派规则
invokedynamic指令与前4条“invoke*”指令的最大差别是它的分派逻辑不是由虚拟机决定的,而是由程序员决定。
基于栈的字节码解释执行引擎
许多Java虚拟机的执行引擎在执行Java代码的时候都有解释执行(通过解释器执行)和编译执行(通过即使编译器产生本地代码执行)两种选择。
解释执行
传统编译原理中程序代码到目标机器代码的生成过程:
Java语言中,Javac编译器完成了程序代码经过词法分析、语法分析到抽象语法树,再遍历语法树生成线性的字节码指令流的过程。因为这一部分动作是在Java虚拟机之外进行的
而解释器在虚拟机内部,所以Java程序的编译就是半独立的实现。
基于栈的指令集与基于寄存器的指令集
Java编译器输出的指令流,基本上是一种基于栈的指令集架构,指令流中的指令大部分都是零地址指令,它们依赖操作数栈进行工作与之相对的另外一套常用的指令集架构
是基于寄存器的指令集,最典型的就是x86的二进制指令集。
计算1+1的结果,基于栈的指令集:
iconst_1 //常量1压入栈 iconst_1 //常量1压入栈
iadd //把栈顶的两个值出栈、相加
istore_0 //结果放回栈顶
基于寄存器:
mov eax, 1 //EAX寄存器的值设置为1
add eax, 1 //把这个值加1,结果保存在eax寄存器里面
基于栈的指令集主要的优点是可移植,寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免地要收到硬件的约束。
使用站架构的指令集,用户程序不会直接使用这些寄存器,就可以由虚拟机实现来自行决定把一些访问频繁的数据放到寄存器中以获取尽量好的性能。栈架构指令集
代码相对更加紧凑、编译器实现更加简单等。
栈架构指令主要缺点是执行速度相对来说要慢一些。所有主流物理机的指令集都是寄存器架构也从侧面印证了这点。虽然指令集的代码非常紧凑,但是完成相同
功能所需的指令数量一般会比寄存器架构多,因为出栈、入栈操作本身就产生了相当多的指令数量。
栈实现在内存之中,频繁的栈访问也就意味着频繁的内存访问,相对于处理器来说,内存始终是执行速度的瓶颈。由于指令数量和内存访问的原因,所以导致了
栈架构指令集的执行速度会相对慢一些。
基于栈的解释器执行过程
代码清单:
public int calc() {
int a = 100;
int b = 200;
int c = 300;
return (a + b) * c;
}
字节码指令:
执行过程中的代码、操作数栈和局部变量表的变化情况:
我们从这段程序执行中可以看出栈结构指令集的一般运行过程,整个运算过程的中间变量都以操作数的出栈、入栈为信息交换途径。