文章目录
- 前言
- 名词解释
- 机器码
- 指令
- 指令集
- 汇编语言
- 高级语言
- 字节码
- 虚拟机&物理机
- 前端编译器&后端编译器
- JVM之执行引擎
- 执行引擎是如何工作的?
- 解释器
- 即时编译器(JIT)
- 分层编译策略
- 虚拟机执行模式
- 热点代码&探测方式
- 1)方法调用计数器
- 2) 回边计数器
- Graal编译器&AOT编译器
前言
本文我们将讲解JVM中的执行引擎。
问题:我们平时所写的Java程序是如何将其进行编译并转换为计算机能够识别的机器码呢?并且Java程序编译和C/C++程序编译有什么区别呢?我们所说的JIT编译器和平时说的编译器有什么区别呢?…我相信在阅读本文过后,你会有一个清楚的认识的。
-
Java的[前端]编译只会生成字节码文件,而不会生成汇编(更不会到机器语言)。Java程序运行时,字节码文件会装载入java虚拟机,虚拟机将字节码“翻译”成机器指令来运行。java在不同平台上实现虚拟机,针对虚拟机编译就可以实现代码可移植性。
-
C/C++程序编译执行过程简单来说,整个过程分为四个阶段:预处理(Pre-Processing)、编译(Compilation)、汇编(Assembling)、链接(Linking)。C/C++语言程序编译成的是机器码,通常不能在不同指令系统的机器上运行。C/C++程序的编译一般是直接针对硬件的。
名词解释
机器码
-
定义:各种用二进制编码方式表示的指令,叫做机器指令码。
-
特点:
- 能够被计算机理解和接受,其编写的程序输入到计算机中,CPU可直接读取运行,因此执行速度是最快的;
- 其与CPU紧密相关,不同类型的CPU对应的机器码也不同;
指令
由于机器码是有0和1组成的二进制序列,可读性太差,于是指令出现了。
-
定义:指令就是把机器码中特定的0和1序列,简化成相应的指令(一般使用英文简写,如mov,inc等),告诉计算机从事某一特殊运算的代码。如:数据传送指令、算术运算指令、位运算指令、程序流程控制指令、串操作指令、处理器控制指令。
-
组成形式:
一条指令通常由两个部分组成:操作码+地址码。- 操作码:指明该指令要完成的操作的类型或性质,如取数、做加法或输出数据等。
- 地址码:指明操作对象的内容或所在的存储单元地址。
-
特点:
- 指令是计算机运行的最小的功能单位
- 不同的硬件平台的同一种指令(比如mov),对应的机器码也可能不同。
指令集
-
定义:一台计算机上全部指令的集合,就是这台计算机的指令系统。指令系统也称指令集,是这台计算机全部功能的体现。
-
特点
-
不同的硬件平台,各自支持的指令,是有差别的。因此每个平台所支持的指令,称之为对应平台的指令集。
-
如常见的
- x86指令集,对应的是x86架构的平台
- ARM指令集,对应的是ARM架构的平台
-
汇编语言
-
概念:汇编语言是一种低级语言,亦称为符号语言。在汇编语言中,用助记符代替机器指令的操作码,用地址符号或标号代替指令或操作数的地址。
-
特点:
- 在不同的硬件平台中,汇编语言对应着不同的机器语言指令集,通过汇编过程转换成机器指令。
- 由于计算机只认识指令码,所以用汇编语言编写的程序还必须翻译成机器指令码,计算机才能识别和执行。
高级语言
-
概念:高级语言是一种独立于机器,面向过程或对象的语言。
-
特点:
- 用人们更易理解的方式编写程序,编写的程序称之为源程序。
- 高级语言与计算机的硬件结构及指令系统无关,它有更强的表达能力,可方便地表示数据的运算和程序的控制结构,能更好的描述各种算法,而且容易学习掌握。
- 当计算机执行高级语言编写程序的时候,仍然需要将
程序解释和编译成机器的指令码
。完成这个过程的程序就叫做解释程序或者编译程序。
字节码
-
概念:字节码是一种特殊状态【中间码】的二进制代码文件,比机器码更抽象,需要转译后才能形成机器码。
-
特点:
- 字节码主要为了实现特定软件运行和软件环境、与硬件环境无关→【跨平台】
- 字节码的实现方式是通过编译器和虚拟机器。编译器将源码编译成字节码,特定平台上的虚拟机器将字节码转译为可以直接执行的指令。
字节码典型的应用为:Java bytecode
虚拟机&物理机
“虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行的能力。
-
区别:
- 物理机的执行引擎是建立在处理器、缓存、指令集和操作系统层面上的。
- 虚拟机的执行引擎则是由软件自行实现的,因此可以不受物理条件的制约指令集和执行引擎的结构体系,能够执行那些不被硬件直接支持的指令集格式。
前端编译器&后端编译器
前端编译器:把*.java文件转变成*.class文件的过程。
后端编译器:把Class文件转换成与本地基础设施(硬件指令集、操作系统)相关的二进制机器码的过程。
JVM之执行引擎
执行引擎是Java虚拟机核心组件之一,由于字节码并不能够直接运行在操作系统之上(字节码指令并非等价于本地机器指令,它内部包含的仅仅只是一些能够被JVM所识别的字节码指令、符号表、以及其他信息),因此JVM将字节码加载到其内部之后,执行引擎(Execution Engine)的任务就是将字节码指令解释/编译为对应平台的本地机器指令
,简单来说JVM的执行引擎充当了高级语言翻译为机器语言的译者。
执行引擎是如何工作的?
- 执行引擎在执行的过程中究竟需要执行什么样的字节码指令完全依赖于PC寄存器;
- 每当执行完一项指令操作之后,PC寄存器就会更新下一条需要被执行的指令的地址;
- 当然方法在执行的过程中,执行引擎可能会通过存储在局部变量表中的对象引用准确定位到存储在Java堆区中的对象的实例信息,以及通过对象头中的元数据指针定位到目标对象的类型信息;
JVM执行引擎具体流程图如下图所示
- Java虚拟机的执行引擎的输入、输出都是一致的
输入:字节码二进制流;
输出:解释器和编译器将字节码”翻译“后的机器指令;
提出问题:为什么说Java是半编译半解释型语言?你会在下面的内容中找到对应的答案
解释器
-
概念:当Java虚拟机启动的时候会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容“翻译”为对应平台的本地机器指令执行。(一行一行解释字节码指令,立即执行,不需要编译)
-
如何工作
-
当一条字节码指令被解释执行完成之后,接着再根据PC寄存器中记录的下一条需要被执行的字节码指令执行解释操作。
-
在Hotspot VM中,解释器主要由Interpreter模块和Code模块构成
-
Interpreter模块:实现了解释器的核心功能
-
Code模块:用于管理Hotspot VM 在运行时生成的本地机器指令
-
由于解释器在设计上和实现上非常简单,如今基于解释器执行已经是低效的代名词。为了解决这个问题,即时编译技术出现了。
即时编译器(JIT)
Java语言的“编译器”在没有具体上下文语境的话,是一个很模糊的概念,因为它可能是指
-
前端编译器(其实也叫 编译器的前端):把 .java 文件转换成 .class 文件的过程
-
后端编译器(JIT编译器 Just In Time Compiler) :把字节码转换为机器码的过程
-
静态提前编译器(AOT编译器 Ahead Of Time Compiler):直接把 .java 文件编译成本地机器码的过程
而JVM执行引擎中的即时编译器是属于后端编译器,因此下面我们主要以讲解即时编译器为主。
Hotspot虚拟机采用解释器和即时编译器并存的架构,在其运行的时候,会找到解释器和即时编译器相互合作的节点。基于这个特点,Java的运行性能已经可以和C/C++程序一教高下的地步。
问题来了: 既然Hotspot VM中已经内置了JIT编译器了,那么为啥还需要再使用解释器来“拖累”程序的执行性能呢?
解释器与编译器两者各有优势:
-
当程序需要迅速启动和执行的时候,解释器可以首先发挥作用【响应快】,不必等待即时编译器全部编译完再执行,可以立即运行。
-
当程序启动后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译为本地代码,这需要一定的时间。但是编译为本地代码后,执行效率高。
综上所述,解释器还可以作为编译器激进优化时后备的“逃生门”,即让编译器根据概率选择一些不能保证所有情况都正确,但大多数时候都能提升运行速度的优化手段,当激进优化的假设不成立时(比如加在了新类以后,类型继承结构出现变化等)可以通过逆优化退回到解释状态继续执行,因此二者经常是相辅相成地配合工作。交互关系如下所示
HotSpot虚拟机内置了两个(或三个)即时编译器。
-
Client Compiler(客户端编译器/C1编译器)
- C1编译器会对字节码进行简单和可靠的优化,耗时短以达到更快的编译速度
-
Server Compiler(服务端编译器/C2编译器)
- C2进行耗时较长的优化,以及激进优化 ,但是优化的代码执行效率高。
-
Graal编译器:JDK10才出现的,目的是代替C2,这里不予讲解。
C1和C2编译器不同的优化策略
-
C1编译器有方法内联,去虚拟化、冗余消除
- 方法内联:将引用的函数代码编译到引用点处,这样就可减少栈帧的生成,减少参数的传递和跳转过程
- 去虚拟化:对唯一的实现类进行内联
- 冗余消除:在运行期间会把一些不会执行的代码折叠掉
-
C2的优化主要是在全局层面,逃逸分析师优化的基础,基于逃逸分析在C2上有如下几种优化
- 标量替换:用标量替换聚合变量
- 栈上分配:对于为逃逸的对象分配对象的在栈而不是在堆
- 同步消除:清除同步动作,通常指的是synchronized
注意:64位版本的JDK只支持"-server"模式,其它版本JDK,用户可以使用"-client"或"-server"参数强制指定虚拟机运行在客户端模式还是服务端模式。
- 程序解释执行(不开启性能监控)可以触发C1,将字节码编译成机器码,可以进行简单优化,也可加上性能优化,C2编译会根据性能监控信息进行激进优化
- 不过在Java7版本之后,一旦开发人员在程序中显示指定命令 "-server"时,默认开启,使用c1和c2共同执行
分层编译策略
分层编译工作模式出现以前,HotSpot虚拟机通常是采用解释器和其中一个编译器直接搭配的方式工作。为了在程序启动响应速度与运行效率之间达到最佳平衡,HotSpot虚拟机在编译子系统中加入了分层编译策略:
- 第 0 层:程序解释执行,默认开启性能监控功能(Profiling),如果不开启,可触发第二层编译;
- 第 1 层:可称为 C1 编译,将字节码编译为本地代码,进行简单、可靠的优化,不开启 Profiling;
- 第 2 层:也称为 C1 编译,开启 Profiling,仅执行带方法调用次数和循环回边执行次数 profiling 的 C1 编译;
- 第 3 层:也称为 C1 编译,执行所有带 Profiling 的 C1 编译;
- 第 4 层:可称为 C2 编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。
虚拟机执行模式
缺省情况下 Hotspot 虚拟机使用解释器与编译器并存的架构,开发人员也可以对其进行调整,设置为完全采用解释器或者完全采用即时编译器。
- -Xint:完全采用解释器模式执行程序
- -Xcomp:完全采用即时编译器模式执行程序,如果编译出现问题,解释器仍然会介入
- -Xmixed:采用解释器+即时编译器混合模式执行程序
C:\Users\13832>java -version
java version "1.8.0_162"
Java(TM) SE Runtime Environment (build 1.8.0_162-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.162-b12, mixed mode)
C:\Users\13832>java -Xint -version
java version "1.8.0_162"
Java(TM) SE Runtime Environment (build 1.8.0_162-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.162-b12, interpreted mode)
C:\Users\13832>java -Xcomp -version
java version "1.8.0_162"
Java(TM) SE Runtime Environment (build 1.8.0_162-b12)
Java HotSpot(TM) 64-Bit Server VM (build 25.162-b12, compiled mode)
上面提到了热点代码,什么是热点代码?如何确定代码是热点代码的呢?请继续向下阅读。
热点代码&探测方式
JIT编译器需要根据字节码执行的频率来确定是否要将字节码编译成本地机器指令,执行频率较高的代码就称为热点代码,JIT编译器就针对这些执行频率较高的代码进行深度优化,编译成本地机器指令,在下次执行时就不用再使用解释器解释了,而是直接执行本地机器指令,进而提升JAVA性能。
热点代码主要有两类:
- 被多次调用的方法
- 被多次执行的循环体
对于这两种情况,编译的目标对象都是整个方法体。由于这种编译方式发生在方法的执行过程中,因此也被称为栈上替换。或者简称为 OSR(On Stack Replacement),即方法的栈帧还在栈上,方法就被替换了。
上面的描述中,一个方法究竟要调用多少次,或者一个循环体究竟循环多少次,才会达到这个标准?这就依赖热点探测功能(目的是确定某段代码是不是热点代码,是不是需要触发即时编译)。
主流的热点探测判定方式有两种,分别是:
- 基于采样的热点探测:虚拟机会周期性地检查各个线程地调用栈顶,如果发现某个(或某些)方法经常出现在栈顶,那么该方法即为“热点方法”。这种方法实现简单高效,但很难精确地确认一个方法地热度。
- 基于计数器的热点探测:虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法”。【HotSpot虚拟机使用的方式】
采用计数器的热点探测,Hotspot VM 将会为每一个方法都建立2个不同类型的计数器,分别为方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)
- 方法调用计数器用于统计方法被调用的次数
- 回边计数器则用于统计循环体执行的循环次数
1)方法调用计数器
- 这个计数器就是用于统计方法被调用的次数,它的默认阈值再 Client 模式下是1500次,再Server下是10000次,超过这个阈值,就会触发JIT编译。这个阈值可以通过虚拟机参数 -XX:ComplieThreshold 来设定
- 当一个方法被调用的时候,会先检查该方法是否存在被JIT编译过的版本,如果存在,则优先使用编译后的本地代码来执行;如果不存在已经被编译的版本,则将此方法的调用计数器值加1,然后判断方法调用计数器和回边调用计数器之和是否超过方法调用计数器的阈值,如果超过,就向编译器提交一个该方法的编译请求
热度衰减概念:方法调用计数器统计的不是被调用的绝对次数,而是一个相对的执行频率。即一段时间之内方法被调用的次数。当超过一定的时间限制,如果方法调用的次数不足以将它转交给即时编译器编译,那这个方法的调用计数器就会减少一半,这个过程就是调用计数器热度的衰减(Counter Decay),而这段时间就称为次方法统计的半衰周期(Counter Half Life Time)。
进行热度衰减的动作,是在虚拟机内部垃圾收集的时候顺便执行的,可以使用虚拟机参数 -XX:-UseCouinterDecay关闭热度衰减,这样只要系统运行时间足够长,程序中绝大部分方法都会被编译成本地代码。
- 可以使用 -XX:CounterHalfLifeTime 参数设置半衰周期的时间,单位是秒。
2) 回边计数器
- 统计方法中循环体代码的执行次数,建立回边计数器统计就是为了触发栈上的替换编译。
- 当解释器遇到一条回边指令时,会先查找将要执行的代码片段是否有已经编译好的版本,如果有的话,将会优先执行已编译的代码,否则就把回边计数器的值加1,然后判断方法调用计数器与回边计数器值之和是否超过回边计数器的阈值。超过阈值的话,将会提交一个栈上替换编译请求,并把回边计数器的值降低一些。
代码演示
public class IntCompTest {
public static void main(String[] args) {
long start = System.currentTimeMillis();
testPrimeNumber(1000000);
long end = System.currentTimeMillis();
System.out.println("花费的时间为:" + (end - start) + "ms");
}
public static void testPrimeNumber(int count){
for (int i = 0; i < count; i++) {
//计算100以内的质数
label:for(int j = 2;j <= 100;j++){
for(int k = 2;k <= Math.sqrt(j);k++){
if(j % k == 0){
continue label;
}
}
}
}
}
}
-Xint[纯解释器]:花费的时间为:6184ms
-Xcomp[纯编译器]:花费的时间为:746ms
-Xmixed[默认]:花费的时间为:710ms
Graal编译器&AOT编译器
Graal编译器
-
在JDK10之后,Hotspot又加入了一个全新的即时编译器:Graal 编译器,目标是在未来替代C2编译器
-
目前还在实验阶段
- -XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler去激活才能使用
AOT编译器
-
JDK9.0之后加入了AOT编译器(静态提前编译器 Ahead Of Time Compiler)
-
所谓的AOT编译器时与即时编译器对立的一个概念,即时编译器在程序运行的时候,而AOT编译器是在程序运行之前,便将字节码转换为机器码。
-
好处:
- 可以直接执行,不必等程序预热,减少第一次运行慢的体验
-
缺点:
- 破坏了Java”一次编译,到处运行“,因为直接编译之后的文件是固定的,必须为每种系统都编译
- 降低了Java动态链接的动态性,加载的代码在编译器期就必须全部已知
- 还需要继续优化中,最初只支持Linux x64 java base