浅谈Java JIT编译器概念

Posted 默辨

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了浅谈Java JIT编译器概念相关的知识,希望对你有一定的参考价值。

文章目录




一、解释器

Java程序在运行的时候,主要就是执行字节码指令,一般这些指令会按照顺序解释执行,这种就是解释执行。



但是那些被频繁调用的代码,比如调用次数很高或者在 for 循环里的那些代码,如果按照解释执行,效率是非常低的。

以上的这些代码称为热点代码。所以,为了提高热点代码的执行效率,在运行时,虚拟机将会把这些代码编译成与本地平台相关的机器码,并进行各种层次的优化。





二、编译器

完成这个任务的编译器,就称为即时编译器(Just In Time Compiler),简称 JIT 编译器。

1、传统编译器

在JDK1.8中 HotSpot 虚拟机中,内置了两个 JIT,分别为 C1 编译器和 C2 编译器。

1)C1编译器

C1 编译器是一个简单快速的编译器,主要的关注点在于局部性的优化,适用于执行时间较短或对启动性能有要求的程序,例如,GUI 应用对界面启动速度就有一定要求,C1也被称为 Client Compiler。

C1编译器几乎不会对代码进行优化


2)C2编译器

C2 编译器是为长期运行的服务器端应用程序做性能调优的编译器,适用于执行时间较长或对峰值性能有要求的程序。根据各自的适配性,这种即时编译也被称为Server Compiler。

但是C2代码已超级复杂,无人能维护!所以才会开发Java编写的Graal编译器取代C2(JDK10开始)

3)分层编译

在 Java7之前,需要根据程序的特性来选择对应的 JIT,虚拟机默认采用解释器和其中一个编译器配合工作。

Java7及以后引入了分层编译,这种方式综合了 C1 的启动性能优势和 C2 的峰值性能优势,当然我们也可以通过参数强制指定虚拟机的即时编译模式。

在 Java8 中,默认开启分层编译。

  1. 通过java -version 命令行可以直接查看到当前系统使用的编译模式(默认分层编译)

  1. 使用-Xint参数强制虚拟机运行于只有解释器的编译模式

  1. 使用-Xcomp强制虚拟机运行于只有 JIT的编译模式下



2、GraalVM

GraalVM 是一个高性能 JDK 发行版,旨在加速用Java和其他JVM语言编写的应用程序的执行,并支持 javascript、Ruby、Python 和许多其他流行语言。

Graal Compiler是GraalVM与HotSpotVM(从JDK10起)共同拥有的服务端即时编译器,是C2编译器的替代者。


1)Graal 和 C2 的区别

Graal 和 C2 最为明显的一个区别是:Graal 是用 Java 写的,而 C2 是用 C++ 写的。相对来说,Graal 更加模块化,也更容易开发与维护,毕竟,连C2的开发者都不想去维护C2了。

许多人会觉得用 C++ 写的 C2 肯定要比 Graal 快。实际上,在充分预热的情况下,Java 程序中的热点代码早已经通过即时编译转换为二进制码,在执行速度上并不亚于静态编译的 C++ 程序。

Graal 的内联算法对新语法、新语言更加友好,例如 Java 8 的 lambda 表达式以及 Scala 语言。


2)JVMCI

前文解释过,编译器是 Java 虚拟机中相对独立的模块,它主要负责接收 Java 字节码,并生成可以直接运行的二进制码。

传统情况下(JDK8),即时编译器是与 Java 虚拟机紧耦合的。也就是说,对即时编译器的更改需要重新编译整个 Java 虚拟机。这对于开发相对活跃的 Graal 来说显然是不可接受的。

为了让 Java 虚拟机与 Graal 解耦合,我们引入了Java 虚拟机编译器接口(JVM Compiler Interface,JVMCI),将即时编译器的功能抽象成一个 Java 层面的接口。这样一来,在 Graal 所依赖的 JVMCI 版本不变的情况下,我们仅需要替换 Graal 编译器相关的 jar 包(Java 9 以后的 jmod 文件),便可完成对 Graal 的升级


