深入理解JVM字节码,jvm能够直接运行java字节码

  深入理解JVM字节码,jvm能够直接运行java字节码

  序言执行引擎是Java虚拟机的核心组件之一。“虚拟机”是相对于“物理机”的概念而言的。两台机器都有执行代码的能力。不同的是,物理机的执行引擎直接构建在处理器、硬件、指令集和操作系统的层面上,而虚拟机的执行引擎是在此基础上实现的。因此它可以自己制作指令集和执行引擎的结构体系,可以执行那些硬件不直接支持的指令集格式。

  JVM的概念字节码执行引擎:输入字节码文件,然后解析处理字节码,最后输出执行结果。

  在Java虚拟机规范中,建立了虚拟机字节码执行引擎的概念模型,成为各类虚拟机执行引擎的统一门面。在不同的虚拟机实现中,当执行Java代码时,执行引擎可能有两种方式:解释执行(由解释器执行)和编译执行(由即时编译器生成的本地代码执行),或者两者都有,甚至可能包括几个不同级别的编译器执行引擎。但从外观上看,所有Java虚拟机的执行引擎都是一样的:输入是一个字节码文件,处理过程是字节码解析的等价过程,输出是执行结果。

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

  每个堆栈框架包括局部变量表、操作数堆栈、动态链接、方法返回地址和一些附加信息。编译程序代码时,堆栈框架需要多少局部变量表,操作数堆栈有多深,已经完全确定,并写入方法表的Code属性。所以一个栈帧需要分配多少内存,不会受程序运行时的变量数据影响,只取决于具体的虚拟机实现。

  一个线程中的方法调用链可能很长,很多方法都处于执行状态。对于执行引擎来说,活动线程中只有栈顶的栈帧才有效,称为当前栈帧,与这个栈帧关联的方法称为当前方法。引擎执行的所有字节码指令都在当前堆栈帧上操作。在概念模型上,典型的堆栈框架结构如下:

  局部变量表局部变量表是一组变量值存储空间,用来存储方法参数和方法内部定义的局部变量。在Java程序中编译成类文件时,方法需要分配的局部变量表的最大容量是在方法的Code属性的max_locals数据项中确定的。

  1.以可变槽为单位。目前,一个槽存储32位以内的数据类型。

  2.对于64位数据,占用2个槽。

  3.对于示例方法,第0个槽存储这个,然后从1到n依次赋给参数表。

  4.然后根据方法体内定义的变量序列和作用域来分配槽。

  5.槽被重用以节省堆栈帧的空间。这种设计可能会影响系统的垃圾收集行为。操作数堆栈是一个后进先出的堆栈。与局部变量表一样,操作数堆栈的最大深度在编译时被写入Code属性的max_stacks数据项。操作栈的每个元素可以是任何Java数据类型,包括long和double。32位数据类型的堆栈容量为1,64位数据类型的堆栈容量为2。在执行该方法的任何时候,操作数堆栈的深度都不会超过max_stacks数据项中设置的最大值。

  在一个方法执行的开始,它的操作数堆栈是空的。在方法的执行过程中,会有各种字节码指令从操作数堆栈中写入和提取内容,即推入和推出操作。

  操作栈(Operation stack):用于存储方法运行过程中每个指令操作的数据。

  1.操作数堆栈中元素的数据类型必须严格匹配字节码指令的顺序。

  2.虚拟机在实现栈帧时可能会做一些优化,使得两个栈帧有部分重叠的区域,并且已经存储了共同的数据。动态链接每个堆栈框架包含对运行时常量池中堆栈框架的方法的引用。这个引用支持方法调用过程中的动态链接。类文件的常量池中有大量的符号引用,字节码中的方法调用指令以常量池中指向方法的符号引用为参数。在类加载阶段或第一次使用时,这些符号引用中的一些将被转换为直接引用,这种转换将成为静态解析。另一部分会在每次运行过程中转换成直接引用,称为动态连接。

  方法返回地址方法返回地址:方法执行后返回的地址。当一个方法开始执行时,只有两种方法可以退出该方法。

  一个是执行引擎遇到任何方法返回的字节码指令。此时,可以将返回值传递给上级方法的调用方。是否有返回值以及返回值的类型将根据指令返回的方法来确定。这种退出方法的方式称为正常完成退出。

  另一种退出方法是方法执行过程中遇到异常,这个异常不在方法体中处理。无论是Java虚拟机内部产生的异常,还是代码中使用athrow字节码指令产生的异常,只要在这个方法的异常表中没有找到匹配的异常处理程序,方法就会退出。这叫做异常完成退出。通过异常完成退出方式退出的方法不会给上层调用者任何返回值。

  无论采用什么退出方法,方法退出后,都需要回到调用方法的地方,程序才能继续执行。当方法返回时,它可能需要在堆栈框架中保存一些信息,以恢复其上层方法的执行状态。一般来说,当方法正常退出时,调用者的PC计数器的值可以作为返回地址,这个计数器值很可能会保存在堆栈帧中。当方法异常退出时,返回地址由异常处理程序表确定,此信息一般不保存在堆栈框架中。

  实际上,退出一个方法的过程相当于弹出当前栈帧,所以退出时可能的操作包括:恢复上一个方法的局部变量表和操作数栈,将返回值(如果有)压入调用方栈帧的操作数栈,调整PC计数器的值指向方法调用指令后的一条指令等。

  附加信息虚拟机规范允许特定的虚拟机实现将规范中没有描述的一些信息添加到堆栈框架中,例如与调试相关的信息,这些信息完全取决于特定的虚拟机实现。实际操作中,动态连接、方法返回地址等附加信息一般归入一类,成为堆栈帧信息。

  二。方法调用方法调用不同于方法执行。方法调用阶段的唯一任务是确定被调用方法的版本(即调用哪个方法),不涉及方法内部的具体运行过程。

  程序运行时,调用方法是最常见、最频繁的操作。如前所述,类文件的编译过程不包括传统编译中的连接步骤,所有方法调用都只是存储在类文件中的符号引用,而不是方法在运行时内存布局中的入口地址(相当于前面提到的直接引用)。这个特性给Java带来了更强大的动态扩展能力,但也使得Java方法调用过程相对复杂。有必要在类加载甚至运行时确定目标方法的直接引用。

  1.部分方法是在类加载的解析阶段直接确定直接引用关系。

  2.然而,对于实例方法,它也被称为虚拟方法。由于重载和多态,它需要在运行时进行动态委托。解析所有方法调用的目标方法是类文件中常量池的符号引用。在类加载的解析阶段,一些符号引用会被转换成直接引用。这种解析的前提是方法在程序实际运行前有一个可确定的调用版本,并且这个方法的调用版本在运行时不能改变。换句话说,编译器在编写和编译程序代码时,必须确定调用目标。这种方法的调用叫做Resolution。

  Java中满足“编译器感知、运行时不可变”要求的方法主要包括静态方法和私有方法。前者与类型直接相关,而后者不能从外部访问。这两种方法的特点决定了它们不能通过继承或者其他方式重写其他版本,所以都适合在类加载阶段进行解析。

  相应地,在Java虚拟机中提供了用于方法调用的五个字节码指令,即:

  Invokestatic:调用静态方法;Invokespecial:调用实例构造函数方法、私有方法和父方法;KeVIVOIRTUAL:调用所有虚方法;Invokeinterface:调用接口方法将在运行时确定实现此接口的另一个对象。Invokedynamic:在运行时动态解析调用点限定符引用的方法,然后执行该方法。只要能被invokestatic和invokespecial指令调用的方法能在解析阶段确定唯一的调用版本,满足这个条件的有四类:静态方法、私有方法、实例构造函数和父方法,它们会在加载时将符号引用解析成直接引用。这些方法可以称为非虚拟方法。反之,其他方法称为虚方法(final方法除外)。

  除了invokestatic和invokespecial调用的方法,Java中还有一个非虚方法,就是final修饰的方法。虽然最终的方法是由invokevirtual指令调用的,但是因为不能被覆盖,也没有其他版本,所以不需要对方法的接收方进行多态选择,或者多态选择的结果必须是唯一的。Java语言规范中明确说明了final方法是非虚方法。

  解析调用:必须是静态过程,完全可以在编译时确定。在类加载的解析阶段,所有涉及到的符号引用都会被转换成可确定的直接引用,直到运行时才会被延迟。

  调度调用:可以是静态的,也可以是动态的,根据调度的数量分为单次调度和多次调度。这两种调度方式的两两组合构成了四种调度组合:静态单调度、静态多调度、动态单调度和动态多调度。我们来看看方法调度是如何在虚拟机中进行的。静态分派和动态分派静态分派:所有依靠静态类型来定位方法的执行版本的分派动作都称为静态分派。静态分派的一个典型应用是方法重载。静态分派发生在编译阶段,因此确定静态分派的动作实际上不是由虚拟机执行的。

  /* * *方法静态分派演示* */公共类静态分派{ public void say hello(string str){ system . out . println( hello, str);} public void say Hello(int str){ system . out . println( Hello, str);}公共静态void main(String[]args){ static dispatch dispatch=new static dispatch();dispatch . say hello( Lucy );dispatch . say hello(5);}}动态调度:一个方法的执行版本的调度是根据运行时的实际类型来决定的,称为动态调度。动态调度与多态性的另一个重要体现“Override”密切相关。

  /* * *方法动态分派演示* */公共类动态分派{静态抽象类human {抽象void say hello();}静态类Man扩展Human { @ Override void say hello(){ system . out . println( Man say hello!);} }静态类Woman扩展Human { @ Override void say hello(){ system . out . println( Woman say hello!);} }公共静态void main(String[]args){ Human Man=new Man();人类女性=新女性();man . say hello();woman . say hello();男人=新女人();man . say hello();}}单分派和多分派方法的接收者和参数统称为方法的自变量。这个定义源于《Java 与模式》这本书。根据派单的数量,派单可以分为单次派单和多次派单。

  调度是根据单个数量确定方法的执行版本;很多情况下,调度是根据一个额外的参数来确定方法的执行版本。

  /* * *方法动态分派演示* */公共类动态分派{静态抽象类human {抽象void say hello();}静态类Man扩展Human { @ Override void say hello(){ system . out . println( Man say hello!);} }静态类Woman扩展Human { @ Override void say hello(){ system . out . println( Woman say hello!);} } public static void main(string[]args){//多分配人类Man=new Man();man . say hello();//单分配女人Woman=new Woman();woman . say hello();}}三。基于栈的字节码解释和执行引擎JVM通过基于栈的字节码解释和执行引擎执行指令,JVM的指令也是基于栈的。

  解释Java语言通常被定义为“解释并执行”的语言,但随着JIT和可以直接将Java代码编译成本地代码的编译器的出现,这种说法并不成立。只有确定了要讨论的对象是具体的Java实现版本和执行引擎的运行方式,再来谈解释执行或者编译执行才会更准确。

  无论是解释执行还是编译执行,无论是物理机还是虚拟机,机器都不可能像人一样阅读和理解一个应用,然后获得执行它的能力。大部分程序代码在到达物理机的目标代码或者虚拟机执行的指令之前,都需要经过下图的步骤。下图中最下面的分支是传统编译原理中从程序代码到目标机器码的生成过程;中间的分支解释了执行的过程。

  现在大多数基于物理机的语言,Java虚拟机或者Java以外的其他高级语言虚拟机,都会遵循这种基于现代编译原理的思想。在执行之前,程序源代码将进行词法分析和语法分析,并将源代码转换成抽象的语法树。对于特定语言的实现,词法分析、语法分析,以及后面的优化器和目标代码生成器都可以选择独立于执行引擎,形成一个完整的编译器来实现。这类代表就是C/C,也可以是半独立编译器,比如Java。或者把这些步骤和执行都打包在一个封闭的黑盒里,比如大部分JavaScript执行器。

  在Java语言中,Javac编译器完成词法分析、语法分析以抽象语法树,然后遍历语法树以生成字节码指令流的过程。因为这部分动作是在Java虚拟机外部执行的,解释器在虚拟机内部,所以Java程序的编译是半独立的实现。

  很多Java虚拟机的执行引擎在执行Java代码时有两种选择:解释执行(由解释器执行)和编译执行(由即时编译器生成的本机代码执行)。对于最新的Android版本,执行模式是AOT JIT解释执行。

  基于堆栈的指令集和基于寄存器的指令集Java编译器输出的指令流基本上都是基于堆栈的指令集架构。基于堆栈的指令集的主要优点是可移植性。寄存器是硬件直接提供的,直接依赖这些硬件寄存器的程序必然会受到硬件的约束。栈的指令集还有一些其他的优点,比如相对更紧凑(字节码中每个字节对应一条指令,参数需要存储在多地址指令集中),编译实现更简单(不需要空间分配,所有空间都在栈上操作)。

  堆栈指令集的主要缺点是执行速度会稍慢。所有主流物理机的指令集都是寄存器架构,也从侧面印证了这一点。

  虽然堆栈架构指令集的代码非常紧凑,但是完成同样的功能所需的指令集数量一般比寄存器架构要多,因为堆栈出和堆栈入操作本身会产生相当数量的指令。更重要的是,栈是在内存中实现的,频繁的栈访问也意味着频繁的内存访问。与处理器相比,内存永远是执行速度的瓶颈。由于指令数量和内存访问的原因,栈架构指令集的执行速度会相对较慢。

  基于以上原因,Android虚拟机采用基于寄存器的指令集架构。然而,一个不同之处是,上面提到的寄存器在物理机上,而Android指的是虚拟机上的寄存器。

  参考:

  https://www.cnblogs.com/baronzhang/p/11108306.html

  版权归作者所有:原创作品来自博主肖波,转载请联系作者取得授权,否则将追究法律责任。

郑重声明:本文由网友发布,不代表盛行IT的观点,版权归原作者所有,仅为传播更多信息之目的,如有侵权请联系,我们将第一时间修改或删除,多谢。

留言与评论(共有 条评论)
   
验证码: