【JVM】JIT即时编译器的优化、JVM中基于解释+编译工作模式

解释与编译?

高级程序语言按照程序的执行方式分为解释与编译两种。

  • 解释型语言
    • 指解释器对源程序逐行解释,成为特定平台的机器码并立即执行。
    • 其执行效率不高,却有着好的移植性。
  • 编译型语言
    • 指编译器针对特定的操作系统,将源代码一次性翻译成可被该平台执行的机器码。
    • 最大的优势是执行速度快。

Java程序的运行过程

JVM是Java虚拟机,目的为在不同的平台上(Windows,macOs,Linux)使用相同的字节码,给出相同的执行结果。

  • 字节码
    在Java中,JVM可理解的代码就叫字节码(即 .class文件 )Java通过字节码的方式,既解决了传统解释型语言效率低的问题,又保留了可移植的特点。
    由于字节码并不针对一种特定的系统,所以Java可以实现"一次编译,到处执行"。其关键就是字节码、不同系统中JVM的实现。

一个Java程序的运行过程:
在这里插入图片描述

  1. 编译:.java文件一次性编译为.class字节码,所以说java是编译型语言
  2. .class转化为机器码:JVM中类加载器先加载字节码文件,然后:
    • 通过解释器逐行解释执行
    • 通过JIT及时编译器,将字节码编译为机器码,并保存下来下次可直接使用。
      所以也说java是编译与解释共存的语言。

为什么Java不直接编译成机器码呢?

  • 机器码是与操作系统相关的,如果编译成机器码那Java的一次编译到处运行的口号也没有了。
  • 之所以不一次性全部编译,是因为有些代码只需要运行一次,没有必要编译,可以直接解释执行。

JIT即时编译器

  • 即时编译器的运行条件?
    在解释执行的过程中,虚拟机同时对程序运行的数据进行收集,在这些信息的基础上,编译器会逐渐发挥作用,它会进行后端编译——把字节码编译成机器码,但不是所有的代码都会被编译,只有被JVM认定为的热点代码,才可能被编译。
  • 热点代码
    JVM中会设置一个阈值,当方法或者代码块的在一定时间内的调用次数超过这个阈值时就会被编译,存入codeCache中。当下次执行时,再遇到这段代码,就会从codeCache中读取机器码,直接执行,以此来提升程序运行的性能。
    • 被多次调用的方法
    • 被多次执行的循环体
  • 热点代码检测——热点探测的两种方式
    • 基于采样的热点探测
      虚拟机会周期性地检查各个线程的栈顶如果发现某些方法经常出现在栈顶,那这个方法就是“热点方法”。
      优点:实现简单高效,容易获取方法调用关系(将调用堆栈展开即可)
      缺点:不精确
    • 基于计数器的热点探测 (HotSpot使用这种方式)
      虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果次数超过一定的阈值就认为它是“热点方法”
      优点:统计结果精确严谨
      缺点:实现麻烦,需要为每个方法建立并维护计数器,不能直接获取到方法的调用关系
      在这里插入图片描述

JVM中集成了两种编译器,Client Compiler和Server Compiler:

  • Client Compiler
    HotSpot中的Client Compiler:C1编译器,注重启动速度和局部的优化。
  • Server Compiler
    HotSpot中默认的Server Compiler:C2编译器。这种编译器注重全局的优化,启动时间长,适用于长时间运行的后台程序。

JVM工作模式

解释器+编译器工作模式

目前主流的 HotSpot 虚拟机默认采用一个解释器 + 其中一个编译器直接配合的方式工作,程序使用哪个编译器,取决于虚拟机运行的模式。

  • 在 HotSpot 中,解释器和 JIT 即时编译器是同时存在的,他们是 JVM 的两个组件。
  • 对于不同类型的应用程序,用户可以根据自身的特点和需求,灵活选择是基于解释器运行还是基于 JIT 编译器运行。HotSpot 为用户提供了几种运行模式供选择,可通过参数设定,分别为:
    • 解释模式
    • 编译模式(并不是完通过 JIT 进行编译,只是优先采用编译方式执行程序,但是解释器仍然要在编译无法进行的情况下介入执行过程)
    • 混合模式,HotSpot 默认是混合模式
      在这里插入图片描述

为什么HotSpot要采用解释器与编译器共存的架构?

  • 解释器:当程序需要迅速启动和执行的时候,解释器可以首先发挥作用,省去编译的时间,立即执行。
  • 编译器:在程序运行后,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获取更高的执行效率。
  • 两者的协作:在程序运行环境中内存资源限制较大时,可以使用解释执行节约内存,反之可以使用编译执行来提升效率。当通过编译器优化时,发现并没有起到优化作用,可以通过逆优化退回到解释状态继续执行。

分层编译模式(1个解释器+2个编译器)

C1的编译速度更快,C2的编译质量更高,分层编译的不同编译路径,也就是JVM根据当前服务的运行情况来寻找当前服务的最佳平衡点的一个过程。从JDK 8开始,JVM默认开启分层编译。

