概述
执行引擎充当字节码的翻译者,与操作系统打交道,并执行代码。
- 执行引擎在执行过程中究竟需要执行什么指令完全依赖PC寄存器
- 每当执行玩一项指令操作后,PC寄存器会更新下一条需要被执行的指令地址
- 每当方法在执行过程中,执行引擎可能会通过存储在局部变量表中的对象引用准确定位到存储在堆区的对象实例信息,以及通过对象头中的元数据指针定位到对象的类型信息。
两条途径
执行引擎执行字节码指令可以用两条途径
1、使用解释器(interpreter)对字节码进行解释执行、
2、使用即时编译器(JIT Compiler),将字节码编译成机器指令后再执行。
起初在Sun Classic VM中只使用解释器,后来在HOTSPOT之后二者结合使用。如下图
java代码编译和执行的过程
前端编译器:图中黄色部分就是前端编译器所作事情,将java编译成class
JIT编译器:图中蓝色部分。
解释器:图中绿色部分。
AOP编译器:黄色+绿色部分,直接将java文件编译为机器指令。
解释器
承担运行时的翻译者,将class文件翻译成对应平台的机器指令执行。当一条字节码指令被解释执行完成之后,接着再根据PC寄存器的下一条直接进行解释操作。
为什么要有class文件,JVM为什么不直接解释java文件?
- 1、java执行中依赖的指令集架构是基于栈的指令集架构。程序在运行时是依赖对于栈的操作的。就好比我们在科学计算器中输入一串式子:(((1+2)*(2+3)+4*5)*3让计算器计算。计算器需要对这串的每个操作和数字进行分析,遇到优先级低的操作符时需要将数字入栈,遇到操作符优先级高的则取栈顶元素计算,然后计算结果入栈。java的基于栈的指令集架构就类似于科学计算器算法。就是出栈运算,结果入栈。但是对于这串式子的解析和判断优先级是要耗时的,所以我们可以直接将其编译,生成那些对栈的操作指令,这样在实际运行的时候便快一点。java也是如此,直接将java文件交给JVM运行,需要对其进行语法词法分析,这是相当耗时的。
- 2、java不仅是跨平台的语言,并且JVM是一个跨语言的平台,其他自开发语言都可以编译成class文件交给JVM运行。比如Scala、Kotlin、Groovy、Jython。
两套解释器
java的历史进程中有两套解释器,古老的字节码解释器,和现在普遍使用的模板解释器。
- 1、字节码解释器:
通过纯软件代码模拟字节码的执行,就是读到字节码时,先翻译这行字节码的意思,比如 “iload_1”,那么就是纯执行C语言代码,operationStack.push(local[1])。
- 2、模板解释器:
将字节码和一个模板函数相关联,比如 “iload_1", 这个iload和一个映射到了一个函数指针,直接就对应运行上了。这个模板函数就直接对应了其字节码执行需要的机器指令。
如下图,就是Jpython的字节码解释用到的模板,器中Load_FAST,Load_CONST等就是用是C语言实现的模板,其映射了一类字节码指令。
在hotspot虚拟机中,解释器主要由Interpreter模块和Code模块组成。
- Interpreter实现解释器核心功能。
- Code模块用于管理HotSpot在运行时生成的本地机器指令。
JIT编译器
对字节码逐行解释执行时很慢的,为了解决这个问题,JVM支持即时编译的技术,可以将整个函数编译为机器码,函数执行时,每次执行机器码即可。
何时选择JIT编译器?热点代码及其探测方式:
根据代码的执行频率而定,将频率高的编译为本地本地代码,这部分代码称为热点代码。一个频繁被执行的方法或循环次数较多的循环体,可以被称为热点代码。将这部分代码编译为机器指令,这个过程称为“栈上替换”或OSR(On Stack Replacement)。目前HotSpot的热点探测方式为基于计数器的热点探测。JVM为每个方法都建立两个不同类型的计数器。(我们操作系统好像使用的时换页算法)
- 方法调用计数器:统计方法调用次数
- 回边计数器:统计循环体循环次数
计数器的默认阈值在clent模式下是1500,在service模式是10000,可以用XX:CompileThreshold控制。
如果不加任何设置,这个统计计数值,是会衰减的,当超过一定的时间限度(半衰期),该方法或者循环体不能到达阈值,会衰减至原来一半,也就是说我们这个时间计数是统计在某一限定范围的被调用次数。(个人感觉这个不合理,既然你已经认为他不好达到阈值,为什么还要统计,统计完还得给他衰减一半,假如它有被调用还得count++)
- 可以用-XX:UseCounterDecay=false关闭衰减。
- 可以用-XX:CounterHalfFifeTime 设置半衰期。
方法计数器的控制大致过程如下图:
回边计数器的控制过程如下图
注意
- 1、图中的codeCache存储在方法区的元空间。
- 2、回边计数器值是与方法计数器值求和生效的。
C1和C2
hotSpot内嵌两个JIT编译器:client Compiler 和 server Compiler,简称C1和C2。可以通过一下参数设置
-client:对字节码简单可靠的优化,耗时短,64位操作系统下配置-client会被忽略。
-server:对字节码执行更加耗时和激进的优化,但执行效率更高,默认64为操作系统下为server,即使配置-client也会被忽略。
C1和C2的不同优化策略
C1:
- 方法内敛:将引用的函数代码编译到引用点处,这样可以减少栈帧的生成,减少参数传递以及跳转过程。
- 去虚拟化:对唯一的实现类进行内联
- 冗余消除:将运行期间不会执行和逻辑上无效的代码折叠。
C2:优化主要在全局层面,逃逸分析是优化的基础。基于逃逸分析有以下优化。(C++编写)
- 标量分离
- 同步消除
- 栈上分配
分层编译策略
程序解释运行,如果不开启性能监控,可以触发C1编译,可以简单优化。如果开启性能监控,C2会根据性能监控信息进行激进优化。如果激进优化失败,会退回到解释执行。
在jdk1.7之后,如果指定-server,默认会开启分层编译策略,C1和C2相互协作共同执行编译任务。
总结
- 一般来说,JIT编译出来的机器码性能比解释器高。
- C2编译器启动时长比C1慢,系统稳定执行之后,C2编译器的执行速度远远快于C1编译器
解释器和JIT编译器
解释器和JIT编译器需要配合使用才可可以在普遍场景下高效。因为编译需要时间,如果要等编译完在执行,就像纸上谈兵,只谈规划不执行,规划好久之后才执行,后面执行很快,但是启动速度。使用解释器就好像不做规划就冲动执行,但是在执行过程中速度很慢。二者结合才可高效。如下图,二者就像执行引擎的两条腿。(就像我们写代码一样不能只干想等想好再写,也不能想都不想,写到哪里算哪里)
Jrockit虚拟机,没有解释器,他适合服务器端的应用,但是对于其他场景,比如前端应用,我们即想启动速度快又想运行快,就得二者结合。
可以配置
- 只使用解释器 -Xinit
- 只使用编译器 -Xcomp
- 混合使用 -Xmixed
热机和冷机
解释执行和编译执行在线上环节有着微妙的辩证关系:机器在热机状态下能承受的负载要大于冷机状态,如果以热机状态的流量进行切流可能导致处于冷机状态的服务无法承受负载而假死。
案例:
在生产中,以分批的方式进行发布,根据机器数量分批次发布。每个批次的数量占集群的1/8。曾经某程序员在发布时将批次误填为2。在热机状态下,下一半机器勉强可以承受流量,但是由于刚刚启动状态的机器属于解释执行阶段,还没有热点代码统计和JIT编译,导致刚刚启动的机器全部宕机 -------阿里团队
java慢?
因为java要在运行过程中解释或编译。class文件的执行要依赖JVM。而exe自己本身就是一个可以直接执行的文件。
C/C++的编译包括编译和汇编,链接之后就生成了一个二进制的exe可以执行文件。exe文件是一种可在操作系统存储空间中浮动定位的可执行程序,直接和操作系统打交道,执行速度很快。而java还需要再执行过程中再次解释或者编译,并且必须要在二者之间达到一种平衡。如下图为C/C++的执行过程。
写在最后
Graal编译器
自JDK10起,HotSpot又引入了一个全新的即时编译器:Graal编译器。
编译效果短短几年追评超过C2编译器,未来可期。
目前”实验状态",需要使用开关参数
使用-XX:+UnlockExperimentalVMOptions -XX:+UserJVMCICompiler激活。
AOT编译器
jdk9引入AOT编译器,静态提前编译(Ahead of Time Compiler)
java9引入实验性AOT编译工具jaotc。它借助Graal编译器,将java文件转化位机器码,并存放生成的动态共享库之中。
所谓AOT编译器,是与即时编译器相互对立的一个概念。我们知道,即时编译器指的是在程序的运行过程中,将字节码转化位可在硬件上直接运行的机器码,并部署至托管环境中的过程。而AOT编译指的是,在程序运行之前,将字节码转化位机器码的过程。
使用过程:
.java --使用javac--> .class --使用jaotc --> .so 然后将so文件放到动态共享库之中。
其好处是:java虚拟机加载已经预编译成二进制库,可以直接执行,不必等待即时编译器的预热,减少带来第一次运行慢的不良体验。
缺点:打破”一次编译,到处运行“,必须位每个不同硬件、OS编译对应的发行包。
降低了java的链接过程的动态性,加载的代码在编译器必须全部已知。
还需要继续优化,目前只支持Linux x64 java base