JVM成神路之全面详解执行引擎子系统与J

引言

执行引擎子系统是JVM的重要组成部分之一,在JVM系列的开篇曾提到:JVM是一个架构在平台上的平台,虚拟机是一个相似于“物理机”的概念,与物理机一样,都具备代码执行的能力。但虚拟机与物理机最大的不同在于:物理机的执行引擎是直接建立在处理器、高速缓存、平台指令集与操作系统层面上的,物理机的执行引擎可以直接调用各处资源对代码进行直接执行,而虚拟机则是建立在软件层面上的平台,它的执行引擎则是负责解释编译执行自身定义的指令集代码。同时,也正因Java设计出了JVM虚拟机的结构,从而才使得Java可以不受物理平台限制,能够真正实现“一次编译,到处执行”的理念。

对于执行引擎这块的知识,对于理解JVM是有很大帮助的,但JVM相关现有的文章/书籍资料对这块却少有提及或者泛泛而谈,本篇文章则是准备对JVM的执行引擎子系统进行全面的阐述。

一、机器码、指令集与汇编语言、高级语言的关系

在准备对JVM的执行引擎进行分析之前,首先得搞明白机器码、指令集、汇编语言以及高级语言之间的关系,只有当搞清楚这几者之间的关系后才能更好的弄懂JVM的执行引擎原理。

1.1、机器码

机器码也被称为机器指令码,也就是指各种由二进制编码方式表示的指令(、等),最开始的程序员就是通过这种方式编写程序,用这种方式编写出的代码可以直接被CPU读取执行,因为最贴近硬件机器,所以也是执行速度最快的指令。但因为这种指令和CPU之间是紧紧相关的,所以不同种类的CPU对应的机械指令也不同。同时,机械指令都是由二进制数字组成的指令,对于人来说,实在太过繁杂、难以理解且不容易记忆,容易出错,最终指令的方式代替了这种编码方式。

1.2、指令与指令集

由于机器码都是由0和1组成的指令代码,可读性实在太差,所以慢慢的推出了指令,用于替代机器码的编码方式。指令是指将机械码中特定的0和1组成的序列,简化为对应的指令,如INC、DEC、MOV等,从可读性上来说,对比之前的二进制序列组成的机器码要好上许多。但由于不同的硬件平台的组成架构也不同,所以往往在执行一个指令操作时,对应的机器码也不同,所以不同的硬件平台就算是同一个指令(如INC),对应的机器码也不同。

同时,正是因为不同的硬件平台支持的指令是有些稍许不同的,所以每个平台所支持的指令则被称为对应平台的指令集。比如X86架构平台对应的X86指令集、ARM架构平台对应的ARM指令集等。

1.3、汇编语言

前面虽然通过了指令和指令集的方式替代了之前由0和1序列组成的机器码,但指令的可读性相对来说还是比较差的,所以人们又发明了汇编语言。在汇编语言中,用助记符(Mnemonics)代替机器指令的操作码,用地址符号(Symbol)以及标号(Label)代替指令或操作数的地址。在不同的平台,汇编代码对应不同的指令集,但由于计算机只认机器码,所以通过汇编语言编写的程序必须还要经过汇编阶段,变为计算机可识别的机器指令码才可执行。

1.4、高级语言

为了使得开发人员编写程序更为简易一些,后面就涌现了各种高级语言,如Java、Python、Go、Rust等。高级语言对比之前的机器码、指令、汇编等方式,可读性更高,代码编写的难度更低。但通过高级语言编写出的程序,则需要先经过解释或编译过程,先翻译成汇编指令,然后再经过汇编过程,转换为计算机可识别的机器指令码才能执行。

各语言与硬件平台的关系

OK~,简单的叙述了一下机器码、指令集与汇编语言、高级语言的关系,从这段阐述中可以得知,Java属于一门高级语言,在执行的时候需要将它编写的代码先编译成汇编指令,再转换为机械指令才能被计算机识别。但似乎我们在使用Java过程中,好像没有这个过程呀?这样因为什么原因呢?

