科普文:一文搞懂jvm原理(三)执行引擎

概叙

科普文:一文搞懂jvm(一)jvm概叙-CSDN博客

科普文:一文搞懂jvm原理(二)类加载器-CSDN博客

前面我们介绍了jvm,jvm主要包括两个子系统和两个组件: Class loader(类装载器) 子系统,Execution engine(执行引擎) 子系统;Runtime data area (运行时数据区域)组件, Native interface(本地接口)组件。

这里我们主要讲解Execution engine(执行引擎),讲到这个就和jvm优化相关。

图一:jvm的运行时区、执行引擎、本地方法接口和库

图二:这个是前面文中多次出现的jvm详细图,

执行引擎是Java虚拟机核心的组成部分之一,属于JVM的下层。

其中包括 解释器、及时编译器、垃圾回收器。

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

JVM的主要任务是负责装载字节码到其内部,但字节码并不能够直接运行在操作系统之上,因为字节码指令并非等价于本地机器指令,它内部包含的仅仅只是一些能够被JVM所识别的字节码指令、符号表,以及其他辅助信息。

那么,如果想要让一个Java程序运行起来,执行引擎(Execution Engine)的任务就是将字节码指令解释/编译为对应平台上的本地机器指令才可以。

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

为什么说Java是半编译半解释性语言?

JDK 1.0 时代,将 Java 语言定位为"解释执行"还是比较准确的,再后来, Java 也发展出可以直接生成本地代码的编译器。

现在 JVM 在执行 Java 代码的时候,通常都会将解释执行和编译执行二者结合起来进行。

  • HotSpot采用的是解释执行与即时编译器并存的架构。在Java虚拟机运行时,解释器和即时编译器能互相协作,尽量选择最适合的方式来权衡编译本地代码的时间和解释执行代码的时间。
  • 也可以使用参数设置只使用解释器或者编译器启动。

Java 代码编译和执行过程

大部分的程序代码转换成物理机的目标代码或虚拟机能执行的指令集之前,都需要经过下图中的各个步骤:

  • 橙色部分是生成字节码文件的过程,和JVM无关
  • 蓝色和绿色才是JVM需要考虑的过程

Java 代码编译是由 Java 源码编译器来完成,流程图如下所示:

Java 字节码的执行是由 JVM 执行引擎来完成,流程图如下所示:

jvm执行引擎Execution engine

JVM中的执行引擎是JVM的重要组成部分之一,主要负责将字节码指令翻译成机器码指令。

执行引擎的组成

执行引擎由三个组件组成:

  1. 解释器(Interpreter):就是运行时的"翻译者",将字节码解释为机器指令。
  2. 及时编译器(JIT:Just In Time Compiler):但是某些频繁执行的热点代码依然采用解释执行的话会导致程序执行很慢,JIT编译器则是负责将热点代码编译分层优化成本地机器码。
  3. 垃圾回收器(Garbager Collection):负责无用对象的销毁,释放内存空间。

1.解释器(Interpreter)

解释器就是一个运行时的"翻译者",将字节码指令翻译为对应平台的本地机器指令由CPU执行,当一条指令执行完成后再根据PC寄存器中记录的下一条指令执行解释操作。

JVM设计者们的初衷仅仅只是单纯地为了满足Java程序实现跨平台特性,因此避免采用静态编译的方式直接生成本地机器指令,从而诞生了解释器:在Java虚拟机启动时会根据预定义的规范对字节码采用逐行解释的方式执行,将每条字节码文件中的内容"翻译"为对应平台的本地机器指令执行。

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

在 Java 的发展历史里,一共有两套解释执行器,即古老的字节码解释器、现在普遍使用的模板解释器。

字节码解释器在执行时通过纯软件代码模拟字节码的执行,效率非常低下。而模版解释器将每一条字节码和一个模板函数相关联,模板函数中直接产生这条字节码执行时的机器码,从而很大程度上提高了解释器的性能。在 HotSpot VM 中,解释器主要由 Interpreter 模块和 Code 模块组成。

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

由于解释器在设计和实现上非常简单,因此除了 Java 语言之外,还有许多高级语言同样也是基于解释器执行的,比如 python 、Perl 、Ruby 等。但是在今天,基于解释器执行已经沦落为低效的代名词,并且时常被一些 C /C++ 程序员所调侃。

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

不过无论如何,基于解释器的执行模式仍然为中间语言的发展做出了不可磨灭的贡献。

2.及时编译器(JIT:Just In Time Compiler)

