4. 执行引擎

前言

前面介绍过

JVM被分为三个主要的子系统:

  1. 类加载器子系统
  2. 运行时数据区(也就是内存相关)
  3. 执行引擎
JVM

前几章我们简单的梳理了一下JVM的类加载机制及运行时数据区,

今天我们来聊聊JVM执行引擎.

如无特殊说明, 所有描述JVM的特性均特指HotSpot VM

Java程序的执行过程

简单回顾下Java程序的执行过程

  • 编译 (Java前端编译器)
    将Java文件编译为.class字节码文件, 这部分工作由Java前端编译器完成, 与JVM本身其实没什么关系;
    编译过程如下图

    编译

  • 加载(JVM-类加载器)
    负责"装载字节码", 但字节码并不能够直接运行在操作系统之上, 因为字节码指令并非等价于本地机器指令, 它内部包含的仅仅只是一些能够被JVM锁识别的字节码指令、符号表和其他辅助信息.

  • 运行(JVM-执行引擎)
    Java字节码的执行是由JVM执行引擎来完成

执行引擎

执行引擎的任务就是将字节码指令解释/编译为对应平台上的本地机器指令.

简单来说,JVM中的执行引擎充当了将高级语言翻译为机器语言的翻译官.

执行引擎

科普(翻译官)

正式介绍执行引擎前, 我们先了解下现实世界的翻译官这个职业.

看过一些跨国演讲节目的都知道, 一般都会配多个翻译官,来实现语言互通.

细心的朋友可能会发现, 翻译的形式一般有两种

  • 交替传译
    领导讲一句, 翻译官翻译一句, 领导继续讲, 如此往复

  • 同声传译
    领导讲的同时, 翻译官也同时翻译, 基本上领导讲完, 翻译也完成了

不难看出, 同声传译是实效性比较高的方式, 同时对翻译官的能力、压力也是极大的.

而交替传译则显得从容了很多, 不过实效性就差了很多.

那么, 有没有办法可以让同声传译的门槛降低点呢? 让普通点的翻译官都能胜任这样的工作呢?!

聪明的小伙伴肯定想到了:

如果能够提前知道大佬演讲的内容, 提前翻译好个大概, 不就完美解决这个问题了么?!

理想情况下, 是外国人照着稿子读, 翻译官按着提前准备好的翻译稿读出来.

实际情况下, 这种照本宣科的场景还是比较少的, 大部分情况下是演讲者会有个大纲, 翻译官也会提前拿到, 然后真正演讲时外国人顺着大纲穿插自由发挥, 这样, 翻译官需要注意的其实就是自由发挥的那部分内容, 这将大大的减轻了翻译的压力.

PS: 更真实通用的情况是:

翻译官凭借自身的老练和职场经验, 能够熟悉的掌握一些话术的翻译, 这些话术来自于多年的工作经验.

比如正式的演讲“尊敬的各位.....”, 这些其实是通用的标准的东西, 翻译官完全可以提前准备好;

或者, 翻译官和演讲者配合多年, 很有默契, 完全掌握演讲者的演讲习惯、话术, 这种默契将有助于翻译官提前预知演讲者接下来的发言.

言归正传, 我们开始了解执行引擎这个“字节码翻译官”的翻译方式.

  • 字节码解释器 (交替传译)

当Java虚拟机启动时会根据预定义的规范对字节码采用逐行编译的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令并执行.

  • JIT编译器 (同声传译)

JVM将源码直接编译为和本地机器平台相关的机器语言,但是并不会立刻执行.
JIT编译器在运行时会针对那些频繁被调用的“热点代码”做出深度优化,将其直接编译为对应平台的本地机器指令,以此提升Java程序的执行性能.

即时编译的目的是避免函数被解释执行, 而是将整个函数体编译成为机器码,每次函数执行时,只执行编译后的机器码即可,这种方式可以使执行效率大幅度提升.

简单的看完以上两种方式, 不难看出, 其实JVM执行引擎这个“翻译官”的翻译方式和现实中的翻译官没什么区别.

早先, JVM执行引擎其实只有“字节码解释器 ”这一种方式, 效率低下, 和C、C++这些语言的执行效率简直无法相比;

直到后来引入了JIT编译器, 一方面将一些函数、固定的代码提前编译好;
另一方面针对一些不固定的则继续保持解释执行的方式; JVM把两者搭配使用, 极大的提升了整体的效率.