这是因为Java存在JVM这个虚拟平台,JVM的主要任务是负责将javac编译后生成的字节码文件装载到其内部,但字节码并不能够直接运行在操作系统之上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只是一些能够被JVM所识别的字节码指令、符号表和其他辅助信息,这些Java字节码指令是无法直接被OS识别的。那么一个Java程序可以在操作系统上跑起来的根本原因在于什么呢?答案是:依靠于JVM的执行引擎子系统。

二、初窥JVM执行引擎与源码编译原理

Java的执行引擎子系统的主要任务是将字节码指令解释/编译成对应平台上的本地机器指令,简单来说,JVM执行引擎是充当Java虚拟机与操作系统平台之间的“翻译官”的角色。

而目前主要的执行技术有:解释执行、静态编译、即时编译、自适应优化、芯片级直接执行,释义如下:

解释执行:程序在运行过程中,只有当每次用到某处代码时,才会将某处代码转换为机器码交给计算机执行。

静态编译:所谓的静态编译是指程序在启动前,先根据对应的硬件/平台,将所有代码全部编译成对应平台的机器码。

即时编译:程序运行过程中,通过相关技术(如HotSpot中的热点探测)动态的探测出运行比较频繁的代码,然后在运行过程中,将这些执行比较频繁的代码转换机械码并存储下来,下次执行时则直接执行机器码。

自适应优化:开始对所有的代码都采取解释执行的方式,并监视代码执行情况,然后对那些经常调用的方法启动一个后台线程,将其编译为本地代码,并进行仔细优化。若方法不再频繁使用,则取消编译过的代码,仍对其进行解释执行。

芯片级直接执行:也就是直接编写机器码的方式,编写出的代码可以直接被CPU识别,读取后可以直接执行。

如上便是现有的一些执行技术,在其中解释执行属于第一代JVM,即时编译JIT属于第二代JVM,自适应优化(目前Sun的Hotspot采用这种技术)则吸取第一代JVM和第二代JVM的经验,采用两者结合的方式。而静态编译的技术在BEA公司的JRockit虚拟机以及JDK9的AOT编译器中都实现了,这样做的好处在于:执行性能堪称最佳,但缺点在于:启动的时间会很长,同时也打破了Java“一次编译,到处运行”的原则。

其实在Java刚诞生时,JDK1.0的时候,Java的定位是一门解释型语言,也就是将Java程序编写好之后,先通过javac将源码编译为字节码,再对生成的字节码进行逐行解释执行。但这样就导致了程序执行速度比较缓慢,启动速度也并不乐观,因为启动时需对于未编译的.java文件进行编译,而且编译之后生成的字节码指令也不能被计算机识别,还需要在执行时再经过一次解释后,才能变为计算机可识别的机器码指令,从而才能使得代码被机器执行。经过如上分析,JDK1.0时的这种解释执行的缺点非常明显,Java为了做到“一次编译,到处运行”这个准则,将程序的综合性能大大拉低了,为什么呢?因为对比其他语言多了一个步骤。一般来说,一个Java程序想要运行,必须要经过先编译,再解释的过程才可以真正的执行。而我们此时再来看看其他语言的执行。

纯编译型语言:在程序启动时,将编写好的源码全部编译为所处平台的机械码指令。特点:执行性能最佳,启动时间较长,移植性差,不同平台需要重新发包。纯解释型语言:在程序运行过程中,需要执行某处代码时,再将该代码解释为平台对应的机械码指令,然后交由计算机执行。特点:启动速度快,执行性能较差,移植性较好。

OK~,简单的看了一下解释型和编译型的语言特点之后,再回过头来想想1.0版本的Java,是不是发现Java因为虚拟机的存在,搞的不上不下的,卡在了中间。因为在Java程序运行时,既要编译源码,又要解释执行,所以最终导致执行性能一般,启动速度也一般。

再到后来,Java为了解决这个问题,在1.2的时候推出了一款后端编译器,也就是JIT即时编译器(后面分析),它可以支持在Java在执行过程中动态生成本地的机械码。现代的高性能JVM都是采用解释器与即使编译器共存的模式工作,所以Java也被称为“半解释半编译型语言”。

而本篇则会基于目前的HotSpot虚拟机对JVM的执行引擎进行分析,它的执行引擎中也采用解释器与即使编译器共存的模型工作,但这款虚拟机的执行模式采用的是自适应优化方案执行。

