【JVM】执行引擎

tech2024-04-17  12

在介绍执行引擎之前,我们先来了解几个概念:

机器码:各种用二进制编码方式表示的指令(0101……),叫做机器指令码。开始,人们就用它采编写程序,这就是机器语言。机器语言虽然能够被计算机理解和接受,但和人们的语言差别太大,不易被人们理解和记忆,并且用它编程容易出差错。用它编写的程序一经输入计算机,CPU直接读取运行,因此和其他语言编的程序相比,执行速度最快。机器指令与CPU紧密相关,所以不同种类的CPU所对应的机器指令也就不同。

指令:由于机器码是有0和1组成的二进制序列,可读性实在太差,于是人们发明了指令。指令就是把机器码中特定的0和1序列,简化成对应的指令(一般为英文简写,如mov,inc等),可读性稍好由于不同的硬件平台,执行同一个操作,对应的机器码可能不同,所以不同的硬件平台的同一种指令(比如mov),对应的机器码也可能不同。

指令集:不同的硬件平台,各自支持的指令,是有差别的。因此每个平台所支持的指令,称之为对应平台的指令集。

汇编语言:由于指令的可读性还是太差,于是人们又发明了汇编语言。在汇编语言中,用助记符(Mnemonics)代替机器指令的操作码,用地址符号(Symbol)或标号(Label)代替指令或操作数的地址。在不同的硬件平台,汇编语言对应着不同的机器语言指令集,通过汇编过程转换成机器指令。由于计算机只认识指令码,所以用汇编语言编写的程序还必须翻译成机器指令码,计算机才能识别和执行。

高级语言:为了使计算机用户编程序更容易些,后来就出现了各种高级计算机语言。高级语言比机器语言、汇编语言更接近人的语言。当计算机执行高级语言编写的程序时,仍然需要把程序解释和编译成机器的指令码。完成这个过程的程序就叫做解释程序或编译程序。

字节码:字节码是一种中间状态( 中间码)的二进制代码(文件),它比机器码更抽象,需要直译器转译后才能成为机器码 字节码主要为了实现特定软件运行和软件环境、与硬件环境无关。字节码的实现方式是通过编译器和虚拟机器。编译器将源码编译成字节码,特定平台上的虚拟机器将字节码转译为可以直接执行的指令。

计算机只接受机器指令,其他高级语言到机器语言之间必须经过编译器编译成机器指令才可以执行,所以从高级语言到机器语言之间必须有一个翻译的过程。如下图:

执行引擎概述

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

JVM的主要任务是负责装载字节码到其内部,但字节码并不能够直接运行在操作系统之上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只是一些能够被JVM所识别的字节码指令、符号表,以及其他辅助信息。那么,如果想要让一个Java程序运行起来,执行引擎的任务就是将字节码指令解释/编译为对应平台上的本地机器指令才可以。简单来说,JVM中的执行引擎充当了将高级语言翻译为机器语言的译者。

执行内部模块图如下:

执行引擎的工作过程

执行引擎也就是执行一条条代码的一个流程(执行字节码命令,将其翻译本地机器指令), 而代码都是是包含在方法体内的,所以执行引擎本质上就是执行一个个方法所串起来的流程,对应到操作系统中一个执行流程就是一个Java线程,因为一个Java进程可以有多个同时执行的执行流程。这样说来每个Java 线程就是一个执行引擎的实例,那么在一个JVM实例中就会同时有多个执行引擎在工作,这些执行引擎有的在执行用户的程序,有的在执行JVM内部的程序(如Java垃圾收集器)。

执行引擎在执行一段程序时需要存储一些东西,如操作码需要的操作数,操作码的执行结果需要保存。class类的字节码还有类的对象等信息都需要在执行引擎执行之前就准备好。每个新的执行引擎实例被创建时会为这个执行引擎创建一个虚拟机栈栈和一个PC寄存器,如果当前正在执行一个Java方法,那么在当前的这个虚拟机栈中保存的是该线程中方法调用的状态,包括方法的参数、方法的局部变量、方法的返回值以及运算的中间结果等,而PC寄存器会指向即将执行的下一条指令。

