写在前面的话:本文是在观看尚硅谷JVM教程后,整理的学习笔记。其观看地址如下:尚硅谷2020最新版宋红康JVM教程

执行引擎是Java虚拟机中的核心组成部分。

==执行引擎的作用就是解析虚拟机字节码指令==,即执行一条条的代码流程,并得到执行结果。

我们可以先来看一下执行引擎在Java虚拟机中的位置,

在这里插入图片描述
可以看出,==一个Java线程就是一个执行引擎的实例==。则在一个JVM实例中就会有很多个执行引擎在工作,可能有的在执行我们编写的应用程序,有的在执行JVM内部程序,如垃圾收集器等。

1、执行引擎的工作过程

  “虚拟机”是一个相对于“物理机”的概念,这两种机器都有代码执行功能。其区别是物理机的执行引擎是直接建立在处理器、缓存、指令集和操作系统层面上的,==而虚拟机的执行引擎则是由软件自行实现的==,因此可以不受物理条件制约,自由地去定制指令集和执行引擎的体系结构,并执行那些不被硬件直接支持的指令集格式。

指令集:所谓指令集,就是在CPU中用来计算和控制计算机系统的一套指令的集合,每一种新型的CPU在设计时都规定了一系列与其他硬件电路相匹配的指令系统。即指令集是用来操纵计算机硬件资的。指令集跟“物理机”上的硬件是绑定的,不同类型的CPU,它的指令集也是不同的。

JVM想要成功运行,也必须跟物理机一样有一套指令集。==JVM的指令集就是我们常说的Java字节码==。所以,任何符合class文件规范的Java字节码指令都可以被JVM执行。

但==字节码并不能够直接运行在操作系统上==,因为字节码不等同于本地机器指令(能被机器直接执行的代码)。字节码仅仅包含有能被JVM所识别的字节码指令、符号表,以及其他的辅助信息。

想要让一个Java程序运行起来,==执行引擎的作用就是将字节码指令解释(或者编译)成对应平台上的本地机器码==。即JVM中的执行引擎充当了将高级语言编译成机器语言的译者。

如图所示,

在这里插入图片描述
在JVM中,==执行引擎需要执行什么样的字节码指令,完全依赖于程序计数器==,程序计数器保存着当前字节码指令的地址。

每执行完一次指令操作后,程序计数器就会更新下一条需要被执行的指令地址。

当然方法在执行过程中,==执行引擎有可能会通过存储在局部变量表中的对象引用准确定位到存储在Java堆区中的对象实例信息,以及通过对象头中的元数据指针定位到目标对象的类型==。

从宏观上看,所有的Java虚拟机的执行引擎输入输出都是一致的:输入的都是字节码二进制流,处理过程就是字节码解释执行(或编译执行)的过程,输出的都是执行结果。

2、Java代码编译和执行的过程

2.1、编译过程

我们知道,任何一种高级语言,都需要经过翻译才能被实体机器所识别并执行,这个“翻译”的过程就是我们常说的编译,==编译器就是专门用于编译工作的==。

通常编译器都是将便于人们理解的语言转化为机器能识别的机器码,如C/C++或者汇编语言都是直接将源代码编译成目标机器能识别的二进制机器码。这个机器码也就是CPU能够直接执行的指令集合。

Java语言常用的编译器是==Javac==,这是JDK自带的一个编译器。但==Javac==并不是直接将Java源代码编译成机器码,而是编译成JVM能够识别并执行的另一种语言——Java字节码。由一条条字节码组成的文件就是我们常说的字节码文件(.class文件)。

==那么字节码指令又如何“翻译”成CPU识别的机器码呢?==

这就是JVM中的执行引擎的任务了。

下面我们先简单介绍Java源代码编译成字节码的过程。

一般的流程如下,
在这里插入图片描述
==词法分析==:

将源代码一字节一字节地读入,然后找出这些字节中的语法关键词(如if、for、while等Java定义的保留关键词),其结果就是从源码中找出符合规范的Token(标记)流。就好比给一句话,我们要找出那些是特殊的词语,那些不是。其结果就是找出源码中所有的Java关键字。

==语法分析==:

检查词法分析中找出来的关键词组合在一起是否符合Java语言规范,如词法分析中找出来一个关键词if,那么语法分析就要检查这个if后面跟着的是不是一个布尔判断表达式,即使用if这个关键词时要符合Java的语法规范。其结果就是形成一个语法树。

==语义分析==:

即分析语法树,把一些浮渣的语法转化成简单语法。其结果就是使语法更贴近目标语言的语法规划,形成一个注解过后的语法树(比如把foreach转化成for循环,并加上注解)。

==字节码生成==:

经过以上几步后,Javac会通过其字节码生成器组件生成字节码文件。

Javac的主要模块就是词法分析器、语法分析器、语义分析器和代码生成器。它们共同完成将Java源码转化成字节码的任务。

2.2、执行过程

Java源码经过编译后,会成为Java字节码。而字节码的执行是由JVM中的执行引擎来完成的。

其执行流程如下,