2.1、执行引擎工作过程

对于执行引擎而言,在《虚拟机规范》中曾提到了,要求所有厂商在实现时,输入输出都必须一致,也就是执行引擎接受的输入内容必须为字节码的二进制流数据,而输出的则必须为程序的执行结果。而执行引擎到底需要执行什么操作,完全是依赖与PC寄存器(程序计数器)的,每当执行引擎处理完一项指令操作后,程序计数器就需要更新下一条需要被执行的指令地址。

在执行Java方法过程中,执行引擎也有可能会根据栈帧中操作数栈的引用信息,直接去访问存储在堆中的Java对象实例数据,也有可能会通过实例对象的对象头中记录的元数据指针(KlassWord)去定位对象的类型信息,也就是会通过元数据指针去访问元数据空间(方法区)中的数据。如下图:

执行引擎工作过程

2.1.1、Java源码编译过程

在之前提及过,JVM只识别字节码文件,所以当编写好.java后缀的Java源码时,我们往往还需要通过javac这样的源码编译器(前端编译器),对Java代码进行编译生成.class后才能被JVM装载进内存,源码编译过程如下:

Java源码编译过程

编译是指将一种语言规范转化成另外一种语言规范,通常编译器都是将便于人理解的语言规范(编程语言)转化成机器容易理解的语言规范(由二进制序列组成的机械码)。比如C/C++或汇编语言都是将源代码直接编译成目标机器码。

javac作为Java语言的源码编译器,它编译的目的却不是为了针对于某个硬件平台进行编译的,而是为JVM进行编译,javac的任务就是将Java源代码转换为JVM可识别的字节码,也就是文件到文件的过程。对于怎么消除不同种类,不同平台之间的差异这个任务就交由JVM来处理,由JVM中的执行引擎来负责将字节码指令翻译成当前程序所在平台可识别的机械码指令。

javac编译过程具体释义如下:

①词法分析:先读取源代码的字节流数据,然后根据源码语言的语法规则找出源代码中的定义的语言关键字,如

if、else、while、for

等,然后判断这些关键字的定义是否合法,对于合法的关键字生成用于语法分析的记号序列,同时创建符号表,将

所有的标识符记录在符号表中,这个过程就被称为词法分析。

符号表的作用:记录源代码中使用的标识符,收集每个表示符的各种属性信息。

词法分析的结果:从源代码中找出一些合法的Token流,生成记号序列。

②语法分析:对词法分析后得到的

流进行语法分析,就是依据源程序的语法规则,检查这些关键词组合在一起是否符合Java语言规范,比如if的后面是不是紧跟着一个布尔型判断表达式、else是否写在if后面等。对于符合规范的,组织上一步产生的记号序列生成语法树。

语法分析的结果:形成一颗符合Java语言规定的抽象语法树。抽象语法树是一个结构化的语法表达形式,它的作用是把语言的主要词法用一个结构化的形式组织在一起,这棵语法树可以被后面按照新的规则再重新组织。

③语义分析:经过语法分析后就不存在语法错误这些问题了,语义分析主要任务有两个,一个是对上步产生的语法树进行检查,其中包括类型检查、控制流检查、唯一性检查等,第二个则是将一些复杂的语法转换为更简单的语法,相当于把一些文言文、古诗、成语翻译成大白话的意思。

语义分析的结果:简化语法后会生成一棵语法树,这棵语法树也就更接近目标语言的语法规则。

④字节码生成:将简化后的语法树转换为

Class

文件的格式,也就是在该阶段会根据简化后的语法树生成字节码。

字节码生成的结果:生成符合虚拟机规范的字节码数据。

经过如上过程后,编写程序时的源代码文件会被转换字节码文件,然后这些字节码会在启动时,被虚拟机的类加载机制装载进内存,当程序运行过程中,调用某个方法时,就会将对应的字节码指令交由执行引擎处理。

总的来说,Java代码执行的过程会主要分为三个阶段,分别为:源码编译阶段、类加载阶段以及类代码(字节码)执行阶段,接着我们再来分析一下执行阶段的过程。

2.1.2、执行引擎执行过程