当执行某些频繁被调用的代码(比如for循环中的代码)时,如果按照解释执行,效率非常低,这种被频繁调用的代码成为热点代码。为了提高热点代码的执行效率,在运行时,JIT编译器则会将这些代码编译成与本地平台有关的机器码,并进行各种层次的优化。

及时编译器(Just In Time Compiler),就是虚拟机将源代码直接编译成和本地机器平台相关的机器语言。

由于解释器在设计和实现上非常简单,因此除了Java语言之外,还有许多高级语言同样也是基于解释器执行的,比如Python、 Perl、Ruby等。但是在今天,基于解释器执行已经沦落为低效的代名词,并且时常被一些C/C+ +程序员所调侃。

JIT的构成组件包括:

  1. 中间代码生成器------生成中间代码
  2. 代码优化器------优化中间代码
  3. 目标代码生成器------生成机器码或本地代码
  4. 分析器------负责查找热点代码

HotSpot VM 是目前市面上高性能虚拟机的代表作之一。它采用解释器与及时编辑器并行的结构。在 Java 虚拟机运行时,解释器和即时编译器能够相互协作,各自取长补短,尽力去选择最合适的方式来权衡编译本地代码的时间和直接解释执行代码的时间。

在今天, Java 程序的运行性能早已脱胎换骨,已经达到了可以和 C/C++ 程序一较高下的地步。

有些开发人员会感觉到诧异,既然 HotSpot VM 中已经内置了 JIT 编译器了,那么为什么还使用解释器来"拖累"程序的执行性能呢? 比如 JRockit VM 内部就不包含解释器,字节码全部都依靠即时编译器编译后执行。

首先明确:当程序启动后,解释器可以马上发挥作用,省去编译的时间,立即执行。编译器要想发挥作用,把代码编译成本地代码,需要一定的执行时间。但编译为本地代码后,执行效率高。所以:尽管 JRockit VM中程序的执行性能会非常的高效,但程序在启动时必然会花费更长的时间来进行编译。对于服务器来说,启动时间并非时关注重点,但对于那些看中启动时间的应用场景而言,或许就需要采用解释器与及时编译器并存的架构来换取一个平衡点。在此模式下,当 Java 虚拟机启动时,解释器可以首先发挥作用,而不必等待即时编译器全部编译完成后在执行,这样可以省去许多不必要的编译时间。随着时间的推移,编译器发挥作用,把越来越多的代码编译成本地代码,获得更高的执行效率。

同时,解释执行在编译器进行激进优化不成立的时候,作为编译器的"逃生门"。

概念解释:

Java 语言的"编译期"其实是一段 "不确定"的操作过程,因为他可能是指一个前端编译器(其实叫 "编译器的前端" 更准确一些)把 .java 文件转变成 .class 文件的过程;

也可能是指虚拟机的后端运行期编译器( JIT 编译器, Just In Time Compiler )把字节码转变成机器码的一个过程。

还可能是指使用静态提前编译器 ( AOT 编译器, Ahead Of Time Compiler ) 直接把 .java 文件编译成本地机器代码的过程。

JIT及时编译器分类

在Hotspot虚拟机中内置了两种JIT编译器:

C1编译器 :主要关注点在于局部性能优化,适用于执行时间短或对启动性能有要求的程序,如:GUI应用,C1编译器也被称为Client Compiler

C2编译器:为长期运行的服务端应用程序做性能优化的编译器,适用于执行时间较长或对峰值性能有要求的程序,C2编译器也被称为Server Compiler。

