虚拟机字节码执行引擎

来源:互联网 发布:vc控制台 数据库 编辑:程序博客网 时间:2024/05/18 21:08

虚拟机字节码执行引擎

概念

执行引擎是Java虚拟机最核心的组成部分之一。“虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行能力,其区别是物理机的执行引擎是直接建立在处理器、硬件、指令集和操作系统层面上的,而虚拟机的执行引擎则是由自己实现的,因此可以自行制定指令集与执行引擎的结构体系,并且能够执行那些不被硬件直接支持的指令格式。

运行时栈帧结构

栈帧是用于支持虚拟机进行方法调用和方法执行的数据结构,他是虚拟机运行时数据区中的虚拟机栈的栈元素。栈帧储存了方法的局部变量表、操作数栈、动态链接和方法返回地址等信息。每一个方法调用开始至执行完成的过程,都对应这一个栈帧在虚拟机栈从入栈到出栈的过程。

每一个栈帧都包括了局部变量表、操作数栈、动态链接、方法返回地址和一些额外的附加信息。在编译代码的时候,栈帧中需要多大的局部变量表,多深的操作数栈都已经完全去确定了,并且写入方法表的Code属性之中,因此一个栈帧需要分配多少内存,不会受到程序运行期变量数据的影响,而仅仅取决于具体的虚拟机体现。

一个线程中的方法调用链可能会很长,很多方法都同时处于执行状态。对于执行引擎来说,在活动线程中,只有位于栈顶的栈帧才是有效的,称为当前栈帧,与这个栈帧相关联的方法称为当前方法。执行引擎运行的所有字节码指令都只针对当前栈帧进行操作。

栈帧的概念结构

局部变量表

局部变量表示一组变量值储存空间,用于存放方法参数和方法内部定义的局部变量。在Java程序编译为Class文件时,就在方法的Code属性的max_locals数据项中定义了该方法所需分配的局部变量表的最大容量。

局部变量表的容量是以变量槽(Variable slot,下称slot)为最小单位,虚拟机规范中并没有明确致命一个slot应该占用的内存空间的大小,只是很有导向性的说到每个Slot都应该能存放一个boolean,byte,char,short,int,float,reference或returnAddress类型数据,这8种数据类型,都可以使用32位或更小的物理内存来存放,但这种描述与明确指出“每个slot占用32位长度的内存空间”是有一些差别的,他允许slot的长度可以随着处理器、操作系统或虚拟机的不同而发生变化。只要保证及时在64位虚拟机中使用了64位的物理内存空间去实现一个slot,虚拟机仍要使用对齐和补白的手段让slot在外观上看起来与32位虚拟机中一致。

操作数栈

操作数栈也常称为操作栈,他是一个后入先出栈。同局部变量表一样,操作数栈的最大深度也在编译的时候写入到Code属性的max_stacks数据项中。操作数栈的每一个元素可以是任意的Java数据类型,包括long和double。32位数据类型所占的栈容量为1,64位数据所占的栈容量为2.在方法执行的任何时候,操作数栈的深度都不会超过在max_stacks数据项中设定的最大值。

动态连接

每个栈帧都包含一个指向运行时常量池中该栈帧所属方法的引用,持有这个引用是为了支持方法调用过程中的动态连接。

方法返回地址

当一个方法开始执行后,只有两种方式可以退出这个方法。第一种是执行引擎遇到任意一个方法返回的字节码指令,这时候可能会有返回值传递给上层的方法调用者(调用当前方法的方法称为调用者),是否有返回值和返回值的类型将根据遇到何种方法返回指令来决定这种退出方式称为正常完成出口。

另外一种退出方式是,在方法执行过程中遇到了异常,并且这个异常没有在方法体内得到处理,无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在本方法的异常表中没有搜索到匹配的异常处理器,就会导致方法退出,这种退出方法的方式称为异常完成出口退出,是不会给上层调用者任何返回值的。

附加信息

虚拟机规范允许具体的虚拟机实现增加一些规范里没有描述的信息到栈帧之中,例如与调试相关的信息,这部分信息完全却绝与具体的虚拟机实现,这里不再详述。