在这里插入图片描述
可以看出,JVM的执行引擎在执行字节码时,有==通过解释器逐行解释执行和通过JIT编译器编译产生本地代码再执行==这两种方式,

什么是解释器和JIT编译器?

==解释器(Interpreter)==:

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

==JIT编译器(Just In Time Complier)==

JVM会将字节码直接编译成和本地机器平台相关的机器语言,缓存起来,然后再执行。

在JDK1.0时,JVM采用的是解释执行,后来又发展出了可以直接生成本地代码的即时编译器。
==现在JVM在执行Java字节码时,都会将解释执行与编译执行二者结合==。

故Java也被称为半编译半解释型语言。

3 、解释器和JIT编译器

3.1、解释器

解释器真正意义上所承担的角色是一个运行时的“翻译者”,将字节码文件中的内容“翻译”为对应平台的本地机器指令执行。

当一条字节码指令被解释执行完成后,接着再==根据PC寄存器中记录的下一条需要被执行的字节码指令进行解释执行==

在HotSpot虚拟机中,解释器主要由Interpreter模块和Code模块构成。

==Interpreter模块==:实现解释器的核心功能。
==Code模块==:用于管理虚拟机在运行时生成的本地机器指令。

在这里插入图片描述
由于解释器在设计和实现上非常简单,因此除了Java语言外,还有许多高级语言也同样基于解释器执行,如Pyhton、Perl、Ruby等。但基于解释器执行的效率总是十分低下。

为了解决这一问题,JVM平台支持一种叫做即时编译的技术。即时编译的目的是避免函数被解释执行,而是将整个函数体编译成为机器码。每次函数执行时,只执行编译后的机器码即可,这种方式使执行效率大幅度提升。

3.2、JIT编译器

现代虚拟机为了提高执行效率,会==使用即时编译技术将方法编译成机器码后再执行==。

HotSpot虚拟机是目前市面上高性能虚拟机的代表作之一。它采用解释器与即时编译器并存的架构,==当虚拟机发现某个方法或代码块的运行特别频繁时,就会把这些代码认定为“热点代码”==。

为了提高热点代码的执行效率,在运行时,虚拟机会把这些代码编译成与本地平台相关的机器码,并运行各层次的优化,完成这个任务的编译器被称为JIT即时编译器。

在这里插入图片描述

3.3、为什么HotSpot要一起使用JIT编译器和解释器?

==首先==,当程序启动后,==解释器可以马上发挥作用,省去编译时间立即执行==。而编译器想要发挥作用,得先把代码编译成本地代码,这需要一定的执行时间。所以,如果只使用JIT编译器的话,程序在启动时,必然需要花费更长的时间来编译。而两者共同使用,在程序启动时,解释器立即执行,省去了编译时间。当越来越多的字节码被JIT编译器编译成本地代码后,,可以获得更高的执行效率。

==其次==,当程序运行环境所能利用的内存资源有限时,可以使用解释器执行以节约内存。若内存充足,则可以使用JIT编译器提升效率。

==而且==,虽然说JIT编译器比解释器快,==但实质上说的是执行编译后的目标代码比边解释边执行快==。对于一些只执行一次的代码(如类的初始化方法< clinit >),如果算上编译所需要的时间,实际上解释执行比JIT编译执行要快。==只有对于频繁执行的热点代码,JIT编译才能确保编译后执行带来的时间优势能抵消掉编译所需的时间开销==。因此,使用解释器执行很少使用或只使用一次的代码,而用JIT编译器执行热点代码,可以在一定程度上保证效率。

==同时==,当执行JIT编译器编译后的代码出现罕见的错误时,可以通过逆优化的手段,退后到解释状态继续执行。

3. 4、热点代码及探测方式

一个被多次调用的方法,或者是一个方法体内部循环次数达到一定次数的代码被称为热点代码。这些代码会被JIT编译器编译为对应平台的本地机器指令。由于JIT编译发生在方法执行过程中,因此也被称为==栈上替换(On Stack Replacement)==。

==HotSpot虚拟机要如何判断出热点代码?==

判断一段代码是否为热点代码的行为被称为“热点探测”,常见的热点探测方式有:

  • ==基于计数器的探测==:JVM会为每个方法(或每个代码块)建立计数器,统计执行次数,如果超过阀值那么就是热点代码。缺点是维护计数器开销。
  • ==基于采样的探测==:JVM会周期性检查各个线程的虚拟机栈的栈顶,如果某个方法经常出现在栈顶,那么就是热点代码。缺点是不精确。

HotSpot采用的热点探测方式是基于计数器的热点探测。HotSpot虚拟机会为每个方法都建立两个不同类型的计数器,分别为==方法调用计数器(Invocation Counter)== 和 ==回边计数器(Back Edge Counter)==。

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

3.5、方法调用计数器

这个计数器就用于统计方法被调用的次数,它的默认阈值在Client模式下是1500次,在Server模式下是10000次。超过这个阈值,就会触发JIT编译。

可以通过虚拟机参数 -XX:Complier Threshold 来设定阈值。

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