热点代码及探测方式

JVM执行引擎是采用了解释执行+编译执行的混合方式.

PS: 这也就是我们傻傻分不清楚JAVA究竟是解释型语言还是编译型语言的原因.

同时我们也提到了执行引擎中的JIT即时编译, 关于JIT即时编译有个很重要的概念, 即热点代码.

JIT只会对热点代码进行编译

热点代码

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

PS: 热点代码经过JIT即时编译后成为机器指令,需要缓存起来Code Cache,存放在方法区(元空间/本地内存)

一个方法究竟要被调用多少次,或者一个循环体究竟需要执行多少次循环才可以达到这个标准?

必然需要一个明确的阈值,JIT编译器才会将这些“热点代码”编译为本地机器指令执行.

这里主要依靠热点探测功能.

热点探测

目前HotSpot VM所采用的热点探测方式是基于计数器的热点探测.
采用基于计数器的热点探测,HotSpot VM将会为每一个 方法都建立2个不同类型的计数器,分别为

  • 方法调用计数器(Invocation Counter)
  • 回边计数器(BackEdge Counter).

方法调用计数器用于统计方法的调用次数, 回边计数器则用于统计循环体执行的循环次数.

  • 方法调用计数器

这个计数器就用于统计方法被调用的次数,它的默认阈值在Client 模式下是1500 次, 在Server 模式下是10000 次.
超过这个阈值,就会触发JIT编译.
这个阈值可以通过虚拟机参数来人为设定.

-XX :CompileThreshold

当一个方法被调用时,会先检查该方法是否存在被JIT编译过的版本,
如果存在,则优先使用编译后的本地代码来执行.
如果不存在已被编译过的版本,则将此方法的调用计数器值加1,然后判断方法调用计数器与回边计>数器值之和是否超过方法调用计数器的阈值.
如果已超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求.

方法调用计数器
  • 回边计数器

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

回边计数器

热度衰减

如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间之内方法被调用的次数.

当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减(Counter Decay),而这段时间就称为此方法统计的半衰周期(Counter Half Life Time);

进行热度衰减的动作是在虚拟机进行垃圾收集时顺便进行的,
可以使用虚拟机参数 -XX:-UseCounterDecay来关闭热度衰减,让方法计数器统计方法调用的绝对次数;这样,只要系统运行时间足够长,绝大部分方法都会被编译成本地代码.
另外,可以使用-XX:CounterHalfLifeTime参数设置半衰周期的时间,单位是秒.

其他热点探测技术

HotSpot中的热点代码探测是基于计数器模式实现的, 但是除了计数器的方式探测之外, 还可以基于采样(sampling)以及踪迹(Trace)模式对代码进行热点探测.

采样探测

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

  • 优点:实现简单,可以很轻松的判定出热度很高(调用次数频繁)的方法
  • 缺点:无法实现精准探测,因为检查是周期性的,并且有些方法中存在线程阻塞、休眠等因素,会导致有些方法无法被精准检测
踪迹探测

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

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

HotSpot虚拟机采用的计数探测的方式,实现难度、编译开销与探测精准三者之间会有一个很好的权衡.

三种探测技术比较如下:

  • 实现难度:采样探测 < 计数探测 < 踪迹探测
  • 探测精度:采样探测 < 计数探测 < 踪迹探测
  • 编译开销:踪迹探测 < 计数探测 < 采样探测

JVM为何不移除解释器?

很多同学可能会有疑惑, 如果以纯JIT编译器的方式执行,性能方面绝对会超出解释器+编译器混合的模式,但为何虚拟机至今也不移除解释器,还要用解释器来拖累Java程序的性能呢?

其实主要有两个原因:

  • 保证Java的绝对跨平台性

如果将解释器从虚拟机中移除就代表着:
每到一个不同的平台,比如从Windows迁移到Linux环境,那么JIT又要重新编译生成对应平台的机器码指令才能让Java程序执行

  • 保证启动速度

如果移除了解释器模块,那么就代表着所有的字节码指令需要在启动时全部先编译为本地的机械码,这样才能使得Java程序能够正常执行.
此时时间开销是巨大的,那么会导致一些需要紧急上线的项目可能编译都需要等半天的时间

PS: 的确有JVM的实现移除了解释器模块, 就是号称“史上最快”的JRockitVM

总结