1个解释器+2个编译器的模式将HotSpot的执行分为5个级别:

  1. 第0层:解释执行
  2. 第1层:执行不带Profiling的C1本地代码
  3. 第2层:执行带方法调用次数和回边调用次数Profiling的C1本地代码
  4. 第3层:执行所有Profiling的C1本地代码
  5. 第4层:执行C2本地代码
  • profiling就是收集能够反映程序执行状态的数据。其中最基本的统计数据就是方法的调用次数,以及循环回边的执行次数。
  • 通常情况下,C2代码的执行效率要比C1代码的高出30%以上。
  • C1层执行的代码,按执行效率排序从高至低则是1层>2层>3层。这5个层次中,1层和4层都是终止状态,当一个方法到达终止状态后,只要编译后的代码并没有失效,那么JVM就不会再次发出该方法的编译请求的。
  • 服务实际运行时,JVM会根据服务运行情况,从解释执行开始,选择不同的编译路径,直到到达终止状态。
    几种常见的编译路径:
    -
  • 图中第①条路径,代表编译的一般情况,热点方法从解释执行到被3层的C1编译,最后被4层的C2编译。
  • 如果方法比较小(比如Java服务中常见的getter/setter方法),3层的profiling没有收集到有价值的数据,JVM就会断定该方法对于C1代码和C2代码的执行效率相同,就会执行图中第②条路径。在这种情况下,JVM会在3层编译之后,放弃进入C2编译,直接选择用1层的C1编译运行。
  • 在C1忙碌的情况下,执行图中第③条路径,在解释执行过程中对程序进行profiling ,根据信息直接由第4层的C2编译。
  • 前文提到C1中的执行效率是1层>2层>3层,第3层一般要比第2层慢35%以上,所以在C2忙碌的情况下,执行图中第④条路径。这时方法会被2层的C1编译,然后再被3层的C1编译,以减少方法在3层的执行时间。
  • 如果编译器做了一些比较激进的优化,比如分支预测,在实际运行时发现预测出错,这时就会进行反优化,重新进入解释执行,图中第⑤条执行路径代表的就是反优化
    基本功 | Java即时编译器原理解析及实践

JIT编译优化

Java 程序员有一个共识,以编译方式执行本地代码比解释执行方式更快,之所以有这样的共识,除去虚拟机解释执行字节码时额外消耗时间的原因外,还有一个重要的原因就是虚拟机设计团队几乎把对代码的所有优化措施都集中在了即时编译器中,因此一般来说,即时编译器产生的本地代码会比 javac 产生的字节码更优秀。以下是具有代表性的 HotSpot 虚拟机的即时编译器在生成代码时采用的代码优化技术:

  1. 公共子表达式消除
    如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有的变量的值都没有发生变化,那么E的这次出现就成为了公共子表达式。对于这种表达式,没有必要花时间对它进行计算,只需要直接用前面计算过的表达式结果代替E就可以了。
  2. 数组边界检查消除
    如果有一个数组foo[],在Java语言中访问数组元素foo[i]时系统将自动进行上下界的范围检查,即检查i必须满足i>=0&&i<foo.length,否则将抛出异常。这对于开发者来说,即使没有编写专门的防御代码,也可以避免大部分的溢出攻击。但是对于虚拟机来说,每次数组元素的读写都带有一次隐含的条件判断操作。
    比如数组下标是一个常量foo[3],只要在编译期根据数据流分析来确定foo.length,并判断下标3没有越界,执行时就无需判断了。
    更常见的情况是数组访问发生在循环之中,并且用循环变量来访问数据,如果编译器只要通过数据流分析就可以判定循环变量的取值范围永远在[0,foo.length]之内,那么在整个循环中就可以把数组的上下界检查消除,可以节省很多次条件判断操作。
  3. 方法内联
    方法内联是编译器优化的最重要手段之一,它可以消除方法调用成本,更重要的意义是为其他优化手段建立良好的基础。其行为看起来很简单,不过是把目标方法的代码复制到发起调用的方法之中,避免发生真是的方法调用而已。但是由于java的动态性,编译器做内联的时候根本无法确定使用哪个方法版本,所以说在很多情况下,虚拟机进行的方法内联都是一种激进优化。
  4. 逃逸分析
    逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法引用,例如作为调用参数传递到其他方法中,成为方法逃逸。甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,成为线程逃逸。
    如果证明一个对象不会逃逸到方法或者线程之外,则可能对这个变量进行一些高效优化:
    • 栈上分配:将对象分配在方法栈中,随着栈帧出栈而销毁,垃圾收集的压力将会小很多。
    • 同步消除:如果能够确定一个变量不会逃逸出线程,那么这个变量的读写肯定不会有竞争,对这个变量实施的同步措施也就可以消除掉。
    • 标量替换:指把一个java对象拆散,根据程序访问情况,将其使用到的成员变量恢复原始类型来访问就叫标量替换。
      标量是指一个数据以及无法在分解为更小的数据来表示了,jvm中的原始数据类型(int、long等数值类型以及reference类型等),如果逃逸分析证明一个对象不会被外部访问,并且这个对象可拆散,那么程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干被这个方法使用到的成员变量来代替。

Java与C/C++的编译器对比,其劣势有:

  • 即时编译器运行占用的是用户程序的运行时间,具有很大的时间压力
  • Java是动态型的类型安全语言,意味着虚拟机必须频繁地进行动态检查,来确保程序不会违反语言语义或访问非结构化内存
  • Java语言虽然没有virtual关键字,但是使用虚方法的频率却远大于C/C++语言,这意味着即时编译器的优化难度将远大于C/C++的静态优化编译器
  • Java是可以动态扩展的语言,运行时加载的类可能改变程序类型的继承关系,这使得很多全局的优化难以进行

其优势有:

  • Java语言的这些性能劣势都是为了换取开发效率上的优势而付出的代价,动态安全、动态扩展、垃圾回收这些"拖后腿"的特性都为java语言的开发效率做出了很大贡献。
  • Java编译器另外一个红利是它的动态性带来的,由于C/C++编译器所有优化都在编译器完成,以运行期性能监控为基础的优化措施它都无法进行,如调用频率预测、分支频率预测、裁剪未被选择的分支等,这些都会成为Java语言独有的性能优势。
  • 1
    点赞
  • 1
    收藏
    觉得还不错? 一键收藏
  • 2
    评论
评论 2
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值