前言
执行引擎是Java虚拟机的核心组成部分之一。
JVM的主要任务是负责装载字节码到其内部,但字节码并不能够直接运行在操作系统之上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只是一些能够被JVM所识别的字节码指令、符号表和其他辅助信,那么,如果想让一个Java程序运行起来、执行引擎的任务就是将字节码指令解释/编译为对应平台上的本地机器指令才可以。
简单来说,JVM中的执行引擎充当了将高级语言翻译为机器语言的译者。
执行引擎在JVM中的结构如下图所示,在左下角。
1.两种执行器
Java虚拟机的执行引擎子系统中包含两种执行器,分别为解释器和即时编译器。当执行引擎获取到由javac编译后的.class
字节码文件后,在运行时是通过解释器(Interpreter)转换成最终的机械码执行。另外为了提升效率,JVM加入了一种名为 JIT即时编译 的技术,即时编译器的目的是为了避免一些经常执行的代码被解释执行,JIT会将整个函数编译为平台本地的机械码,从而在很大程度上提升了执行的效率。
1.1、解释器(Interpreter)
当Java程序运行时,在执行一个方法或某处代码时,会找到.class
文件中对应的字节码,然后会根据定义的规范,对每条需执行的字节码指令逐行解释,将其翻译成平台对应对应的本地机械码执行。当一条字节码指令被解释执行完成后,紧接着会再根据PC寄存器(程序计数器)中记录的下一条需被执行指令,读取并再次进行解释执行操作。
在HotSpot虚拟机中,解释器主要由Interpreter模块和Code模块构成,Interpreter模块实现了解释执行的核心功能,Code模块主要用于管理解释器运行时生成的本地机械指令。
解释器分为两种类,模板解释器和C++字节码解释器如下:
本篇文章先入个门,先简单的了解一下,之后细讲这一块。
1.2、JIT即时编译器(Just In Time Compiler)
在JVM运行过程中采用的解释器+编译器混合执行的模式,一般是指JIT编译器,在HotSpot虚拟机中内嵌着两个JIT即时编译器,分别为Client Compiler
与Server Compiler
,也就是通常所说的C1和C2编译器,JVM在64位的系统中默认采用的C2编译器,也就是Server Compiler
编译器。不过同样的,在程序启动的时候也可以通过参数显式指定运行时到底采用哪种编译器,如下:
-client:指定JVM运行时采用C1编译器。
C1编译器会对字节码进行简单和可靠的优化,耗时比较短,追求编译速度。
-server:指定JVM运行时采用C2编译器。
C2编译器会对字节码进行激进优化,耗时比较长,追求编译后的执行性能。
由于解释器实现简单,并且具备非常优异的跨平台性,所以现在的很多高级语言都采用解释器的方式执行,比如Python、Rust、JavaScript
等,但对于编译型语言,如C/C++、Go
等语言来说,执行的性能肯定是差一筹的,而前面不止一次提到过:Java为了解决性能问题,所以采用了一种叫做JIT即时编译的技术,也就是直接将执行比较频繁的整个方法或代码块直接编译成本地机器码,然后以后执行这些方法或代码时,直接执行生成的机器码即可。
OK~,那么对于上述中 执行次数比较频繁的代码 判断基准又是什么呢?答案是:热点探测技术。
2.热点代码探测技术
HotSpot VM的名字就可以看出这是一款具备热点代码探测能力的虚拟机,所谓的热点代码也就是指调用次数比较多、执行比较频繁的代码,当某个方法的执行次数在一定时间内达到了规定的阈值,那么JIT则会对于该代码进行深度优化并将该方法直接编译成当前平台对应的机器码,以此提升Java程序执行时的性能。
一个被多次调用执行的方法或一处代码中循环次数比较多的循环体都可以被称为 热点代码 ,因此都可以通过JIT编译为本地机器指令。
2.1 栈上替换OSR(On Stack Replacement)
纵观所有编程语言,类似于C/C++、GO
等编译型语言,都属于静态编译型,也就是指在程序启动时就会将所有源代码编译为平台对应的机器码,但JVM中的JIT却属于动态编译器,因为对于热点代码的编译是发生在运行过程中的,所以这种方式也被称之为 栈上替换(On Stack Replacement),在有的地方也被称为OSR替换。
2.2方法调用计数器与回边计数器
前面提到过:“一个被多次调用执行的方法或一处代码中循环次数比较多的循环体都可以被称为 热点代码”,那么一个方法究竟要被调用多少次或一个循环体到底要循环多少遍才可被称为热点代码呢?必然会存在一个阈值,而JIT又是如何判断一段代码的执行次数是否达到了这个阈值的呢?主要依赖于热点代码探测技术。
在HotSpotVM中,热点代码探测技术主要是基于计数器实现的。HotSpot中会为每个方法创建两个不同类型的计数器,分别为方法调用计数器(Invocation Counter)和回边计数器(BackEdge Counter),方法调用计数器主要用于统计方法被调用的次数,回边计数器主要用于统计一个方法体中循环体的循环次数。
2.2.1方法调用计数器
方法调用计数器的阈值在Client
模式下默认是1500次,在Server
模式下默认是10000次,当一段代码的执行次数达到这个阈值则会触发JIT即时编译。当然,如果你对这些缺省(默认)的数值不满意,也可以通过JVM参数-XX :CompileThreshold
来自己指定。
当一个方法被调用执行时,会首先检查该方法是否已经被JIT编译过了,如果是的话,则直接执行上次编译后生成的本地机器码。反之,如果还没有编译,则先对方法调用计数器+1,然后判断计数器是否达到了规定的阈值,如果还未达到阈值标准则采用解释器的模式执行代码。如果达到了规定阈值则提交编译请求,由JIT负责后台编译,后台线程编译完成后会生成本地的机器码指令,这些指令会被放入Code Cache
中缓存起来(热点代码缓存,存放在方法区/元数据空间中),当下次执行该方法时,直接从缓存中读取对应的机械码执行即可。
2.2.2回边计数器
回边计数器的作用是统计一个方法中循环体的执行次数,在字节码中遇到控制流向后跳转的指令称为“回边” (Back Edge)。与方法调用计数器一样,当执行次数达到某个阈值后,也会触发OSR编译。
OK~,回边计数器的编译过程和方法调用计数器的相差无几,唯一值得一提的就是:不管是方法调用计数器还是回边计数器,在提交OSR编译请求的那次执行操作,还是依旧会采用解释器执行,而不会等到编译操作完成后去执行机器码,因为这样耗费的时间比较长,只有下次再执行该代码时才会执行编译后的机器码。
3.热度衰减
一般而言,如果以缺省参数启动Java程序,那么方法调用计数器统计的执行次数并不是绝对次数,而是一个相对的执行频率,也代表是指方法在一段时间内被执行的次数。当超过一定的时间,但计数器还是未达到编译阈值无法提交给JIT即时编译器编译时,那此时就会对计数器进行减半,这个过程被称为方法调用计数器的热度衰减(Counter Decay),而这段时间则被称为方法调用计数器的半衰周期(Counter Half Life Time)。
而发生热度衰减的动作是在虚拟机GC进行垃圾回收时顺带进行的,可以通过参数-XX:-UseCounterDecay
关闭热度衰减,这样可以使得方法调用计数器的判断基准变为绝对调用次数,而不是以相对执行频率作为阈值判断的标准。不过如果关闭了热度衰减,就会导致一个Java程序只要在线上运行的时间足够长,程序中的方法必然绝大部分都会被编译为本地机器码。
同时也可以通过-XX:CounterHalfLifeTime
参数调整半衰周期的时间,单位为秒。
一般而言,如果项目规模不大,并且上线后很长一段时间不需要进行版本迭代的产品,都可以尝试把热度衰减关闭掉,这样可以使得Java程序在线上运行的时间越久,执行性能会更佳。只要线上运行的时间足够长,到后面可以与C编写的程序性能相差无几甚至超越(因为C/C++需要手动管理内存,管理内存是需要耗费时间的,但Java程序在执行程序时却不需要担心内存方面的问题,会有GC机制负责)。
4.其他的热点探测技术
在前面分析中,我们得知了,在HotSpot中的热点代码探测是基于计数器模式实现的,但是除开计数器的方式探测之外,还可以基于采样(sampling)以及踪迹(Trace)模式对代码进行热点探测。
4.1采样探测
采样探测:采用这种探测技术的虚拟机会周期性的检查每个线程的虚拟机栈栈顶,如果一些在检查时经常出现在栈顶的方法,那么就代表这个方法经常被调用执行,对于这类方法可以判定为热点方法。
优点:实现简单,可以很轻松的判定出热度很高(调用次数频繁)的方法。
缺点:无法实现精准探测,因为检查是周期性的,并且有些方法中存在线程阻塞、休眠等因素,会导致有些方法无法被精准检测。
4.2踪迹探测
踪迹探测:采用这种方式的虚拟机是将一段频繁执行的代码作为一个编译单元,并仅对该单元进行编译,该单元由一个线性且连续的指令集组成,仅有一个入口,但有多个出口。也就代表着:基于踪迹而编译的热点代码不仅仅局限在一个单独的方法或者代码块中,一条踪迹可能对应多个方法,代码中频繁执行的路径就可能被识别成不同的踪迹。
优点:这种方式实现可以使得热点探测拥有更高精度,可以避免将一块代码块中所有的代码都进行编译的情况出现,能够在很大程序上减少不必要的编译开销。因为无论是采样探测还是计数器探测的方式,都是以方法体或循环体作为编译的基本单元的。
缺点:踪迹探测的实现过程非常复杂,难度非常高。
4.3三种探测技术的对比
HotSpot虚拟机采用的计数探测的方式,实现难度、编译开销与探测精准三者之间会有一个很好的权衡。三种探测技术比较如下:
实现难度:采样探测 < 计数探测 < 踪迹探测
探测精度:采样探测 < 计数探测 < 踪迹探测
编译开销:踪迹探测 < 计数探测 < 采样探测
5.通过JVM参数指定执行模式
-Xint:完全采用解释器模式执行程序。
-Xcomp:完全采用即时编译器模式执行程序。如果即时编译器出现问题,解释器会介入执行。
-Xmixed:采用解释器+JIT即时编译器的混合模式共同执行(默认的执行方式)。
上述情况可以酌情考虑,在JRockitVM中,就移除了解释器模块,从而使它获取了一个“史上最快”虚拟机的称号。
至于HotSpot虚拟机为什么要保留这两项,有以下两点:
一个是为了保证Java的绝对跨平台性,另一个则是为了保证启动速度,考虑综合性能。 ①保证绝对的跨平台性:如果将解释器从虚拟机中移除就代表着:每到一个不同的平台,比如从Windows迁移到Linux环境,那么JIT又要重新编译,生成对应平台的机器码指令才能让Java程序执行。但如果是解释器+JIT编译器混合的模式工作就不需要担心这个问题,因为前期可以直接由解释器将字节码指令翻译成当前所在的机械码执行,解释器会根据所在平台的不同,翻译出平台对应的机器码指令。这样从而使得Java具备更强的跨平台性。 ②保证Java启动速度,考虑综合性能:因为如果移除了解释器模块,那么就代表着所有的字节码指令需要在启动时全部先编译为本地的机械码,这样才能使得Java程序能够正常执行。不过如果想在启动时将整个程序中所有的字节码指令全部编译为机器码指令,需要的时间开销是非常巨大的,如果把解释器从JVM中移除,那么会导致一些需要紧急上线的项目可能编译都需要等半天的时间。
当然我们上面也提到过,如果项目规模不大,并且上线后很长一段时间不需要进行版本迭代的产品,我们可以采用默认的执行方式(采用解释器+JIT即时编译器的混合模式共同执行),这个时候可以尝试把热度衰减关闭掉,这样可以使得Java程序在线上运行的时间越久,执行性能会更佳。只要线上运行的时间足够长,到后面可以与C编写的程序性能相差无几甚至超越它。
6.热机与冷机
热机:长时间在线上运行Java程序的机器,程序中很多代码都已经被JIT编译为了本地机器码指令。
冷机:刚刚启动的Java程序的机器,所有代码还是处于解释执行的阶段。
从上面的分析中可以得知:机器在热机状态可以承受的流量负载会远远超出冷机状态,因为热机中在执行时有许多class字节码是经过JIT编译的,如果程序以热机状态切换流量到冷机状态的机器时,可能会导致冷机状态的服务器因无法承载流量而假死。
在生产环境发布过程中,以分批的方式进行发布,根据机器数量划分成多个批次,每个批次的机器数至多占到整个集群的1/8。曾经有这样的故障案例:某程序员在发布平台进行分批发布,在输入发布总批数时,误填写成分为两批发布。如果是热机状态,在正常情况下一半的机器可以勉强承载流量,但由于刚启动的JVM均是解释执行,还没有进行热点代码统计和JIT动态编译,导致机器启动之后,当前1/2发布成功的服务器马上全部宕机,此故障说明了JIT的存在。——阿里团队
从上述这个案例中可以得知,如果直接将热机状态的流量迁移到冷机状态的机器是不可行的,所以一般在计划扩容时,想要流量平滑的切换到新的机器,一般有软硬件两种层面的解决方案,如下:
第一种方案是和上述案例中一样,采用更多的机器承载热机状态过来的流量,等后续这些刚启动的冷机变成热机状态了,可以再把多余的机器停掉。
第二种方案则是网关这边控制流量,先将一部分流量转发给刚启动的冷机,让刚启动的冷机先做预热,等运行一段时间之后再将原本计划的所有流量迁移到这些机器。
7.编译器的种类
在JVM运行过程中采用的解释器+编译器混合执行的模式,一般是指JIT编译器,在Java中对于静态编译器的应用还是比较少的。在HotSpot虚拟机中内嵌着两个JIT即时编译器,分别为Client Compiler
与Server Compiler
,也就是通常所说的C1和C2编译器,JVM在64位的系统中默认采用的C2编译器,也就是Server Compiler
编译器。不过同样的,在程序启动的时候也可以通过参数显式指定运行时到底采用哪种编译器,如下:
-client:指定JVM运行时采用C1编译器。
C1编译器会对字节码进行简单和可靠的优化,耗时比较短,追求编译速度。
-server:指定JVM运行时采用C2编译器。
C2编译器会对字节码进行激进优化,耗时比较长,追求编译后的执行性能。
下面来简单分析一下C1和C2编译器。
7.1、C1编译器(Client Compiler)
C1编译器主要追求稳定和编译速度,属于保守派,C1中常见的优化方案有几种:公共子表达式消除、方法内联、去虚拟化以及冗余消除等。
公共子表达式消除:如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那E的这次出现就成公共子表达式,可以用原先的表达式进行消除,直接使用上次的计算结果,无需再次计算。
方法内联:将引用的方法代码编译到引用点处,这样可以减少栈帧的生成,减少参数传递以及跳转过程。
去虚拟化:对唯一的实现类进行内联。
冗余消除:通过对字节码指令进行流分析,将一些运行过程中不会执行的代码消除。
空检测消除:将显式调用的NullCheck(空指针判断)擦除,改成ImplicitNullCheck异常信号机制处理。
自动装箱消除:对于一些不必要的装箱操作会被消除,比如刚装箱的数据又在后面立马被拆箱,这种无用操作就会被消除。
安全点消除:对于线程无法抵达或不会停留的安全点会进行消除。
反射消除:对于一些可以正常访问无需通过反射机制获取的数据,会被改为直接访问,消除反射操作。
7.2、C2编译器(Server Compiler)
C2编译器则主要是追求编译后的执行性能,属于激进派,C2编译器建立在C1编译器的基础优化之上,除开使用了C1中的优化手段之外,还有几种基于逃逸分析的激进优化手段:标量替换、栈上分配以及同步消除等。
逃逸分析:逃逸分析是建立在方法为单位之上的,判断变量作用域是否存在于其他栈帧或者线程中,如果一个成员在方法体中产生,但是直至方法结束也没有走出方法体的作用域,那么该成员就可以被理解为未逃逸。反之,如果一个成员在方法最后被return出去了或在方法体的逻辑中被赋值给了外部成员,那么则代表着该成员逃逸了,判断逃逸的方法被称为逃逸分析。
全局变量赋值逃逸:当前对象被赋值给类属性、静态属性
参数赋值逃逸:当前对象被当作参数传递给另一个方法
方法返回值逃逸:当前对象被当做返回值return
①栈帧逃逸:当前方法内定义了一个局部变量逃出了当前方法/栈帧。
②线程逃逸:当前方法内定义了一个局部变量逃出了当前线程能够被其他线程访问。
也可以换个说法,建立在线程的角度来看:如果一条线程中的对象无法被另一条线程访问到,就代表该对象未逃逸。
逃逸的作用域:
逃逸类型:
标量替换:建立在逃逸分析的基础上使用基本量标量代替对象这种聚合量。
①能够节省堆内存,因为进行标量替换之后的对象可以在栈上进行内存分配。
②相对运行而言省去了去堆中查找对象引用的过程,速度会更快一些。
③因为是分配在栈上,所以会随着方法结束和线程栈的弹出自动销毁,不需要GC的介入。
标量:reference与八大基本数据类型就是典型的标量,泛指不可再拆解的数据。
好处:
栈上分配:对于未逃逸的对象使用标量替换进行拆解,然后将拆解后的标量分配在局部变量表中,从而减少实例对象的产生,减少堆内存的使用以及GC次数。
①对象能够通过标量替换分解成一个个标量。
②对象在栈帧级作用域不可逃逸。
决定一个对象能否在栈上分配的因素(两个都必须满足):
同步消除:在出现synchronized
嵌套的情况下,如一个同步方法中调用另一个同步方法,那么第二个同步方法的synchronized
锁会被消除,因为第二个方法只有获取到了第一个锁的线程才能访问,不存在线程并发安全问题。
①当前对象被分配在栈上。
②当前对象的无法逃出线程作用域。
决定能否同步消除(满足一个即可):
空检查剪支:经过流分析后,对于一些不会执行的Null分支判断会直接剪掉
如一个参数在外部方法传递前已经做了非空检测了,但在内部方法中依旧又做了一次非空判断,那么对于内部的这个非空判断会被直接剪除掉。
前面提到了,64位的JVM中都是默认使用C2编译器的,但实际上JDK1.6之后如果是64位的机器,默认情况下或显式指定了-server模式运行时,JVM会开启分层编译策略,也就是通过C1+C2相互协作共同处理编译任务。而分层编译大体的逻辑为:Java程序刚启动还处于冷机状态时,采用C1编译器进行简单优化,追求编译速度和稳定性,当JVM达到热机状态时,后面的编译请求则通过C2编译器进行全面激进优化,追求编译后执行时的性能和效率。
PS:两种不同的模式运行,热点代码缓存区大小也会不一样,Server模式下CodeCache的初始大小为2496KB,Client模式下CodeCache的初始大小为160KB,可以通过-XX:ReservedCacheSize
参数指定CodeCache的最大大小。
7.3 Graal
编译器
Graal
编译器:在JDK10的时,HotSpot加入了一种新的编译器
该编译器的性能经过几代的更新后很快就追上了老牌的C2编译器,在JDK10中可以通过-XX: +UnlockExperimentalVMOptions -XX: +UseJVMCICompiler
参数使用它。
8.执行引擎
JVM中执行引擎所在的位置:
执行引擎的流程图:
——图片来自网络
一般而言,在字节码被加载进内存之后,都会经过如上几个步骤才会被翻译成本地的机械指令执行,但这几个优化步骤却并不是必须的,如果不需要也可以在程序启动时通过JVM参数关闭。但综合而言,虽然优化的过程会耗费一些时间,但这样却能够大大的提升程序在执行时的速度,所以总归而言利大于弊。
8.1对于Class文件结构的定义
执行引擎的入口的数据是字节码文件,而在HotSpot虚拟机中对于Class文件结构的定义如下:
struct ClassFile { u4 magic; // 识别Class文件格式,具体值为0xCAFEBABE u2 minor_version; // Class文件格式副版本号 u2 major_version; // Class文件格式主版本号 u2 constant_pool_count; // 常量表项个数 cp_info **constant_pool; // 常量表,又称变长符号表 u2 access_flags; // Class的声明中使用的修饰符掩码 u2 this_class; // 常数表索引,索引内保存类名或接口名 u2 super_class; // 常数表索引,索引内保存父类名 u2 interfaces_count; // 超接口个数 u2 *interfaces; // 常数表索引,各超接口名称 u2 fields_count; // 类的域个数 field_info **fields; // 域数据,包括属性名称索引 u2 methods_count; // 方法个数 method_info **methods; // 方法表:包括方法名称索引/方法修饰符掩码等 u2 attributes_count; // 类附加属性个数 attribute_info **attributes; // 类附加属性数据,包括源文件名等};
任何.java
后缀的Java源码经过编译后都会生成为符合如上格式的class
字节码文件。执行引擎接收的输入格式也为如上格式的class
文件,不过值得注意一提的是:JVM不仅仅只接收.java
文件编译成的.class
文件,对于所有符合如上格式规范的字节码文件都可以被JVM接收执行。
8.2 对于方法的定义
HotSpot虚拟机是基于栈式的,也就代表着执行引擎在执行方法时,执行的是一个个的栈帧,栈帧中包含局部变量表、操作数栈、动态链接以及方法返回地址等描述方法的相关信息。但执行引擎在虚拟机运行时,只会执行最顶层的栈帧,因为最顶层的栈帧是当前需要执行的方法,执行完当前方法后会弹出顶部的栈帧,然后将下一个栈帧(新的顶部栈帧)拿出继续执行。 刚刚提到了方法的相关信息被存储在栈帧中,而栈帧的方法信息是从class
字节码文件中读出来的,每个方法通过结构体method_info
来描述,如下:
struct method_info{ u2 access_flags; //方法修饰符掩码 u2 name_index; //方法名在常数表内的索引 u2 descriptor_index; //方法描述符,其值是常数表内的索引 u2 attributes_count; //方法的属性个数 attribute_info **attributes; //方法的属性表(局部变量表)};原文:https://juejin.cn/post/7102783284018577422