3)AOT

Ahead-of-time compile(aot,提前编译),他在编译期时,会把所有相关的东西,包含一个基底的 VM,一起编译成机器码(二进制)。

graal 的 aot 属于“GraalVM ”中的一项技术,好处是可以更快速的启动一个 java 应用(以往如果要启动 java程序,需要先启动 jvm 再载入 java 代码,然后再即时的将 .class 字节码编译成机器码,交给机器执行,非常耗时间和耗内存,而如果使用AOT,可以取得一个更小更快速的镜像,适合用在云部署上)


4)特点

GraalVM是一款高性能的可嵌入式多语言虚拟机,它能运行不同的编程语言

  • 基于JVM的语言,比如Java, Scala, Kotlin和Groovy
  • 解释型语言,比如JavaScript, Ruby, R和Python
  • 配合LLVM一起工作的原生语言,比如C, C++, Rust和Swift

GraalVM的设计目标是可以在不同的环境中运行程序

  • 在JVM中
  • 编译成独立的本地镜像(不需要JDK环境)
  • 将Java及本地代码模块集成为更大型的应用





三、热点代码

热点代码,就是那些被频繁调用的代码,比如调用次数很高或者在 for 循环里的那些代码。这些再次编译后的机器码会被缓存起来,以备下次使用,但对于那些执行次数很少的代码来说,这种编译动作就纯属浪费。

JVM提供了一个参数“-XX:ReservedCodeCacheSize”,用来限制 CodeCache 的大小。也就是说,JIT 编译后的代码都会放在 CodeCache 里,默认大小240M。

如果这个空间不足,JIT 就无法继续编译,编译执行会变成解释执行,性能会降低一个数量级。同时,JIT 编译器会一直尝试去优化代码,从而造成了 CPU 占用上升。

通过 java -XX:+PrintFlagsFinal –version查询:


1)热点探测

在 HotSpot 虚拟机中的热点探测是 JIT 优化的条件,热点探测是基于计数器的热点探测,采用这种方法的虚拟机会为每个方法建立计数器统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法”

虚拟机为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阈值,当计数器超过阈值溢出了,就会触发 JIT 编译。


2)方法调用计数器

用于统计方法被调用的次数,方法调用计数器的默认阈值在客户端模式下是 1500 次,在服务端模式下是 10000 次(我们用的都是服务端,java –version查询),可通过 -XX: CompileThreshold 来设定

通过 java -XX:+PrintFlagsFinal –version查询





四、编译器优化技术

JIT 编译运用了一些经典的编译优化技术来实现代码的优化,即通过一些例行检查优化,可以智能地编译出运行时的最优性能代码.

1、方法内联

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

例如以下方法:

最终会被优化为:



JVM 会自动识别热点方法,并对它们使用方法内联进行优化。

我们可以通过 -XX:CompileThreshold 来设置热点方法的阈值。

但要强调一点,热点方法不一定会被 JVM 做内联优化,如果这个方法体太大了,JVM 将不执行内联操作。

而方法体的大小阈值,我们也可以通过参数设置来优化:

经常执行的方法,默认情况下,方法体大小小于 325 字节的都会进行内联,我们可以通过 -XX:FreqInlineSize=N 来设置大小值;

不是经常执行的方法,默认情况下,方法大小小于 35 字节才会进行内联,我们也可以通过 -XX:MaxInlineSize=N 来重置大小值。


即方法大小满足:FreqInlineSize(默认325字节) > 方法大小 > MaxInlineSize(默认35字节),时才会触发触发方法内联优化


热点方法的优化可以有效提高系统性能,一般我们可以通过以下几种方式来提高方法内联:

  • 通过设置 JVM 参数来减小热点阈值或增加方法体阈值,以便更多的方法可以进行内联,但这种方法意味着需要占用更多地内存;
  • 在编程中,避免在一个方法中写大量代码,习惯使用小方法体;
  • 尽量使用 final、private、static 关键字修饰方法,编码方法因为继承,会需要额外的类型检查。

