1.执行引擎概述
执行引擎属于JVM的下层, 里面包括 解释器 , 及时编译器 , 垃圾回收器
执行引擎是Java虚拟机核心组成部分之一, “虚拟机”是一个相对于”物理机”的概念, 这两种机器都有代码执行能力, 其区别是物理机的执行引擎是直接建立在处理器 , 缓存, 指令集和操作系统层面上的, 而虚拟机的执行引擎则是由软件自行实现的, 因此可以不受物理条件,制约地指令集与执行引擎的结构体系, 能够执行那些不被硬件直接支持的指令集格式.
JVM的主要任务是负责转载字节码到其内部, 但字节码并不能直接运行在操作系统之上, 因为字节码指令并非等价于本地机器指令, 它内部包含的仅仅只是一些能够被JVM所识别的字节码指令, 符号表, 以及其他辅助信息.
那么,如果想要让一个Java程序运行起来,执行引擎(Execution Engine)的任务就是将字节码指令解释/编译为对应平台上的本地机器指令才可以。简单来说,JVM中的执行引擎充当了将高级语言翻译为机器语言的译者。
1.执行引擎工作流程
- 执行引擎在执行的过程中究竟需要执行什么样的字节码指令完全依赖于PC寄存器
- 每当执行完一项指令操作后,PC寄存器就会更新下一条需要被执行的指令地址.
- 当然方法在执行的过程中, 执行引擎有可能会通过存储在局部变量表中的对象引用准确定位到存储在Java堆区中的对象实例信息, 以及通过对象头中的元数据指针定位到目标对象的类型信息
从外观上来看, 所有的Java虚拟机的执行引擎输入, 输出都是一致的: 输入的字节码二进制流 , 处理过程是字节码解析执行的等效过程, 输出是执行过程.
2.Java代码编译和执行过程
大部分的程序代码转换为物理机的目标代码或虚拟机能执行的指令集之前, 都需要经过上图中的各个步骤
- 前面橙色部分都是生成字节码文件的过程, 和JVM无关
- 后面蓝色和绿色才是JVM需要考虑的过程
Java 代码编译是由Java源码编译器来完成的 , 流程图如下所示:
Java字节码的执行是由JVM执行引擎来完成, 流程图 如下所示
我们用一个总图 , 来说明解释器和编译器
1.什么是解释器(Interpreter)
当Java虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行, 将每条字节码文件中的”内容”为对应平台的本地机器指令执行
2.什么是JIT编译器
JIT(Just In Time Compiler)编译器: 就是虚拟机将源代码直接编译成和本地机器平台相关的机器语言.
3.为什么Java是半编译半解释型语言
JDK1.e时代 , 将Java语言定位为”解释执行”还是比较准确的 , 再后来, Java也发展出可以直接生成本地代码的编译器,现在JVM执行代码的时候, 通常都会将解释执行和编译执行二者结合起来进行.
翻译成本地代码后, 就可以做一个缓存操作, 存储在方法区中
3.机器码 指令 汇编语
1.机器码
各种用二进制编码方式表示的指令, 叫做机器指令码, 开始 ,人们就开始用它采编写程序, 这就是机器语言.
机器语言虽然能够被计算理解和接收, 但和人们的语言差别太大, 不易被人们理解和记忆, 并且用它变成容易出错.
用它编写的程序一经输入计算机, CPU直接读取运行 , 因此和其他语言编的程序相比, 执行速度快.
机器指令与CPU紧密相关, 所以不同种类的CPU所对应的的机器指令也就不同.
2.指令
由于机器码是有0和1组成的二进制序列, 可读性实在太差, 于是人们就发明了指令.
指令就是把机器码中特定的0和1序列, 简化成对应的指令(一般英文简写,如mov, inc等)可读性稍好
由于不同的硬件平台, 执行同一个操作, 对应的机器码可能不同, 所以不同的硬件平台的同一种指令(mov),对应的机器码也可能不同.
3.指令集
不同硬件平台, 各自支持的指令,是有差别的, 因此每个平台所支持的指令,称之为对应平台的指令集,如常见的
- x86指令集 , 对应的是x86架构的平台
- ARM指令集, 对应的是ARM架构的平台
4.汇编语言
由于指令的可读性还是太差, 于是人们又发明了汇编语言
在汇编语言中, 用助记符(Mnemonics)代替机器指令的操作码 , 用地址符号(Symbo1)或标号(Labe1)代替指令或操作数的地址 , 在不同的硬件平台, 汇编语言对应着不同的机器语言指令集, 通过汇编过程转换成机器指令.
由于计算机只认识指令码 , 所以汇编语言编写的程序还必须翻译成机器指令码, 计算机才能识别和执行
5.高级语言
为了是计算机用户编写程序更容易一些, 后来就出现了各种高级计算机语言
高级语言比机器语言 , 汇编语言更加接近人的语言当计算机执行高级语言编写的程序时,仍然需要把程序解释和编译成机器的指令码.完成这个过程的程序就叫做解释程序或编译程序
高级语言也不是直接翻译成 机器指令, 而是翻译成汇编语言.
6.C , C++源程序执行过程
编译过程又可以分成为两个阶段, 编译和汇编.
编译过程: 是读取源程序(字符流), 对之进行词法和语法的分析, 将高级语言指令转换为功能等效的汇编代码.
汇编过程: 实际上指把汇编语言代码翻译成目标机器指令的过程
7.字节码
字节码是一种中间态(中间码)的二进制代码(文件) , 它比机器码更抽象,需要直译后才能成为机器码
字节码主要是为了实现特定软件运行和软件环境 , 与硬件环境无关
字节码的实现方式是通过编译器和虚拟机器 , 编译器将源码编译成字节码, 特定平台上的虚拟机器将字节码转译为可以直接执行的指令
- 字节码应用: Java bytecode
4.解释器
JVM设计者们的初衷仅仅只是为了单纯地满足Java程序实现跨平台特性, 因此避免采用静态编译的方式直接生成本地机器指令, 从而诞生了实现解释器在运行时采用逐行解释字节码执行程序想法
解释器真正意义上所承担的角色就是一个运行时”翻译者” , 将字节码文件中的内容”翻译”为对应平台的本地机器指令执行.
当一条字节码指令被解释执行完成之后, 接着在根据PC寄存器中记录的下一条需要被执行的字节码指令执行解释操作,
1.解释器分类
在Java的发展历史里, 一共两套解释执行器, 即古老的字节码解释器, 现在普遍使用的模板解释器
字节码解释器在执行时通过纯软件代码模拟字节码的执行, 效率非常低下.
而模板解释器将每一条和一个模板函数相关联, 模板函数中直接产生这条字节码执行时的机器码, 从而很大程度上提高了解释器的性能.
在Hotspot VM中, 解释器主要由Interpreter模板和Code模块构成.
- Interpreter模板, 实现了解释器的核心功能
- Code模块, 用于管理Hotspot VM在运行时生成的本地机器指令
2.现状
由于解释器在设计和实现上非常简单,因此除了Java语言之外,还有许多高级语言同样也是基于解释器执行的,比如Python、Per1、Ruby等。但是在今天,基于解释器执行已经沦落为低效的代名词
为了解决这个问题,JVM平台支持一种叫作即时编译的技术。即时编译的目的是避免函数被解释执行,而是将整个函数体编译成为机器码,每次函数执行时,只执行编译后的机器码即可,这种方式可以使执行效率大幅度提升。
5.JIT编译器
1.Java代码的执行分类
第一种是将源代码编译成字节码文件, 然后在运行时通过解释器将字节码文件转为机器执行
第二种是编译执行(直接编译成机器码) . 现代虚拟机为了提高执行效率,会使用即时编译技术(JIT, Just In Time)将方法编译成机器码后在执行
Hotspot VM 是目前市面上高性能虚拟机代表作之一, 它采用解释器与即时编译器并存的架构, 在Java虚拟机运行时, 解释器和即时编译器能够相互协作, 各自取长补短 , 尽力去选择最合适的方式来权衡编译本地代码的时间和直接解释执行代码的时间
2.问题来了
有些开发人员会感觉到诧异 , 既然Hotspot VM中已经内置了JIT编译器了, 那么为什么还需要使用解释器来”拖累”程序的执行性能? 比如JRockit VM内部就不包含解释器 , 字节码全部都依靠即时编译器编译后执行
- JRockit虚拟机是砍掉了解释器, 也即是只采用即时编译器, 那是因为JRockit只部署在服务器上, 一般已经有时间让他进行指令编译的过程了, 对于响应要求不高的,等即时编译的完成后, 会提供更好的性能
- 首先明确: 当程序启动后, 解释器可以马上发挥作用, 省去编译的时间,立即执行,编译器想要发挥作用,把代码编译成本地代码. 需要一定的执行时间, 但编译成本地代码后 , 执行效率会非常高
- 所以: 尽管JRockit VM中程序的执行性能会非常高效 , 但程序在启动时必然需要花费更长的时间来进行编译, 对于服务端应用来说 , 启动时间并非是关注重点, 对于那些看中启动时间的应用场景而言,或许就需要采用解释器与即时编译器并存的架构来换取一个平衡点.
- 在此模式下, 当Java虚拟器启动时, 解释器可以首先发挥作用,而不必等待即时编译器全部编译完成后再执行, 这样可以省去许多不必要的编译时间, 随着时间的推移,编译器发挥作用, 把越来越多的代码编译成本地代码,获得更高的执行效率
- 同时, 解释器执行在编译器进行激进优化不成立的时间,作为编译器的”逃生门”.
3.Hotspot JVM执行的方式
当虚拟机启动的时候 , 解释器可以首先发挥作用,而不必等待即时编译器全部编译完成再执行,这样可以省去许多不必要的编译时间,并且随着程序运行时间推移, 即时编译器逐渐发挥作用,根据热点探测功能, 将有价值的字节码编译为本地机器指令,以换取更高的程序执行效率
4.案例
注意解释执行的与编译执行在线上的环境微妙的辩证关系,机器在热机状态下可以承受的负载要大于冷机状态, 如果热机状态时的流量进行且流,可能使处于冷机的服务器因无法承载流量而假死.
在生成环境发布的过程中,以分批的方式进行发布, 根据机器数量划分成多个批次, 每个批次的机器数至多占到整个集群的1/8 ,
- 曾经有这样的故障案例: 某程序员在发布平台进行分批发布, 在输入发布总批数时,误填写成分为两批发布, 如果是热机状态, 在正常情况下一半的机器可以勉强承载流量, 但由于刚刚启动的JVM均是解释执行,还没有进行热点代码统计和JIT动态编译,导致机器启动之后, 当前1/2分发布成功的服务器马上全部宕机, 此故障说明了JIT的存在,
5.概念解释
- Java语言的”编译器”其实是一段”不确定”的操作过程, 因为它可能是前端编译器(其实”编译器的前端”更加准确一些) 把.java文件转变为.class文件的过程; 也可能是指虚拟机的后端运行期编译器(JIT 编译器 , Just In Complier)
- 把字节码转变为机器码的过程
- 还可能是指使用静态编译器(AOT编译器 , Ahead of Time Compiler)直接把.java文件编译成本地机器代码的过程
前端编译器: Sun的javac, Eclipse JDT中的增量事编译器(ECJ)
JIT编译器: Hotspot VM的C1 , C2编译器
AOT编译器: GUN Compiler for the Java(GCJ), Excelsior JET.
6.热点探测技术
一个被多次调用的方法, 或者是一个方法体内部循环次数较多的循环体都可以被称之为”热点代码” , 因此都可以通过JIT编译器编译为本地机器指令, 由于这种编译方式发生在方法的执行过程中,因此被称之为栈上替换,或者是OSR(On Stack Replacement)编译.
一个方法究竟要被调用多少次 , 或者一个循环体究竟需要执行多少次循环才可以达到这个标准? JIT编译器才会将这些”热点代码”编译为本地机器指令执行, 这里主要依靠热点探测功能.
目前Hotspot VM所采用的的是热点探测方式是基于计数器的热点探测.
采用基于计数器的热点探测 , Hotspot VM将会为每一个方法都建立2个不同类型的计数器, 分别为方法调用计数器(Invocation Counter) 和回边计数器(Back Edge Counter)
- 方法调用计数器用于统计方法的调用次数
- 回边计数器则用于统计循环体执行的循环次数
7.方法调用计数器
这个计数器就用于统计方法被调用的次数, 它的默认阈值在Client模式下是1500次, 在Server模式下是10000次, 超过这个值就会触发JIT编译.
这个阈值可以通过虚拟机参数: -XX:CompileThreshold来人为设定
当一个方法被调用时, 会检查该方法是否存在被JIT编译过的版本, 如果存在 , 则优先使用编译后的本地代码来执行 . 如果不存在已被编译过的版本, 则将此方法的调用计数器值加1, 然后判断该方法调用计数器与回边计数器值之和是否超过方法调用计数器的阈值, 如果已超过阈值, 那么将会向即时编译器提交一个该方法的代码编译请求.
8.热点衰减
如果不做任何设置, 方法调用计数器统计的并不是方法被调用的绝对次数, 而是一个相对的执行频率, 即一段时间之内方法被调用的次数, 当超过一定的时间限度, 如果方法的调用次数仍然不足以让它提交给即时编译器编译, 那这个方法的调用计数器就会被减少一半, 这个过程的成为方法调用计数器热度的衰减(Counter Decay), 而这段是时间就称之为此方法统计的半衰周期(Counter Half Life Time)
进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的, 可以使用虚拟机参数-XX:-UseCounterDecay 来关闭热度衰减, 让方法计数器统计方法调用的绝对次数, 这样, 只要系统运行时间足够, 绝大部分方法都会被编译成本地代码
另外, 可以使用-XX:CounterHalfLifeTime参数设置半衰周期的时间, 单位秒
9.回边计数器
它的作用是统计一个方法中循环体代码执行的次数, 在字节码中遇到控制流向后跳转的指令称为”回边”(Back Edge) ,显然 , 建立回边计数器统计的目的就是为了触发OSR编译
10.Hotspot VM可以设置程序执行方法
缺省情况下, Hotspot VM是采用解释器与即时编译器并存的架构 , 当然开发人员可以根据具体的应用场景, 通过命令显式地位Java虚拟机指定的运行时到底是完全采用解释执行, 还是完全采用即时编译执行. 如下所示:
- -Xint: 完全采用解释器模式执行程序
- -Xcomp: 完全采用即时编译器模式执行程序, 如果即时编译出现问题, 解释器会介入执行
- -Xmixed: 采用解释器+即时编译器的混合模式共同执行程序
/**
* -Xint 花费时间为6548ms. 解释器方式执行
* -Xcomp 花费时间为777ms. JIT方式执行
* -Xmixed 花费时间为840ms. 混合模式执行(解释器/JIT)
* @author anda
* @since 1.0
*/
public class CompilerTest {
public static void main(String[] args) {
long start = System.currentTimeMillis();
testCompiler();
long end = System.currentTimeMillis();
System.out.println("花费时间为" + (end - start) + "ms.");
}
private static void testCompiler() {
outter:
for (int i = 0; i < 1000000; i++) {
intter:
for (int j = 2; j <= 100; j++) {
label:
for (int k = 2; k < Math.sqrt(j); k++) {
if (j % k == 0) {
continue intter;
}
}
}
}
}
}
11. Hotspot VM中的JIT分类
JIT的编译器还分为两种, 分别是C1和C2, 在Hotspot VM中嵌套有两个JIT编译器, 分别为Client Compiler 和 Server Compiler , 但绝大多数情况下我们简称为C1编译器和C2编译器
- -client: 指定Java虚拟机运行在Client模式下, 并且使用C1编译器;
- C1编译器会对字节码进行简单和可靠的优化, 耗时短, 以达到更快的编译速度.
- -server: 指定Java虚拟机运行在server模式下, 并且使用C2编译器.
- C2进行耗时较长的优化, 及其激进优化, 但优化的代码执行效率更高.
12.C1和C2编译器不同的优化策略
在不同的编译器上有不同的优化策略,
- C1编译器上主要是有方法内联, 去虚拟化吗元余消除
- 方法内联: 将引用的函数代码编译到引用点处, 这样可以减少栈帧的生成, 减少参数传递以及跳转过程
- 去虚拟化: 对唯一的实现樊进行内联
- 冗余消除: 在运行期间把一些不会执行的代码折叠掉
- C2的优化主要是在全局层面, 逃逸分析是优化的基础, 基于逃逸分析在C2上有如下几种优化:
14.总结
- 一般来讲, JIT编译出来的机器码性能比解释器高
C2编译器启动时长比C1慢, 系统慢慢稳定执行以后, C2编译器执行速度远远快于C1编译器
15.AOT编译器
JDK9 引入了AOT编译器(静态提前编译器, Ahead of Time Compiler)
Java 9中引入实验性AOT编译工具aotc, 它借助了Graal编译器, 将所输入的Java类文件转换为机器码, 并且存放至生成的动态共享库之中.
所谓AOT编译, 是与即时编译相对立的一个概念, 我们知道, 即时编译指的是程序的运行过程中, 将字节码转换为可以在硬件上直接运行的机器码, 并部署至托管环境中的过程, 而AOT编译指的则是, 在程序员运行之前, 便将字节码转换为机器码的过程.java -> .class -> (使用jaotc) -> .so
最大的好处: Java虚拟机加载已经预编译成二进制库, 可以直接执行, 不必等待即时编译器的预热, 减少Java应用,给人带来的”第一次运行慢”的不良体验
缺点:破坏了java”一次编译, 到处运行”必须为每个不同的硬件, OS编译对应的发行包
- 降低了Java链接过程的动态性, 加载的代码在编译器就必须全部已知
还在继续优化中, 最初只支持Linux X64 java base
16.总结
自JDK10起, Hotspot又加入一个全新的即时编译器器: Graal编译器
- 编译效果短短几年就和G2编译器保持一样
- 目前是带着实验状态标签, 需要使用开关参数去激活才能使用
-XX:+UnlockExperimentalvOptions -XX:+UseJVMCICompiler