Graal编译器:用Java实现的JIT编译器,JDK10中引入(具体信息参考:https://openjdk.org/jeps/317 ),JDK17中移除(移除原因参考:https://openjdk.org/jeps/410

JIT的编译器还分为了两种,分别是C1和C2,在HotSpot VM中内嵌有两个JIT编译器,分别为Client Compiler和Server Compiler。但大多数情况下我们简称为C1编译器 和 C2编译器。开发人员可以通过如下命令显式指定Java虚拟机在运行时到底使用哪一种即时编译器。

  • client:指定Java虚拟机运行在Client模式下,并使用C1编译器;C1编译器会对字节码进行简单和可靠的优化,耗时短。以达到更快的编译速度。
  • server:指定Java虚拟机运行在server模式下,并使用C2编译器;C2进行耗时较长的优化,以及激进优化。但优化的代码执行效率更高。

在不同的编译器上有不同的优化策略,C1编译器上主要有方法内联,去虚拟化、冗余消除:

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

C2的优化主要是在全局层面,逃逸分析是优化的基础。基于逃逸分析在C2上有如下几种优化:

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

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

总的来说,C2编译器启动时长比C1慢,系统稳定执行以后,C2编译器执行速度远快于C1编译器。

分层编译

在Java7引入了分层编译,这种方式综合了C1的启动优势和C2的峰值性能优势。分层编译将JVM的执行状态分为5个层次:

第0层:程序解释执行,默认开启性能监控(Profiling),如果不开启,可触发第二层编译

第1层:C1编译,将字节码编译成本地代码,进行简单可靠的优化,不开启Profiling

第2层:C1编译,开启Profiling,仅执行带方法调用次数和循环回边执行次数Profiling的C1编译

第3层:C1编译,执行所有带Profiling的C1编译

第4层:C2编译,将字节码编译为本地代码,但会启用一下编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。

Java8中默认开启分层编译,-XX:-TieredCompilation 参数可关闭分层编译只使用C2编译,如果只使用C1编译可设置参数 -XX:TieredStopAtLevel=1

-Xint 参数可设置为强制解释器模式运行,-Xcomp可设置为强制运行JIT编译模式。

热点探测技术

关于编译器可大致分为两种:

  • 前端编译器:把 .java 文件转变成 .class 文件;
  • 后端编译器:把 .class 文件转变为 机器指令;

是否需要启动即时编译器将字节码转换为机器指令,则需要根据代码的调用频率而定。一个被多次调用的方法,或者是一个方法体内部循环次数较多的循环体都可以被称之为"热点代码",即时编译器在运行时会针对那些被频繁调用的热点代码做出深度优化,将其直接编译为本地的机器指令,以此来提升程序的性能。由于这种编译方式发生在方法的执行过程中,因此被称之为栈上替换,或简称为OSR(On Stack Replacement)编译。

一个方法究竟要被调用多少次,或者一个循环体究竟需要执行多少次循环才可以达到这个标准?必然需要一个明确的阈值,JIT编译器才会将这些"热点代码"编译为本地机器指令执行。这里主要依靠热点探测功能。

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

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

方法调用计数器

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

当某个方法调用次数达到阈值就会触发JIT编译优化,jinfo -flag CompileThreshold <pid> 或者 java -XX:+PrintFlagsFinal -version 命令可查看方法调用次数阈值

当一个方法被调用时,会先检查该方法是否存在被即时编译器编译过的版本,如果存在,则优先使用编译后的本地代码来执行。

如果不存在已被编译过的版本,则将此方法的调用计数器值加1,然后判断方法调用计数器与回边计数器值之和是否超过方法调用计数器的阀值。如果已超过阈值,那么将会向即时编译器提交一个该方法的代码编译请求;否则就通过解释器执行。

回边计数器

它的作用是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为"回边"(Back Edge)。

跟方法调用计数器搭配使用,如何两者相加总和超过计数器的阀值,那么就会除法即时编译器。显然,建立回边计数器统计的目的就是为了触发栈上替换编译。

用于统计方法体中循环体代码执行次数,字节码中遇到控制流向后跳转的指令称为"回边"(Back Edge),用于计算是否为热点代码的阈值。

计算公式如下:

回边计数器阈值 = 方法调用计数器阈值(CompileThreshold)*(OSR比率(OnStackReplacePercentage)-解释器监控比例(InterpreterProfilePercentage))/ 100

java -XX:+PrintFlagsFinal -version 命令可查看相关参数

根据图中显示的各参数的默认值可以计算出回边计数器阈值为:1000 * (140 - 33) = 10700

热点衰减

如果不做任何设置,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,可理解为一段时间之内方法被调用的次数。当超过一定的时间限度,如果方法的调用次数仍然不足以让它提交给即时编译器编译,那这个方法的调用计数器就会被减少一半,这个过程称为方法调用计数器热度的衰减,而这段时间就称为此方法统计的半衰周期。可以使用-XX:CounterHalfLifeTime参数设置半衰周期的时间,单位是秒。

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

如何选择编译器和解释器:

当然是否需要启动 JIT 编译器将字节码直接译为对应平台的本地机器指令,则需要根据代码被调用执行的频率而定。关于那些需要被编译为本地代码的字节码,也被称之为 "热点代码" JIT 编译器在运行时会针对那些频繁被调用的"热点代码"做出深度优化,将其直接编译为对应平台的本地机器指令,以此提升 Java 程序的性能。

缺省情况下 HotSpot VM 是采用解释器与即时编译器并存的架构,当然开发人员可以根据具体的应用场景,通过命令显示地为 Java 虚拟机指定在运行时到底是完全采用解释器执行,还是完全采用即时编译器执行。如下所示:

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

静态提前编译器

JDK9 引入了静态提前编译器(Ahead of Time Compiler)。Java 9引入了实验性AOT编译工具AOTC。它借助了Graal编译器,将所输入的Java类文件转换为机器码,并存放至生成的动态共享库之中。

静态提前编译器:,是直接把.java文件编译成机器指令。大致过程:.java -> .class -> (使用jaotc) -> .so。

所谓AOT编译,是与即时编译相对立的一个概念。即时编译指的是在程序的运行过程中,将字节码转换为可在硬件上直接运行的机器码,并部署至托管环境中的过程。而AOT编译指的则是,在程序运行之前,便将字节码转换为机器码的过程。

优点:

  • Java虚拟机加载已经预编译成二进制库,可以直接执行。不必等待及时编译器的预热,减少Java应用给人带来"第一次运行慢" 的不良体验;

缺点:

  • 破坏了 java " 一次编译,到处运行",必须为每个不同的硬件,OS编译对应的发行包;
  • 降低了Java链接过程的动态性,加载的代码在编译器就必须全部已知;
  • 还需要继续优化中,最初只支持Linux X64 java base;

Graal编译器

自JDK10起,HotSpot又加入了一个全新的及时编译器:Graal编译器,编译效果短短几年时间就追评了G2编译器,未来可期。

特点:

高效能运行 Java: 使用 GraalVM 执行 Java 程序可以变得更快;

多语言并行:可以在 Java 里面同时使用多种语言,像是 JavaScript等;

快速启动:直接把 Java 应用编译成机器码,执行起来体积更小、启动速度更快;

编译优化技术

1. 方法内联

方法内联的优化行为是将目标方法的代码复制到发起调用的方法中,避免真实方法调用。

    private int add(int a, int b, int c, int d) {
        return addInt1(a, b) + addInt2(c, d);
    }
​
    private int addInt1(int a, int b) {
        return a + b;
    }
​
    private int addInt2(int a, int b) {
        return a + b;
    }

例如上面的代码如果被检测为热点代码,则会被优化为以下代码:

    private int add(int a, int b, int c, int d) {
        return a + b + c + d;
    }

热点方法不一定都会被内联优化,只有当方法体大小小于参数 -XX:FreqInlineSize 值(默认325字节)才会进行内联,非热点方法当方法体小于参数 -XX:MaxInlineSize 值(默认35字节)才会进行内联。

  • 相关性能调优
    • 减小热点方法检测阈值,增加内联方法体阈值,缺点则是会增加内存占用
    • 尽量避免在一个方法体内写入大量代码,习惯使用小方法体
    • 尽量使用final private static 关键字修饰方法,代码优化时,因为继承需要额外的类型检查。
2. 锁消除

当方法中的局部方法中创建的对象只能被当前线程访问时,不存在锁竞争,JIT编译会对这个对象的方法进行锁消除。

参数 -XX:+EliminateLocks 可以开启锁消除(默认开启),-XX:-EliminateLocks则是关闭锁消除

3. 锁粗化

如果检测到同一个对象执行了连续的加锁和解锁操作,则会将这一系列操作合并成一个更大的锁,从而提升程序运行效率。

4. 逃逸分析

JIT编译器会对热点代码中的对象进行逃逸分析,分析该对象动态作用域,当被传递到其他方法中称为方法逃逸,当能被外部方法所引用则为线程逃逸。

不逃逸到方法逃逸再到线程逃逸,逃逸程度由低到高。

逃逸分析可以通过参数 -XX:+DoEscapeAnalysis 开启(jdk1.8默认开启),或 -XX:-DoEscapeAnalysis 关闭。

关闭逃逸分析会导致对象分配到堆中,频繁触发垃圾回收导致代码运行慢。

5. 标量替换

当确定对象不会逃逸出线程之外,该对象则会被分配到栈上,对象分配到栈需要进行成员变量拆分,这种优化技术叫做标量替换。标量替换需要开启逃逸分析。

标量替换可以通过参数 -XX:+EliminateAllocations 开启(jdk1.8默认开启),或***-XX:+EliminateAllocations***关闭

3.垃圾回收器(Garbager Collection)

程序计数器、虚拟机栈、本地方法栈随线程而生,也随线程而灭;栈帧随着方法的开始而入栈,随着方法的结束而出栈。这几个区域的内存分配和回收都具有确定性,在这几个区域内不需要过多考虑回收的问题,因为方法结束或者线程结束时,内存自然就跟随着回收了。

垃圾收集器(Garbage Collection,简称:GC), GC主要用于Java堆的管理。Java 中的堆是 JVM 所管理的最大的一块内存空间,主要用于存放各种类的实例对象。

在系统运行期间,会产生大量的对象实例,对于一些对象实例用完后已经没有引用指向(对象已无法访问),这些对象属于内存垃圾,为了能够回收没用的对象,就诞生了垃圾回收器。

既然JVM种有垃圾收集器,为什么我们还要去了解垃圾收集和内存分配呢?

答案很简单:当需要排查各种内存溢出,内存泄露问题时,当垃圾收集成为系统达到更高并发量的瓶颈时,我们必须对这些进行监控和调节。

关于垃圾回收器,在讲完"jvm运行时区"后,再单独详细描述。

判断对象是否存活

引用计数法

实现原理:为每个对象头维护一个独立的 counter 计数器,当对象被引用时加1,取消引用时减1。当计数器为 0 时,对象就是不可能再被使用的。

引用计数法虽然会占用一些额外的内存空间来计数,但它的实现原理简单,效率也很高,Java虚拟没有选用计数算法来管理内存,主要原因是引用计数算法存在对象之间循环引用的问题。

下图就是循环引用的例子


可达性分析法

Java使用的是可达性分析算法来判断对象是否可以被回收。可达性分析将对象分为两类:垃圾回收的根对象(GC Roots)和普通对象,对象与对象之间存在引用关系。

实现原理:下图中A到B再到C和D,形成了一个引用链,可达性分析算法指的是如果从某个对象到GC Roots对象是可达的,对象就不可被回收。如果某个对象到GC Roots间没有任何引用链相连,那么就可以判定对象是不再使用。


固定可作为GC Roots对象包括以下几种:

  1. 虚拟机栈(栈帧中的本地变量表)中引用对象,比如方法中使用到的参数、局部变量、临时变量等。
  2. 方法区中静态属性、常量引用的对象。
  3. 本地方法(Native 方法)栈引用的对象。
  4. Java虚拟机内部引用的对象,如基本数据类型对应的Clas对象。
  5. 所有被同步锁(Synhronized关键字)持有的对象。
  6. 当前活动线程正在运行的线程对象。

等等...

GC Roots 并不包括堆中对象所引用的对象,这样就不会有循环引用的问题。

常见的几种对象引用

可达性算法中描述的对象引用,一般指的是强引用,即是GCRoot对象对普通对象有引用关系,只要这层关系存在,普通对象就不会被回收。除了强引用之外,Java中还设计了几种其他引用方式:

  1. 强引用:类似 "Object obj = new Object()" 这类的引用,就是强引用,只要强引用存在,垃圾收集器永远不会回收被引用的对象。
  2. 软引用:软引用相对于强引用是一种比较弱的引用关系,如果一个对象只有软引用关联到它,当程序内存不足时,就会将软引用中的数据进行回收。
  3. 弱引用:弱引用的强度比软引用更弱一些,弱引用包含的对象在垃圾回收时,不管内存够不够都会直接被回收。
  4. 虚引用:最弱的一种引用关系,虚引用唯一的用途是当对象被垃圾回收器回收时可以接收到对应的通知。常规开发中是不会使用的。

垃圾收集算法

垃圾回收的核心算法:释放不再存活对象的内存,使得程序能再次利用这部分空间。下图为垃圾收集的四种算法

相关推荐
鱼跃鹰飞7 小时前
大厂面试真题-简单说说线程池接到新任务之后的操作流程
java·jvm·面试
王佑辉7 小时前
【jvm】Major GC
jvm
阿维的博客日记7 小时前
jvm学习笔记-轻量级锁内存模型
jvm·cas·轻量级锁
曹申阳11 小时前
2. JVM的架构模型和生命周期
jvm·架构
琪露诺大湿12 小时前
JavaEE-多线程初阶(4)
java·开发语言·jvm·java-ee·基础·1024程序员节·原神
王佑辉14 小时前
【jvm】Full GC
jvm
九鼎科技-Leo14 小时前
C# 内存管理与对象生命周期在面向对象设计中的重要性
jvm·c#
王佑辉1 天前
【jvm】堆空间分代思想
jvm
为啥不能修改昵称啊1 天前
静态数据区,堆,栈
java·jvm·算法
救苦救难韩天尊2 天前
《JVM第7课》堆区
jvm