2、锁消除

在非线程安全的情况下,尽量不要使用线程安全容器,比如 StringBuffer。由于 StringBuffer 中的 append 方法被 Synchronized 关键字修饰,会使用到锁,从而导致性能下降。

但实际上,在以下代码测试中,StringBuffer 和 StringBuilder 的性能基本没什么区别。这是因为在局部方法中创建的对象只能被当前线程访问,无法被其它线程访问,这个变量的读写肯定不会有竞争,这个时候 JIT 编译会对这个对象的方法锁进行锁消除。

在下面的测试代码中,StringBuffer 和 StringBuilder 的性能基本没什么区别。这是因为在局部方法中创建的对象只能被当前线程访问,无法被其它线程访问,这个变量的读写肯定不会有竞争,这个时候 JIT 编译会对这个对象的方法锁进行锁消除。


把锁消除关闭—测试发现性能差别有点大

-XX:+EliminateLocks开启锁消除(jdk1.8默认开启,其它版本未测试)

-XX:-EliminateLocks 关闭锁消除




3、标量替换

逃逸分析证明一个对象不会被外部访问,如果这个对象可以被拆分的话,当程序真正执行的时候可能不创建这个对象,而直接创建它的成员变量来代替。将对象拆分后,可以分配对象的成员变量在栈或寄存器上,原本的对象就无需分配内存空间了。这种编译优化就叫做标量替换(前提是需要开启逃逸分析)。

-XX:+DoEscapeAnalysis开启逃逸分析(jdk1.8默认开启)

-XX:-DoEscapeAnalysis 关闭逃逸分析

-XX:+EliminateAllocations开启标量替换(jdk1.8默认开启)

-XX:-EliminateAllocations 关闭标量替换



4、逃逸分析

逃逸分析的原理:分析对象动态作用域,当一个对象在方法中定义后,它可能被外部方法所引用。

比如:调用参数传递到其他方法中,这种称之为方法逃逸。甚至还有可能被外部线程访问到,例如:赋值给其他线程中访问的变量,这个称之为线程逃逸。

从不逃逸到方法逃逸到线程逃逸,称之为对象由低到高的不同逃逸程度。

如果确定一个对象不会逃逸出线程之外,那么让对象在栈上分配内存可以提高JVM的效率。

当然逃逸分析技术属于JIT的优化技术,所以必须要符合热点代码,JIT才会优化,另外对象如果要分配到栈上,需要将对象拆分,这种编译优化就叫做标量替换技术。



如果是逃逸分析出来的对象可以在栈上分配的话,那么该对象的生命周期就跟随线程了,就不需要垃圾回收,如果是频繁的调用此方法则可以得到很大的性能提高。

采用了逃逸分析后,满足逃逸的对象在栈上分配

没有开启逃逸分析,对象都在堆上分配,会频繁触发垃圾回收(垃圾回收会影响系统性能),导致代码运行慢


最终可总结为下图:

  1. 要创建一个对象的时候,会判断该对象是否为热点代码
  2. 如果不是直接创建到堆中(不考虑对象过大,以及后续堆中的动态年龄判定、大小判定等情况)
  3. 如果时热点代码(服务端执行次数超过设置的10000次),则判定是否开启逃逸分析以及能否逃逸
  4. 如果没有开启逃逸分析或者是不满足逃逸分析的条件,则同第二步的执行流程
  5. 初次之外的其他情况,则继续判定是否开启了标量替换
  6. 如果没有开启标量替换,则同第二步的执行流程
  7. 如果开启了标量替换,则将该对象分配在堆中

以上是关于浅谈Java JIT编译器概念的主要内容,如果未能解决你的问题,请参考以下文章

技术浅谈解释器与JIT编译器的功能

浅谈JIT编译器

浅谈对JIT编译器的理解

浅谈对JIT编译器的理解。

JVM之JIT即时编译

深入Java虚拟机之七:Javac编译与JIT编译