执行引擎在执行的过程中究竟需要执行什么样的字节码指令完全依赖于PC寄存器。每执行完一项指令操作后,PC寄存器就会更新下一条需要被执行的指令地址。当然方法在执行的过程中,执行引擎有可能会通过存储在局部变量表中的对象引用准确定位到存储在Java堆区中的对象实例信息,以及通过对象头中的元数据指针定位到目标对象的类型信息。

Java代码编译和执行的过程

编译过程

上图中这几个步骤是由Javac编译器执行,最终形成字节码。Javac的任务就是将Java源码(.java文件)编译成Java字节码(.class文件),也就是JVM能够识别的二进制码。从表面上看就是将.java文件转化成.class文件,而实际上是将Java源代码转化成一连串的二进制数字,这些二进制数字是有格式的,只有JVM才能正确识别他们具体表达了什么意思。(注意:这个Javac是前端编译,他只是将java这种高级语言编译成JVM可以识别的一种语言而已。真正在本地物理机上并不能执行字节码)

Javac主要是由以下4大模块配合完成上述工作:

词法分析:首先,要读取源代码,一个字节一个字节地读进来,找出在这些字节中哪些是我们定义的语法关键词,如if、else、for等关键词;要识别哪些if是合法的关键词,哪些不是,这个步骤就是词法分析的过程。词法分析的结果就是从源代码中找出一些规范化的 Token流,就像在人类语言中,给你一句话,你要能分辨出其中哪些是词语,哪些是标点符号,哪些是动词,哪些是名词等。语法分析:接着,就是对这些Token流进行语法分析了,这一步就是检查这些关键词组合在一起是不是符合Java语言规范,如在if的后面是不是紧跟着一个布尔判断表达式。就像在人类语言中是不是有主谓宾,主谓宾结合得是否正确,语法是否正确。语法分析的结果就是形成一个符合Java 语言规范的抽象语法树。抽象语法树是一个结构化的语法表达形式,它的作用是把语言的主要词法用一个结构化的形式组织在一起,就像我们大学中所学的离散数学,用数字的形式来表达非数字但又有复杂关系的物质世界。对这棵语法树我们可以在后面按照新的规则再重新组织,这也是编译器的关键所在。语义分析:语义分析的主要工作是把一些难懂的、复杂的语法转化成更加简单的语法,将这个步骤对应到我们人类的语言中,就是将难懂的文言文转化成大家都能懂的白话文,或者注解一下一些成语,便于人们更好地理解。对应到Java中,如将foreach转成for循环结构,还有注解等,最后形成一个注解过后的抽象语法树,这棵语法树更接近目标语言的语法规则。字节码生成器:最后,通过字节码生成器生成字节码,根据经过注解的抽象语法树生成字节码,也就是将一个数据结构转化为另外一个数据结构,就像将所有的中文词语翻译成英文单词后,按照英文语法组装成英文语句。  

执行过程

由执行引擎执行JVM字节码,为了满足Java语言的跨平台性,屏蔽各种操作系统的指令集,处理器差异,不同的编译器都必须遵循字节码指令规范而实现,所以相同的Java程序生成的字节码文件是一样的,执行引擎必须进行二次编译成机器码执行。

解释器和JIT编译器

解释器: 当Java虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行。 JIT (Just In Time Compiler) 编译器: 就是虚拟机将源代码直接编译成和本地机器平台相关的机器语言。

很多时候我们会听到某种语言是编译型语言,某种语言是解释型语言。举一个形象一点的例子:我们把让计算机执行一段代码比作让外国厨师做一道菜。用解释的方式执行一段代码,就好比是,你每次让英国厨师做一道水煮鱼,就得先把中文菜谱里的第一个步骤念给翻译,然后翻译再把这一步骤翻译成英文念给厨师听,等厨师把第一个步骤做完,你就接着将第二个步骤念给翻译.........直到做完。(这里的翻译者就是解释器)那么编译执行一段代码就好比是你预先将整个中文菜谱都交给翻译者,让他写出一套完整的英文菜谱,这样厨师做水煮鱼的时候就可以直接按照英文菜谱制作。(这里的翻译者就相当于编译器)这里就可以看出编译执行要比解释执行性能好一些。

