Java - JIT即时编译器

前言

我们知道,Java有着“一次编译,处处运行”的特性,会将编译阶段分成两部分:

  1. 前端编译:由javac编译成字节码,该过程会进行词法分析、语法分析、语义分析。
  2. 后端编译:由解释器字节码解释为机器码来执行。

而后端编译又可以根据执行的方式来分为两种:

  1. 解释执行:一行一行解释成机器码再执行,每次调用时都需要重新逐条解释执行。
  2. 编译执行JIT(Just In Time):将执行的比较多的热点代码编译优化成本地代码,提高执行效率。(好比加了缓存)

热点代码

当方法或者代码块在一定时间内的调用次数超过JVM规定的阈值,则会被编译,存入codeCache中。

codeCache

代码缓存区,主要存放JIT所编译的代码,同时还有Java所使用的本地方法代码也会存储在codecache中,例如Object.wait(),Object.notify()等被native修饰的都是所谓的本地方法。

Java编译的整个过程为:
在这里插入图片描述

一. JVM编译器

JVM有两种编译器:

  • Client Compiler(C1编译模式):着重于启动速度局部优化
  • Server Compiler(C2编译模式):着重于全局优化性能更好,但是启动速度慢。

对于HotSpot而言,一共有三种编译模式:

  • 混合模式(Mixed Mode):C1和C2编译模式混合起来使用(默认的模式),若希望单独使用C1编译模式或者C2编译模式,分别使用-client-server参数来打开。
  • 解释模式(Interpreted Mode)
  • 编译模式(Compiled Mode)

输入命令java -vsersion可以查看当前的编译模式:
在这里插入图片描述

1.1 Client Compiler

HotSpot VM中的Client Compiler为C1编译器启动速度快,主要做三件事情:

  • 局部优化,例如字节码上的基础优化、方法内联、常量传播。
  • 将字节码构造成高级中间表示(HIR)。
  • 将HIR转换成低级中间表示(LIR),在LIR基础上进行局部优化,生成机器码。

1.2 Server Compiler

主要关注一些编译比较耗时的全局优化,适用于长时间运行的后台程序,其性能一般比Client Compiler要高30%以上,Server Compiler有两种。

1.2.1 C2 Compiler

C2是Hotspot的默认Server编译器,C2使用一种控制流和数据流相结合的图数据结构(Ideal Graph,后面简称IG),其表示当前程序的数据流向和指令间的依赖关系。而C2则通过这种图结构来优化步骤。

1.2.2 Graal Compiler

启用方式:

-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler

与C2编译器相比而言,Graal有这么几个关键特性:

  1. 由Java编写,对于lambda表达式、stream流的使用要更加友好。
  2. 会做虚函数的内联部分逃逸分析等更深层次的优化。
  3. 对于代码的分支预测和选择优化要更好。

总结就是:
Java执行过程有两步:

  1. 前端编译:由javac编译成字节码。
  2. 后端编译:由解释器字节码解释为机器码来执行。

后端编译有两种执行方式:

  1. 解释执行:一行一行解释成机器码再执行,每次调用时都需要重新逐条解释执行。
  2. 编译执行JIT(Just In Time):将热点代码编译优化成codeCache

JVM虚拟机有两种编译器:Client / Server Compiler

比较项Client CompilerServer Compiler
启动速度
包含的编译器C1C2,Graal
性能一般高30%
主要的工作字节码优化、方法内联。将字节码转HIR,再转LIR主要关注一些编译耗时较长的全局优化,甚至会还会根据程序运行的信息进行一些不可靠的激进优化
优化范围局部优化全局优化

二. JIT和编译优化

JIT的触发时机:JVM根据某个方法的调用次数和循环回边的执行次数来触发。当两者的次数和超过-XX:CompileThreshold指定的阈值,则触发JIT编译。

C1的默认阈值为1500.
C2的默认阈值为10000.

JIT在进行编译的时候,会做一些优化操作。

2.1 中间表达形式(IR)

首先,编译的阶段由上文我们知道分为了:

  • 前端:通过词法分析、语法分析、语义分析生成中间表达形式(IR),例如字节码。
  • 后端:对IR进行优化,生成目标代码。

那么这些IR优化一般有哪些内容呢?

  • 识别冗余赋值。
  • 删除无用代码。

2.2 方法内联

方法内联的含义:在编译过程中遇到方法调用时,将目标方法的方法体纳入编译范围之中,并取代原方法调用的优化手段。 并且JIT大部分的优化都是在内联的基础上进行的。

get/set为例,区别如下:

  • 无方法内联:调用时,程序执行时需要保存当前方法的执行位置,创建并压入用于getter/setter的栈帧访问字段弹出栈帧,最后再恢复当前方法的执行
  • 有方法内联:程序执行到对应的位置时,直接进行字段访问即可。

方法内联的优劣势:

  • 优势:内联的方法越多,生成代码的执行效率越高。
  • 劣势:内联的方法越多,JIT编译时间也就越长

2.3 逃逸分析

逃逸分析可以分析在程序的哪些地方可以访问到指针,JIT会对新建的对象进行逃逸分析,判断对象是否逃逸出线程或者方法。 判断对象是否逃逸的依据有两种:

  • 对象是否存入堆中,若存入,则其他线程便能获得这个对象的引用。
  • 对象是否被传入未知代码(未被内联的代码)中,此时可认为方法调用的调用者以及参数是逃逸的。

2.4 Loop Transformations

JIT编译器会对循环做一些转换,其中最重要的是循环展开和循环分离。

循环展开:以牺牲程序二进制码大小为代价来优化程序的执行速度,通过减少或消除控制程序循环的指令,来减少计算开销(空间换时间)。

例如:

public void test(){
  for(int i = 0;i<100;i++){
    run(i);  
  }
}

经过循环展开后得到:

public void test(){
  for(int i = 0;i<100;i+=5){
    run(i);  
    run(i+1);  
    run(i+2);  
    run(i+3);  
    run(i+4);  
  }
}

效果:减少循环次数


循环分离:把循环中一次或多次的特殊迭代分离出来,在循环外执行。

例如:
原本的代码如下:

int a = 10;
for(int i = 0;i<10;i++){
  b[i] = x[i] + x[a];
  a = i;
}

经过循环分离后:(将特殊情况第一次a=10分离了出来)

b[0] = x[0] + 10;
for(int i = 1;i<10;i++){
  b[i] = x[i] + x[i-1];
}

2.5 窥孔优化与寄存器分配

窥孔优化作为最后一步JIT优化。将编译器所生成的中间代码的某些组合替换为效率更高的指令组。

例如:

y=x*3 

变成:(移位的效率更高)

y=(x<<1)+x

寄存器分配(C2):把频繁使用的变量保存在寄存器中,CPU访问寄存器的速度比内存快得多,就可以提升程序的运行速度。

在窥孔优化和寄存器分配结束后,程序就会被转换成机器码保存到codecache中了。

参考:(详细介绍的请看第一篇文章)

  • 1
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Zong_0915

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

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

余额充值