在这里插入图片描述
==如果不做任何设置,方法调用计数器统计的并不是方法的绝对调用次数,而是一段时间内的方法被调用的次数==。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那么这个方法的调用计数器值就会减半,这个过程称为方法调用计数器的==热度衰减(Counter Decay)==,而这段时间被称为方法的半衰周期( Counter Half Life Time )。

进行热度衰减的动作是在虚拟机进行垃圾回收时一起进行的,==可以使用虚拟机参数来关闭热度衰减,让方法计数器统计方法的绝对调用次数==。这样,只要系统运行的时间足够长,绝大部分方法都会被编译成本地代码。当然也可以使用虚拟机参数来设置半衰期的时间,单位是秒。

-XX:+/-UseCounterDecay 开启/关闭热度衰减
-XX:CounterHalfLifeTime 设置半衰期时间,单位秒

3.6、回边计数器

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

3.7、HotSpot设置程序执行方式

==在缺省情况下(即未设置参数时)HotSpot虚拟机是采用解释器与即时编译器并存的架构==。

开发人员也可以根据具体的应用场景,通过命令显式地为JVM指定在运行时采用的是完全解释执行,或者是完全采用编译执行,亦或是混合执行(默认)。

虚拟机设置的参数如下,

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

3.8、C1编译器和C2编译器

在HotSpot虚拟机中内置了两个JIT编译器,分别为Client Compiler和Server Compiler,我们平时简称为C1编译器和C2编译器。

C1编译器会对字节码进行简单和可靠的优化,耗时短,以达到更快的编译速度。C2编译器进行耗时较长的优化,属激进优化,但字节码码执行效率更高。

可以通过以下虚拟机参数指定Java虚拟机运行时使用的是哪一个即时编译器。

-Client:指定JVM运行在Client模式下,并使用C1编译器
-Server:指定JVM运行在Server模式下,并使用C2编译器

==JVM的Client模式和Server模式:==

JVM的Server模式启动时,速度较慢,但当运行起来后,性能将会比Client模式更高。原因是JVM在Client模式下使用的是C1编译器(体量较轻)。当JVM运行在Server模式下时,使用的是C2编译器(体量较重)。C2编译器比C1编译器更加彻底,服务运行起来后性能更强,但启动时间长。

我们可以直接在命令行输入 java -verion来查看当前JVM是运行在何种模式下。

如果我们在JVM启动时,没有指定运行在那种模式下,那么虚拟机将会自动检测主机是否为服务器。如果是,则以Server模式运行,否则以Client模式运行。虚拟机检测是否为服务器的标准是,至少有两个CPU和最低2G内存。

不过需要注意的是,如果想切换模式,要先确认JDK是否两种模式都支持。
查看方式是,去JAVA_HOME/jre/bin目录下看是否存在名为Client和Server的目录,存在即表明支持。一般情况下,32位的机器都支持Server模式和Client模式。但64位的JDK只支持Server模式。

==C1和C2编译器不同的优化策略:==

在不同的编译器上有不同的优化策略。

(1)、C1编译器上主要有方法内联、去虚拟化、冗余消除
①方法内联:将引用的函数代码编译到引用处,这样可以减少栈帧的生成,减少参数传递以及跳转过程。
②去虚拟化:对唯一的实现类进行内联。
③冗余消除:在运行期间把一些不会执行的代码折叠掉
(2)、C2的优化主要在全局层面,逃逸分析是优化的基础。基于逃逸分析在C2上有如下几种优化:
①标量替换:用标量值替换聚合对象的属性值
②栈上分配:对于未发生逃逸的对象分配内存在栈上而不是堆上。
③同步消除:消除同步操作,通常指消除syhchronized。

==分层编译策略:==

第一层,为解释执行,在程序解释执行(不开启性能监控)时,可触发第二层。第二层,触发C1编译,可以进行简单优化。第三层,加上性能监控,C2编译器会根据性能监控信息进行激进优化。

不过在现在的Java7版本后,如在启动时指定运行Server模式时,默认将开启分层编译策略,由C1和C2相互协作共同编译任务。

==总结:==

一般来讲,JIT编译器编译出来的机器码性能比解释器高。C2编译器启动时间比C1编译器长,但C2编译器执行速度远快于C1编译器。

==扩展:==

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

所谓AOT编译,是与即时编译器相对应的概念。在即时编译器中,字节码在程序运行时被转换为可在硬件上执行的本地机器码。而AOT编译,则是在程序执行之前,就直接将字节码编译为本地机器码,在JVM启动时即立即执行。

这样做的好处是:

JVM可以直接执行这些被AOT编译过的代码,无需等待即时编译器的预热,加快了启动速度。

缺点是:

①破坏了Java一次编译,到处运行的特性,必须为每个不同硬件,操作系统编译对应的发行包。
②降低了Java链接过程的动态性,加载的代码在编译期必须全部已知,否则将无法编译通过动态生成class文件的Java代码(如反射等)。
③目前也仅仅支持64的Linux操作系统。