JVM入门-执行引擎

Posted by 小拳头 on Friday, January 29, 2021

虚拟机是一个相对于"物理机"的概念, 这两种机器都有代码执行能力; 区别是物理机的执行引擎是直接建立在处理器, 缓存, 指令集和操作系统层面上的. 而虚拟机的执行引擎则是由软件自行实现的, 因此可以不受物理条件制约地定制指令集与执行引擎的结构体系, 能够执行那些不被硬件直接支持的指令集格式. 执行引擎的任务就是将字节码指令解释(解释器)/编译(编译器)为对应平台上的本地机器指令. 注意这里的编译是指后端编译, 而不是生成字节码的前端编译.

工作过程

执行引擎工作过程主要有步骤如下. 根据PC寄存器的当前位置对栈进行操作, 从外观上来看, 所有的Java虚拟机的执行引擎输入, 输出都是一致的: 输入字节码二进制流, 处理过程是字节码解析执行的等效过程, 输出执行结果.

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

代码编译(编译器):

字节码执行(执行引擎)如下. 其中解释器在Java虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行, 将每条字节码文件中的内容"翻译"为对应平台的本地机器指令执行. JIT(Just In Time Compiler)即时编译器: 就是虚拟机将源代码直接编译成和本地机器平台相关的机器语言(还没执行), 因为方法区有JIT代码缓存, 这个缓存的就是机器指令, 增加机器效率. Java是半编译半解释型语言.

下图是一个流程的概括.

各种码及运行过程

  1. 机器码: 二进制编码方式表示的指令. CPU直接读取运行, 因此和其他语言编的程序相比, 执行速度最快. 不同种类的CPU所对应的机器指令不同.
  2. 指令: 把机器码中特定的0和1序列, 简化成对应的指令. 不同的硬件平台执行同一个操作对应的机器码可能不同, 所以不同的硬件平台的同一种指令对应的机器码也可能不同.
  3. 指令集: 不同的硬件平台各自支持的指令是有差别的. 因此每个平台所支持的指令, 称之为对应平台的指令集.
  4. 汇编语言: 用助记符代替机器指令的操作码, 用地址符号或标号代替指令或操作数的地址. 不同的硬件平台对应着不同的机器语言指令集, 需要通过汇编过程转换成机器指令.
  5. 高级语言: 仍然需要把程序解释和编译成机器的指令码, 完成这个过程的程序就叫做解释程序编译程序.

对于C和Cpp, 执行过程如下图. 编译过程中读取源程序(字符流), 进行词法和语法的分析, 将高级语言指令转换为功能等效的汇编. 汇编过程中把汇编语言代码翻译成目标机器指令.

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

解释器

为啥不直接把Java源文件直接让虚拟机识别再转化为机器指令呢, 为什么还要字节码文件这个过程呢. 主要还是为了简化开发. 那么解释器就是一个运行时翻译者, 将字节码文件中的内容翻译为对应平台的本地机器指令执行. 当一条字节码指令被解释执行完成后, 接着再根据PC寄存器中记录的下一条需要被执行的字节码指令执行解释操作.

**字节码解释器(老版本)**在执行时通过纯软件代码模拟字节码的执行, 效率非常低下. **模板解释器(现在)**将每一条字节码和一个模板函数相关联, 模板函数中直接产生这条字节码执行时的机器码, 提高了解释器的性能. 并且JVM平台支持一种叫作即时编译(JIT)的技术, 即时编译的目的是避免函数被解释执行, 而是将整个函数体编译成为机器码, 每次函数执行时, 只执行编译后的机器码即可, 使得执行效率大幅度提升.

JIT编译器

JIT编译器的好处就是速度快, 那么为什么还要解释器呢. 因为当程序启动后, 解释器可以马上发挥作用, 省去编译的时间立即执行. 而编译器要想发挥作用, 把代码编译成本地代码, 需要一定的执行时间. 但是要注意JRockit VM就是没有解释器的. 对于服务端应用来说, 启动时间一般不是关注重点, 但对于那些看中启动时间的应用场景, 就需要采用解释器与即时编译器并存的架构来换取一平衡点. 我愿意总结为解释器先上, JIT慢慢起.

一般编译器分为前端编译器(把.java文件转变成.class文件的过程), 后端运行期编译器(把字节码转变成机器码), 静态提前编译器(直接把.java文件编译成本地机器代码的过程).

热点代码及探测方式