HotSpot中采用的是解释器+JIT即时编译器混合.

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

当然,我们在程序启动时也可以通过JVM参数自己指定执行模式

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

HotSpot VM 中的JIT分类

在HotSpot VM中内嵌有两个JIT编译器

  • Client Compiler(简称C1)

  • Server Compiler(简称C2)

C1编译器(Client Compiler)

C1编译器主要追求稳定和编译速度,属于保守派.

C1中常见的优化方案有几种

  • 公共子表达式消除

如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那E的这次出现就成公共子表达式,可以用原先的表达式进行消除,直接使用上次的计算结果,无需再次计算

  • 方法内联

将引用的方法代码编译到引用点处,这样可以减少栈帧的生成,减少参数传递以及跳转过程

  • 去虚拟化

对唯一的实现类进行内联

  • 冗余消除

通过对字节码指令进行流分析,将一些运行过程中不会执行的代码消除

  • 空检测消除
    将显式调用的NullCheck(空指针判断)擦除,改成ImplicitNullCheck异常信号机制处理
  • 自动装箱消除
    对于一些不必要的装箱操作会被消除,比如刚装箱的数据又在后面立马被拆箱,这种无用操作就会被消除
  • 安全点消除
    对于线程无法抵达或不会停留的安全点会进行消除
  • 反射消除
    对于一些可以正常访问无需通过反射机制获取的数据,会被改为直接访问,消除反射操作

C2编译器(Server Compiler)

C2编译器则主要是追求编译后的执行性能,属于激进派.

C2编译器建立在C1编译器的基础优化之上,除了使用C1中的优化手段之外,还有几种基于逃逸分析的激进优化手段

  • 标量替换

用标量值代替聚合对象的属性值

  • 栈上分配

对于未逃逸的对象分配对象在栈而不是堆

  • 同步消除

清除同步操作,通常指synchronized

逃逸分析

Java 中对象的创建一般会由堆内存去分配内存空间来进行存储,在堆内存空间不足的时候,GC 便会对堆内存进行垃圾回收.

如果 GC 运行的次数过多,便会影响程序的性能,所以 “逃逸分析” 由此诞生.

它的目的就是判断哪些对象是可以存储在栈内存中而不用存储在堆内存中,从而让其随着线程的消逝而消逝,进而减少 GC 发生的频率.

简而言之, 逃逸分析就是Hotspot 虚拟机可以分析新创建对象的使用范围,并决定是否在 Java 堆上分配内存的一项技术.

逃逸分析是建立在方法为单位之上的,如果一个成员对象在方法体中产生,但是直至方法结束也没有走出方法体的作用域, 那么该成员就可以被理解为未逃逸.

反之,如果一个成员在方法最后被“return”出去了, 或在方法体的逻辑中被赋值给了外部成员,那么则代表着该成员逃逸了.

逃逸的方式
  • 方法逃逸

一个对象在方法中被定义,但却被方法以外的其他代码使用.

在一个方法体内,定义一个局部变量,而它可能被外部方法引用.
比如作为调用参数传递给方法,或作为对象直接返回
可以理解为对象跳出了方法

  • 线程逃逸

一个对象由某个线程在方法中被定义,但却被其他线程访问

这个对象被其他线程访问到,比如赋值给了实例变量,并被其他线程访问到了.
可以理解为对象逃出了当前线程

逃逸的状态

一个对象有三种逃逸状态

  • 全局逃逸

一个对象的作用范围逃出了当前方法或者当前线程

一般有以下几种场景

  • 对象是一个静态变量
  • 对象是一个已经发生逃逸的对象
  • 对象作为当前方法的返回值
  • 参数逃逸

一个对象被作为方法参数传递或者被参数引用,但在调用过程中不会发生全局逃逸,这个状态是通过被调方法的字节码确定的

  • 没有逃逸

方法中的对象没有发生逃逸

逃逸分析的好处

逃逸分析的作用,就是筛选出没有发生逃逸的对象,从而对它们进行以下三方面的优化

  • 同步消除(锁消除)
    如果你定义的类的方法上有同步锁,但在运行时,却只有一个线程在访问,此时逃逸分析后的机器码,会去掉同步锁运行
  • 标量替换
    Java虚拟机中的原始数据类型(int,long等数值类型以及reference类型等)都不能再进一步分解,它们可以称为标量.
    相对的,如果一个数据可以继续分解,那它称为聚合量.
    Java中最典型的聚合量是对象.
    如果逃逸分析证明一个对象不会被外部访问,并且这个对象是可分解的,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替.
    拆散后的变量便可以被单独分析与优化,可以各自分别在栈帧或寄存器上分配空间,原本的对象就无需整体分配空间了

  • 栈内存分配
    将原本分配在堆内存上的对象转而分配在栈内存上,这样就可以减少堆内存的占用,从而减少 GC 的频次