方法调用

方法调用不等同于方法执行,方法调用阶段唯一的任务就是确定被调用方法的版本,暂时还不涉及方法内部的具体实现过程。

解析

所有方法调用中的目标方法在Class文件里面都是一个Class文件里面一个常量池中的符号引用,在类加载的解析阶段,会将其中一部分符号引用转化为直接引用。这种解析能成立的前提是:方法在程序真正运行之前就有一个可确定的调用版本,并且这个方法的调用版本在运行期是不可改变的。换句话说,调用目标在程序代码写好、编译器进行编译时就必须确定下来。这类方法称为解析。

在java语言中符合“编译器可知,运行期不可变”这个要求的方法,主要包括静态方法和私有方法两大类,前者与类型直接关联,后者在外部不可访问。

分派

静态分派

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.satHello(woman);  }}

运行结果:

hello,guy;

hello,guy;

Human man = new Man();

我们把上面代码中的”Human”称为变量的静态类型,或者叫做外观类型,后面的“Man”则称为变量的实际类型,静态类型和实际类型在程序中都可以发生一些变化,区别是静态类型的变化仅仅在使用时发生,变量本省的静态类型不会被改变,并且最终的静态类型是在编译期可知的;而实际类型变化的结果在运行期才可确定,编译器在编译程序的时候并不知道一个对象的实际类型是什么。例如下面的代码:

//实际类型变化Human man = new Man();man = new Woman();//静态类型变化sr.sayHello((Man)man);sr.sayHello((Woman)man);

所有依赖静态类型来定位方法执行版本的分派动作称为静态分配。静态分派的典型应用是方法重载。静态分配发生在编译阶段,因此确定静态分派的动作实际上不是虚拟机来执行的。另外,编译器虽然能够确定出方法的重载版本,但在很多情况下这个重载版本并不是唯一的,往往只能确定一个更合适的版本。这种模糊的结论在由0和1构成的计算机世界中算是比较稀罕的事情,产生这种模糊结论的主要原因是字面量不需要定义。

动态分派

package com.jvm.memory;/** * @author:Mingming * @Description: * @Date:Created in 18:29 2017/10/25 * @Modificd By: */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

在使用对象调用方法的时候,首先将调用方法的对象压入栈中,然后运行方法的调用指令。在调用指令执行时会用到invokevirtual指令

invokevirtual运行时的解析过程:

1)找到操作数栈顶的第一个元素所指向的元素的实际类型,记做C

2)如果在类型C中找到与常量中描述符合简单名称都相符的方法,则进行访问权限校验,如果通过则返回这个方法的直接引用,查找过程结束;如果不通过,则返回java.lang.IllegalAccessErrot异常。

3)否则,按照继承关系从下往上一次对C的各个父类进行第二步的搜索和验证过程。

4)如果始终没有找到合适的方法,则抛出java.lang.AbstractMethodError异常。

单分派多分派

方法的接收者与方法的参数统称为方法的宗量,这个定义最早来源于《Java与模式》一书。根据分派基于多少种宗量可以将分派划分为单分派和多分派。单分派是根据一个宗量对目标方法进行选择,多分派则是根据多于一个宗量对目标方法进行选择。