是否需要启动JIT编译器将字节码直接编译为对应平台的本地机器指令, 需要根据代码被调用执行的频率而定. JIT编译器在运行时会针对那些频繁被调用的热点代码做深度优化, 将其直接编译为对应平台的本地机器指令来提升Java程序的执行性能(栈上替换OSR On Stack Replacement过程, 因为发生在栈上). 被多次调用的方法, 或者是一个方法体内部循环次数较多的循环体都可以被称之为热点代码. 通过热点探测功能来判断和这个调用的次数和循环体次数的阈值. HotSpot VM所采用的热点探测方式是基于计数器的热点探测.

HotSpot VM将会为每一个方法建立2个不同类型的计数器, 分别为方法调用计数器(Invocation Counter)回边计数器(BackEdge Counter). 方法调用计数器用于统计方法的调用次数, 回边计数器用于统计循环体执行的循环次数.

方法调用计数器

默认阈值在Client模式下是1500 次, 在Server模式下是10000次, 阈值可以通过虚拟机参数一XX: CompileThreshold来设定, 而超过这个阈值就会触发JIT编译.

如果不做任何设置, 方法调用计数器统计的并不是方法被调用的绝对次数, 而是一个相对的执行频率, 即一段时间之内方法被调用的次数. 当超过一定的时间限度, 如果方法的调用次数仍然不足以让它提交给即时编译器编译, 那这个方法的调用计数器就会被减少一半. 这个过程称为方法调用计数器热度的衰减(Counter Decay), 而这段时间就称为此方法统计的半衰周期(Counter Half Life Time).

热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的, 可以通过-XX: -UseCounterDecay来关闭, 可以通过使用-XX: CounterHalfLifeTime参数设置半衰周期的时间, 单位是秒.

回边计数器

统计一个方法中循环体代码执行的次数, 在字节码中遇到控制流向后跳转的指令称为回边(Back Edge). 建立回边计数器统计的目的就是为了触发OSR编译.

设置执行方式

默认是mix mode.

  • -Xint: 完全采用解释器模式执行程序.
  • -Xcomp: 完全采用即时编译器模式执行程序. 如果即时编译出现问题, 解释器会介入执行.
  • -Xmixed: 采用解释器+即时编译器的混合模式共同执行程序.

HotSpot中JIT分类

在HotSpot VM中内嵌有两个JIT编译器, 分别为Client Compiler和Server Compiler, 大多数情况下我们简称为C1编译器(对字节码进行简单和可靠的优化, 耗时短, 以达到更快的编译速度)和C2编译器(耗时较长的优化, 更激进优化, 但优化的代码执行效率更高). 分别通过-client和-server命令来设置.

对C1来说:

  • 方法内联: 将引用的函数代码编译到引用点处, 这样可以减少栈帧的生成, 减少参数传递以及跳转过程.
  • 去虚拟化: 对唯一的实现类进行内联.
  • 冗余消除: 在运行期间把一些不会执行的代码折叠.

C2的优化主要是在全局层面, 逃逸分析是优化的基础(堆中提到过):

  • 标量替换: 用标量值代替聚合对象的属性值.
  • 栈上分配:对于未逃逸的对象分配对象在栈而不是堆.
  • 同步消除:清除同步操作, 通常指synchronized.

虽然server才会有C2, 但是分层编译(Tiered Compilation)策略会让程序解释执行(不开启性能监控)可以触发C1编译, 将字节码编译成机器码, 进行简单优化. 加上性能监控, C2编译会根据性能监控信息进行激进优化.

总的说来就是两点:

  1. JIT编译出来的机器码性能比解释器高
  2. C2启动时长比C1长, 系统稳定执行以后, C2速度远远快于C1.

Graal编译器与AOT编译器

JDK10起, HotSpot又加入一个全新的即时编译器Graal编译器. 需要使用开关参数-XX: +UnlockExperimentalVMOptions, -XX: +UseJVMCICompiler开启.

jdk9引入了AOT编译器(Ahead Of Time Compiler), JIT是程序运行中进行优化, 而AOT是在运行前优化, 他们的关西是并列的. 通过jaotc, 借助Graal编译器, 将所输入的Java类文件转换为机器码, 并存放至生成的动态共享库之中. 可以直接执行, 不必等待即时编译器的预热, 减少启动阶段的时间. 但是他破坏了跨平台的特性(机器指令堆不同硬件不同), 降低了Java链接过程的动态性, 加载的代码在编译期就必须全部已知.

参考

  1. 尚硅谷最新版宋红康JVM教程
  2. The Java® Virtual Machine Specification

comments powered by Disqus