被加载进内存的字节码最终执行是由执行引擎来负责的,但JVM的执行引擎并不能真正的执行字节码指令,而是将字节码指令翻译成本地机械指令交由物理机的执行引擎来真正的执行的。整体流程如下:

JVM执行引擎执行过程

一般而言,在字节码被加载进内存之后,都会经过如上几个步骤才会被翻译成本地的机械指令执行,但这几个优化步骤却并不是必须的,如果不需要也可以在程序启动时通过JVM参数关闭。但综合而言,虽然优化的过程会耗费一些时间,但这样却能够大大的提升程序在执行时的速度,所以总归而言利大于弊。

OK~,从上图中可以看出,执行引擎的入口的数据是字节码文件,而在HotSpot虚拟机中对于Class文件结构的定义如下:

structClassFile{u4magic;//识别Class文件格式,具体值为0xCAFEBABEu2minor_version;//Class文件格式副版本号u2major_version;//Class文件格式主版本号u2constant_pool_count;//常量表项个数cp_info**constant_pool;//常量表,又称变长符号表u2access_flags;//Class的声明中使用的修饰符掩码u2this_class;//常数表索引,索引内保存类名或接口名u2super_class;//常数表索引,索引内保存父类名u2interfaces_count;//超接口个数u2*interfaces;//常数表索引,各超接口名称u2fields_count;//类的域个数field_info**fields;//域数据,包括属性名称索引u2methods_count;//方法个数method_info**methods;//方法表:包括方法名称索引/方法修饰符掩码等u2attributes_count;//类附加属性个数attribute_info**attributes;//类附加属性数据,包括源文件名等};

任何后缀的Java源码经过编译后都会生成为符合如上格式的class字节码文件。执行引擎接收的输入格式也为如上格式的文件,不过值得注意一提的是:JVM不仅仅只接收文件编译成的文件,对于所有符合如上格式规范的字节码文件都可以被JVM接收执行。

HotSpot虚拟机是基于栈式的,也就代表着执行引擎在执行方法时,执行的是一个个的栈帧,栈帧中包含局部变量表、操作数栈、动态链接以及方法返回地址等描述方法的相关信息。但执行引擎在虚拟机运行时,只会执行最顶层的栈帧,因为最顶层的栈帧是当前需要执行的方法,执行完当前方法后会弹出顶部的栈帧,然后将下一个栈帧(新的顶部栈帧)拿出继续执行。刚刚提到了方法的相关信息被存储在栈帧中,而栈帧的方法信息是从字节码文件中读出来的,每个方法通过结构体来描述,如下:

struct{u2;//方法修饰符掩码u2name_index;//方法名在常数表内的索引u2descriptor_index;//方法描述符,其值是常数表内的索引u2;//方法的属性个数**attributes;//方法的属性表(局部变量表)};

在中存在一个类型的成员attributes,该成员就是平时所说的局部变量表,其内也存放着方法参数和方法内的局部变量,当方法是实例方法时,局部变量表的第0位会被用来传递方法所属对象的引用,即this。Java虚拟机执行引擎是基于栈式的,栈就是操作数栈,操作数栈的深度也是记录在方法属性集合的Code属性中,同时成员中也记录着局部变量表所需的空间大小。

下面来个简单的例子感受一下执行引擎执行的过程:

/*------Java代码------*/publicintadd(){inta=3;intb=2;intc=a+b;returnc;}/*------javap-c-v-p查看到的字节码(省略描述方法的字节码)------*/0:iconst_3//将3放入操作数栈顶1:istore_1//写出操作数栈顶部元素,并将其放在局部变量表中索引为1的位置2:iconst_2//将2放入操作数栈顶3:istore_2//写出操作数栈顶部元素,并将其放在局部变量表中索引为2的位置4:iload_1//从局部变量表中加载索引位置=1的数据值5:iload_2//从局部变量表中加载索引位置=2的数据值6:iadd//弹出操作栈顶的两个元素并进行加操作(3+2)7:istore_3//将加之后的结果刷写到局部变量表中索引为3的位置8:iload_3//从局部变量表中加载索引位置=3的数据值8:ireturn//将加载的c返回

对于如上过程中,前四条分配指令就不分析了,重点分析一下后面的运算过程,也就是c=a+b这个过程,具体执行如下:

①数据a从局部变量表经过总线传输到操作数栈

②数据b

③数据从操作数栈经过总线传输给CPU

④数据

⑤计算完成后,将结果通过数据总线传输到操作数栈

⑥运算结果从操作数栈经过总线传输到

⑦将数据经过总线传输到局部变量表赋值给c

⑧将计算后的结果从局部变量表索引为3的位置加载到操作数栈

⑨最后使用ireturn指令将计算后的结果返回给方法的调用者

如上便是栈式虚拟机的执行过程,其中所提到的局部变量表会在编译器确定长度,也就是等于一个加上三个局部变量,长度最终为4。当程序执行到方法定义的那行代码时,局部变量表中会被依次填入数据:this、3、2,同时程序计数器会跟着代码的执行位置不断更新,当执行完add操作后,会将数据a+b的结果5再填入局部变量表。

三、详解JVM执行引擎子系统

在第二阶段,咱们简单的分析了一下Java代码的编译过程以及执行过程,同时在前面也提到了,Java是使用解释器+编译器共存的模式工作的,也就代表着JVM执行引擎子系统中,是包含了解释器和编译器的,如下图:

JVM执行引擎子系统

Java虚拟机的执行引擎子系统中包含两种执行器,分别为解释器和即时编译器。当执行引擎获取到由javac编译后的字节码文件后,在运行时是通过解释器(Interpreter)转换成最终的机械码执行。另外为了提升效率,JVM加入了一种名为JIT即时编译的技术,即时编译器的目的是为了避免一些经常执行的代码被解释执行,JIT会将整个函数编译为平台本地的机械码,从而在很大程度上提升了执行的效率。

3.1、解释器(Interpreter)

当Java程序运行时,在执行一个方法或某处代码时,会找到文件中对应的字节码,然后会根据定义的规范,对每条需执行的字节码指令逐行解释,将其翻译成平台对应对应的本地机械码执行。当一条字节码指令被解释执行完成后,紧接着会再根据PC寄存器(程序计数器)中记录的下一条需被执行指令,读取并再次进行解释执行操作。

在HotSpot虚拟机中,解释器主要由Interpreter模块和Code模块构成,Interpreter模块实现了解释执行的核心功能,Code模块主要用于管理解释器运行时生成的本地机械指令。

3.2、JIT即时编译器(JustInTimeCompiler)

由于解释器实现简单,并且具备非常优异的跨平台性,所以现在的很多高级语言都采用解释器的方式执行,比如Python、Rust、JavaScript等,但对于编译型语言,如C/C++、Go等语言来说,执行的性能肯定是差一筹的,而前面不止一次提到过:Java为了解决性能问题,所以采用了一种叫做JIT即时编译的技术,也就是直接将执行比较频繁的整个方法或代码块直接编译成本地机器码,然后以后执行这些方法或代码时,直接执行生成的机器码即可。

OK~,那么对于上述中执行次数比较频繁的代码判断基准又是什么呢?答案是:热点探测技术。

3.3、热点代码探测技术

HotSpotVM的名字就可以看出这是一款具备热点代码探测能力的虚拟机,所谓的热点代码也就是指调用次数比较多、执行比较频繁的代码,当某个方法的执行次数在一定时间内达到了规定的阈值,那么JIT则会对于该代码进行深度优化并将该方法直接编译成当前平台对应的机器码,以此提升Java程序执行时的性能。

一个被多次调用执行的方法或一处代码中循环次数比较多的循环体都可以被称为热点代码,因此都可以通过JIT编译为本地机器指令。

3.3.1、栈上替换

纵观所有编程语言,类似于C/C++、GO等编译型语言,都属于静态编译型,也就是指在程序启动时就会将所有源代码编译为平台对应的机器码,但JVM中的JIT却属于动态编译器,因为对于热点代码的编译是发生在运行过程中的,所以这种方式也被称之为栈上替换(OnStackReplacement),在有的地方也被称为OSR替换。

3.3.2、方法调用计数器与回边计数器

前面提到过:“一个被多次调用执行的方法或一处代码中循环次数比较多的循环体都可以被称为热点代码”,那么一个方法究竟要被调用多少次或一个循环体到底要循环多少遍才可被称为热点代码呢?必然会存在一个阈值,而JIT又是如何判断一段代码的执行次数是否达到了这个阈值的呢?主要依赖于热点代码探测技术。

在HotSpotVM中,热点代码探测技术主要是基于计数器实现的。HotSpot中会为每个方法创建两个不同类型的计数器,分别为方法调用计数器(InvocationCounter)和回边计数器(BackEdgeCounter),方法调用计数器主要用于统计方法被调用的次数,回边计数器主要用于统计一个方法体中循环体的循环次数。

方法调用计数器

方法调用计数器的阈值在Client模式下默认是次,在Server模式下默认是次,当一段代码的执行次数达到这个阈值则会触发JIT即时编译。当然,如果你对这些缺省(默认)的数值不满意,也可以通过JVM参数-XX:CompileThreshold来自己指定。

JIT方法调用计数器编译原理

如上,当一个方法被调用执行时,会首先检查该方法是否已经被JIT编译过了,如果是的话,则直接执行上次编译后生成的本地机器码。反之,如果还没有编译,则先对方法调用计数器+1,然后判断计数器是否达到了规定的阈值,如果还未达到阈值标准则采用解释器的模式执行代码。如果达到了规定阈值则提交编译请求,由JIT负责后台编译,后台线程编译完成后会生成本地的机器码指令,这些指令会被放入CodeCache中缓存起来(热点代码缓存,存放在方法区/元数据空间中),当下次执行该方法时,直接从缓存中读取对应的机械码执行即可。

回边计数器

回边计数器的作用是统计一个方法中循环体的执行次数,在字节码中遇到控制流向后跳转的指令称为“回边”(BackEdge)。与方法调用计数器一样,当执行次数达到某个阈值后,也会触发OSR编译。如下图:

JIT回边计数器编译原理

OK~,回边计数器的编译过程和方法调用计数器的相差无几,唯一值得一提的就是:不管是方法调用计数器还是回边计数器,在提交OSR编译请求的那次执行操作,还是依旧会采用解释器执行,而不会等到编译操作完成后去执行机器码,因为这样耗费的时间比较长,只有下次再执行该代码时才会执行编译后的机器码。

3.3.3、热度衰减

一般而言,如果以缺省参数启动Java程序,那么方法调用计数器统计的执行次数并不是绝对次数,而是一个相对的执行频率,也代表是指方法在一段时间内被执行的次数。当超过一定的时间,但计数器还是未达到编译阈值无法提交给JIT即时编译器编译时,那此时就会对计数器进行减半,这个过程被称为方法调用计数器的热度衰减(CounterDecay),而这段时间则被称为方法调用计数器的半衰周期(CounterHalfLifeTime)。

而发生热度衰减的动作是在虚拟机GC进行垃圾回收时顺带进行的,可以通过参数-XX:-UseCounterDecay关闭热度衰减,这样可以使得方法调用计数器的判断基准变为绝对调用次数,而不是以相对执行频率作为阈值判断的标准。不过如果关闭了热度衰减,就会导致一个Java程序只要在线上运行的时间足够长,程序中的方法必然绝大部分都会被编译为本地机器码。

同时也可以通过-XX:CounterHalfLifeTime参数调整半衰周期的时间,单位为秒。

一般而言,如果项目规模不大,并且上线后很长一段时间不需要进行版本迭代的产品,都可以尝试把热度衰减关闭掉,这样可以使得Java程序在线上运行的时间越久,执行性能会更佳。只要线上运行的时间足够长,到后面可以与C编写的程序性能相差无几甚至超越(因为C/C++需要手动管理内存,管理内存是需要耗费时间的,但Java程序在执行程序时却不需要担心内存方面的问题,会有GC机制负责)。

3.3.4、其他的热点探测技术

在前面分析中,我们得知了,在HotSpot中的热点代码探测是基于计数器模式实现的,但是除开计数器的方式探测之外,还可以基于采样(sampling)以及踪迹(Trace)模式对代码进行热点探测。

采样探测:采用这种探测技术的虚拟机会周期性的检查每个线程的虚拟机栈栈顶,如果一些在检查时经常出现在栈顶的方法,那么就代表这个方法经常被调用执行,对于这类方法可以判定为热点方法。

优点:实现简单,可以很轻松的判定出热度很高(调用次数频繁)的方法。

缺点:无法实现精准探测,因为检查是周期性的,并且有些方法中存在线程阻塞、休眠等因素,会导致有些方法无法被精准检测。

踪迹探测:采用这种方式的虚拟机是将一段频繁执行的代码作为一个编译单元,并仅对该单元进行编译,该单元由一个线性且连续的指令集组成,仅有一个入口,但有多个出口。也就代表着:基于踪迹而编译的热点代码不仅仅局限在一个单独的方法或者代码块中,一条踪迹可能对应多个方法,代码中频繁执行的路径就可能被识别成不同的踪迹。

优点:这种方式实现可以使得热点探测拥有更高精度,可以避免将一块代码块中所有的代码都进行编译的情况出现,能够在很大程序上减少不必要的编译开销。因为无论是采样探测还是计数器探测的方式,都是以方法体或循环体作为编译的基本单元的。

缺点:踪迹探测的实现过程非常复杂,难度非常高。

而HotSpot虚拟机采用的计数探测的方式,实现难度、编译开销与探测精准三者之间会有一个很好的权衡。三种探测技术比较如下:

实现难度:采样探测计数探测踪迹探测

探测精度:采样探测计数探测踪迹探测

编译开销:踪迹探测计数探测采样探测

3.4、JVM为何不移除解释器?

在前面分析了JIT即时编译器,可以很直观的感受到,如果程序以纯JIT编译器的方式执行,性能方面绝对会超出解释器+编译器混合的模式,但为何虚拟机中至今也不移除解释器,还要用解释器来拖累Java程序的性能呢?就如在开篇中提到的JRockit虚拟机中,就移除了解释器模块,字节码文件全部依靠即时编译器执行。

主要有两个原因,一个是为了保证Java的绝对跨平台性,另一个则是为了保证启动速度,考虑综合性能。①保证绝对的跨平台性:如果将解释器从虚拟机中移除就代表着:每到一个不同的平台,比如从Windows迁移到Linux环境,那么JIT又要重新编译,生成对应平台的机器码指令才能让Java程序执行。但如果是解释器+JIT编译器混合的模式工作就不需要担心这个问题,因为前期可以直接由解释器将字节码指令翻译成当前所在的机械码执行,解释器会根据所在平台的不同,翻译出平台对应的机器码指令。这样从而使得Java具备更强的跨平台性。②保证Java启动速度,考虑综合性能:因为如果移除了解释器模块,那么就代表着所有的字节码指令需要在启动时全部先编译为本地的机械码,这样才能使得Java程序能够正常执行。不过如果想在启动时将整个程序中所有的字节码指令全部编译为机器码指令,需要的时间开销是非常巨大的,如果把解释器从JVM中移除,那么会导致一些需要紧急上线的项目可能编译都需要等半天的时间。

综上所述,虚拟机移除解释器有移除后的隐患,当然,如果移除了也有移除之后的好处,比如前面提到的JRockitVM中,就移除了解释器模块,从而使它获取了一个“史上最快”虚拟机的称号。

而HotSpot中采用的是解释器+JIT即时编译器混合的模式,这种模式的好处在于:在Java程序运行时,JVM可以快速启动,前期先由解释器发挥作用,不需要等到编译器把所有字节码指令编译完之后才执行,这样可以省去很大一部分的编译时间。后续随着程序在线上运行的时间越来越久,JIT发挥作用,慢慢的将一些程序中的热点代码替换为本地机器码运行,这样可以让程序的执行效率更高。同时,因为HotSpotVM中存在热度衰减的概念,所以当一段代码的热度下降时,JIT会取消对它的编译,重新更换为解释器执行的模式工作,所以HotSpot的这种执行模式也被成为“自适应优化”执行。

当然,我们在程序启动时也可以通过JVM参数自己指定执行模式:①-Xint:完全采用解释器模式执行程序。②-X


转载请注明:http://www.aierlanlan.com/rzdk/4846.html