相关参数配置
-XX:+DoEscapeAnalysis   开启逃逸分析(JDK1.8 中是默认开启)
-XX:-DoEscapeAnalysis    关闭逃逸分析
-XX:+PrintEscapeAnalysis  显示分析结果

-XX:+EliminateLocks    开启锁消除(JDK1.8 中是默认开启)
-XX:-EliminateLocks    关闭锁消除

-XX:+EliminateAllocations    开启标量替换(JDK1.8 中是默认开启)
-XX:-EliminateAllocations    关闭标量替换
-XX:+PrintEliminateAllocations    显示标量替换详情
逃逸实例

下面这段代码演示了逃逸

public class EscapeAnalysisDemo {

    public static Object globalVariableObject;

    public Object instanceObject;

    public void globalVariableEscape(){
        globalVariableObject = new Object();  // 静态变量,外部线程可见,发生逃逸
    }

    public void instanceObjectEscape(){
        instanceObject = new Object();  // 赋值给堆中实例字段,外部线程可见,发生逃逸
    }
    
    public Object returnObjectEscape(){
        return new Object();   // 返回实例,外部线程可见,发生逃逸
    }

    public void noEscape(){
        Object noEscape = new Object();   // 仅创建线程可见,对象无逃逸
    }

}

C1和C2的对比

  • C2编译器启动时长比C1编译器慢
  • 系统稳定执行以后C2编译器执行速度远远快于C1编译器

程序解释执行(不开启性能监控)可以触发C1编译,将字节码编译成机器码,可以进行简单优化,也可以加上性能监控;

C2编译会根据性能监控信息进行激进优化.

不过在Java7版本之后,一旦开发人员在程序中显式指定命令“-server"时,默认将会开启分层编译策略,由C1编译器和C2编译器相互协作共同来执行编译任务.

开发人员可以通过命令显式指定Java虚拟机在运行时到底使用哪一种即时编译器

-client
指定Java虚拟机运行在Client模式下,并使用C1编译器;
C1编译器会对字节码进行简单和可靠的优化,耗时短,以达到更快的编译速度

-server
指定Java虚拟机运行在Server模式下,并使用C2编译器.
C2进行耗时较长的优化,以及激进优化,但优化的代码执行效率更高

注意:64位操作系统默认使用-server服务器模式,即C2编译器

Graal编译器

JDK10起,HotSpot又加入一个全新的即时编译器: Graal编译器

编译效果短短几年时间就追平了C2编译器,未来可期.

目前,带着“实验状态"标签,需要使用开关参数去激活,才可以使用

-XX: +UnlockExperimentalVMOptions 
-XX: +UseJVMCICompiler

AOT编译器

JDK9引入了AOT编译器(静态提前编译器,Ahead Of Time Compiler)

Java 9引入了实验性AOT编译工具jaotc,它借助了Graal 编译器,将所输入的Java 类文件转换为机器码,并存放至生成的动态共享库之中.

所谓AOT编译,是与即时编译相对立的一个概念.

我们知道,即时编译指的是在程序的运行过程中,将字节码转换为可在硬件上直接运行的机器码,并部署至托管环境中的过程.

而AOT编译指的则是,在程序运行之前,便将字节码转换为机器码的过程.

最大好处:
Java虚拟机加载已经预编译成二进制库,可以直接执行.不必等待即时编译器的预热,减少Java应用给人带来“第一次运行慢”的不良体验

缺点:
破坏了java"一次编译,到处运行”(提前干掉了能够跨平台的class文件),必须为每个不同硬件、oS编译对应的发行包.
降低了Java链接过程的动态性,加载的代码在编译期就必须全部已知.
还需要继续优化中,最初只支持Linux x64 java base

请关注我的订阅号

订阅号.png

参考

  • 《深入理解JAVA虚拟机:JVM高级特性与最佳实践》

你可能感兴趣的:(4. 执行引擎)