package com.jvm.memory;/** * @author:Mingming * @Description: * @Date:Created in 18:58 2017/10/25 * @Modificd By: */public class Dispath {    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

编译阶段编译器的选择过程,也就是静态分配的过程。这是选择目标的方法的依据有两点:一是静态类型是Father还是Son,而是方法参数是QQ还是360.这次选择结果的最终产物是产生两条incokevirtual指令,两条指令的参数分别为常量池中指向Father.hardChoice(360)及Father.hardChoice(QQ)方法的符号引用。因为是根据两个宗量进行选择,所以Java语言是静态分派属于多分派类型。

再看看运行阶段虚拟机的选择,也就是动态分配的过程。在执行”son.hardChoice(new QQ())”这句代码时,更准确的说,是在执行这局代码对应的incokevirtual指令时,由于编译期已经决定目标方法的签名必须为hardChoice(QQ),虚拟机此时不会关心传递过来的参数“QQ”到底是“腾讯QQ”还是“奇瑞QQ”,因为这是参数的静态类型、实际类型都对方法的选择不会构成任何影响,唯一可以影响虚拟机选择的因素只有此方法的接收者的实际类型Father还是Son。因为只有一个宗量作为选择依据,所以Java的动态分派属于单分派类型。

虚拟机动态分派的实现

由于动态分配时非常频繁的动作,而且动态分派的方法版本选择过程需要运行时在类的方法元数据中搜索合适的目标方法,因此在虚拟机的实际实现中基于性能的考虑,大部分实现都不会真正的进行如此频繁的搜索。面对这种情况,最常用的“稳定优化”手段就是为类在方法区中建立一个虚方法表,使用虚方法表来索引来代替元素据查找以提高性能。

虚方法表中存放着各个方法的实际入口地址。如果某个方法在子类中没有被重写,那子类的虚方法表里面的地址入口和父类相同方法的地址入口是一致的,都指向父类的实现入口。如果子类中重写了这个方法,子类方法表中的地址将会替换为指向子类实现版本的入口地址。

虚拟机除了使用方法表之外,在条件允许的情况下,还会使用内联缓存和基于“类型继承关系分析”技术的守护内联两种非稳定的激进优化手段来获得更高的性能。

动态类型语言支持

package com.jvm.memory;import java.lang.invoke.MethodHandle;import java.lang.invoke.MethodType;import static java.lang.invoke.MethodHandles.lookup;/** * @author:Mingming * @Description: * @Date:Created in 20:04 2017/10/25 * @Modificd By: */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();        getPrintlnMH(obj).invokeExact("icyfenix");    }    private static MethodHandle getPrintlnMH(Object reveiver) throws Throwable{      /*Method:代表“方法类型”,包含了方法返回值和具体参数      */        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就可以写出类似下面的函数声明:

void sort(List list,MethodHandle compare);

从上面的例子我们可以看出,使用MethodHandle并没有什么困难,但是和反射有相似之处。不过他们还是有以下区别:

从本质上来讲,Reflection和MethodHandle机制都是在模拟方法调用,但Reflection是在模拟java代码层次的调用,而MethodHandle是在模拟字节码层次的方法调用。在MethodHanldes.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上也应当可以采用类似思路去支持。而反射去调用方法则不行。

invokedynamic指令

每一处含有invokedynamic指令的位置都可以称作“动态调用点”,这条指令的第一个参数不再是代表方法符号引用的CONSTANT_Methodref_info常量而是变为JDK1.7新加入的CONSTANT_InvokeDynamic_info常量,从这个新常量中可以得到3项信息:引导方法(Bootstrap Method,此方法存放在新增的BootstrapMethods属性中)、方法类型和名称。引导方法是有固定的参数,并且返回值是java.lang.invoke.CallSite对象,这个代表真正要执行的目标方法的调用。根据CONSTANT_InvokeDynamic_info常量中提供的信息,虚拟机可以找到并且执行引导方法,从而获得一个CallSite对象,最终调用要执行的目标方法。

基于栈的字节码解释执行引擎

基于栈的指令集与基于寄存器的指令集

java编译器输出的指令流,基本上是一种基于栈的指令集架构,指令流中的指令大部分都是零地址指令,他们一来操作数栈进行工作。与之相对应的是另外一套常用的指令集架构是基于寄存器的指令集,最经典的就是X86的二进制地址指令集,说的通俗一些,就是现在我们主流PC机中直接支持的指令集架构,这些指令依赖寄存器进行工作。

基于栈的指令集主要优点就是可一直,寄存器由硬件直接提供,程序直接依赖这些硬件寄存器则不可避免的收到硬件的约束。栈架构指令集的主要缺点是执行速度相对来说要慢一些。所有主流的物理机的指令集都是寄存器架构也从侧面说明了这一点。

原创粉丝点击