所以其实同样一段代码既可以解释执行又可以编译执行,比如Java代码的执行就可以分成两类:

将源代码编译成字节码文件,然后在运行时通过解释器将字节码文件转为机器码执行编译执行(直接编译成机器码)。现代虚拟机为了提高执行效率,会使用即时编译技术(JIT,Just In Time)将方法编译成机器码后再执行。(比如JRockit VM内部就不包含解释器,字节码全部都依靠即时编译器编译后执行)

所以某个语言是编译型语言或者某个语言是解释型语言这个说法本身就是伪命题。应该说某个语言的特定实现时编译型或者是解释型的。

HotSpot VM是目前市面上高性能虚拟机的代表作之一。它采用解释器与即时编译器并存的架构。在Java虚拟机运行时,解释器和即时编译器能够相互协作,各自取长补短,尽力去选择最合适的方式来权衡编译本地代码的时间和直接解释执行代码的时间。

 为什么采用解释器和JIT编译器并存的模式?

首先明确:当程序启动后,解释器可以马上发挥作用,省去编译的时间,立即执行。编译器要想发挥作用,把代码编译成本地代码,需要一定的执行时间。但编译为本地代码后,执行效率高。 所以:尽管JRockit VM中程序的执行性能会非常高效,但程序在启动时必然需要花费更长的时间来进行编译。对于服务端应用来说,启动时间并非是关注重点,但对于那些看中启动时间的应用场景而言,或许就需要采用解释器与即时编译器并存的架构来换取一个平衡点。在此模式下,当Java虚拟器启动时,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成后再执行,这样可以省去许多不必要的编译时间。随着时间的推移,编译器发挥作用,根据热点探测功能,将有价值的字节码编译为本地机器指令,以换取更高的程序执行效率。

如何选择让解释器或者是JIT工作?(二者之间如何协调工作)

是否需要启动JIT编译器将字节码直接编译为对应平台的本地机器指令,则需要根据代码被调用执行的频率而定。关于那些需要被编译为本地代码的字节码,也被称之为“热点代码”,JIT编译器在运行时会针对那些频繁被调用的“热点代码”做出深度优化,将其直接编译为对应平台的本地机器指令,以此提升Java程序的执行性能。

什么是热点代码?

一个被多次调用的方法,或者是一个方法体内部循环次数较多的循环体都可以被称之为“热点代码”,因此都可以通过JIT编译器编译为本地机器指令。由于这种编译方式发生在方法的执行过程中,因此也被称之为栈上替换,或简称为OSR(On Stack Replacement)编译。

那么一个方法究竟要被调用多少次,或者一个循环体究竟需要执行多少次循环才可以达到这个标准?必然需要一个明确的阙值,JIT编译器才会将这些“热点代码”编译为本地机器指令执行。这里主要依靠热点探测功能。目前HotSpot VM所采用的热点探测方式是基于计数器的热点探测。采用基于计数器的热点探测,HotSpot VM将会为每一个方法都建立2个不同类型的计数器,分别为方法调用计数器(用于统计方法的调用次数)和回边计数器(用于统计循环体执行的循环次数)

方法调用计数器: 用于统计方法的调用次数,这个计数器就用于统计方法被调用的次数,它的默认阀值在Client模式下是1500次,在Server模式下是10000次。超过这个阈值,就会触发JIT编译。阈值可以通过虚拟机参数 -XX:CompileThreshold 来人为设定。当一个方法被调用时,会先检查该方法是否存在被JIT编译过的版本,如果存在,则优先使用编译后的本地代码来执行。如果不存在已被编译过的版本,则将此方法的调用计数器值加1,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阈值。如果已超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求。

热度衰减

如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减,而这段时间就称为此方法统计的半衰周期可以使用虚拟机参数 -XX:-UseCounterDecay 来关闭热度衰减,让方法计数器统计方法调用的绝对次数,这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码。另外,可以使用-XX:CounterHalfLifeTime参数设置半衰周期的时间,单位是秒。

回边计数器

用于统计循环体的循环次数,它的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”。显然,建立回边计数器统计的目的就是为了触发OSR编译。

 

最新回复(0)