只是不愿随波逐流 ...|

lidongdongdong~

园龄:2年6个月粉丝:14关注:8

56、JIT 编译

内容来自王争 Java 编程之美

上一节中我们概述了 Java 编译执行的整个过程,其中包括:前端编译、类加载、解释执行、JIT 编译、AOT 编译
本节我们详细讲解其中的 JIT 编译,尽管 JIT 编译相关的知识比较偏底层,但对于程序员来说也并非完全没有感知

  • 比如在做性能测试时,我们经常会发现,循环多次执行被测代码时,被测代码的执行速度会变快
  • 这是因为前几次代码的执行为解释执行,但循环执行多次之后,虚拟机便发现代码为热点代码,就会启动 JIT 编译
    在进行编译的同时进行编译优化,将字节码编译为高效的机器码
    之后执行代码便直接执行高效的机器码,而非解释执行字节码,因此代码的执行速度就变快了

实际上 JIT 编译过程会涉及非常多的细节内容,这其中就包括:JIT 编译器、分层编译、热点探测、编译优化,接下来我们就详细讲解一下 JIT 编译的这 4 部分内容

1、JIT 编译器

HotSpot 虚拟机支持两种 JIT 编译器:Client 编译器和 Server 编译器,其中 Client 编译器也叫做 C1 编译器,Server 编译器也叫做 C2 编译器
两种编译器的区别在于编译时间和编译优化程度有所不同,编译时间和编译优化程度成反比,编译优化程度越高,最终生成的机器码就越高效,当然也要付出更多的编译时间作为代价

  • Client 编译器只进行局部的编译优化,是一种编译时间短、编译优化程度低的编译器
  • Server 编译器进行局部和全局的编译优化,是一种编译时间长、编译优化程度高的编译器

局部优化只关注局部的代码,分析局部是否有值得优化的地方,全局优化关注全局的代码,综合更多的信息,分析代码是否有大的值得优化的地方
这就有点类似《设计模式之美》中讲到的小重构和大重构
小重构针对局部的函数、类进行改动,耗时比较少,而大重构针对全局的代码结构进行改动,耗时比较多,对代码质量的提高也更加显著

我们常说 JVM 有两种运行模式:Client 模式和 Server 模式,实际上这两种运行模式就是基于 JIT 编译器类型来区分的
在 Java 7 及其以前版本中,我们可以通过 -client 或 -server 这两个 VM 参数来指定 JIT 编译器的类型
对于长时间运行的程序,我们可以牺牲一些编译时间,生成一些更加高效的机器码
因此服务器一般选择 Server 编译器,对应 Server 工作模式,相反客户端一般选择 Client 编译器,对应 Client 工作模式

2、分层编译

在 Java 7 之前,虚拟机要么选择 Client 编译器,要么选择 Server 编译器,两种编译器无法同时使用
为了解决这个问题,Java 7 引入了分层编译的技术
对编译类型做了更加细化的区分,能够让虚拟机根据不同代码、不同时刻的实际运行情况,选择不同的编译类型,编译工作更加精细化和有针对性

分层编译主要分为以下 5 个层级,我们只需要大概知道有这么一回事即可,不需要深入研究各个层级到底都做了哪些编译优化

  • 第一级:解释执行
  • 第二级:使用不带编译优化的 Client 编译器
  • 第三级:使用仅带部分编译优化的 Client 编译器
  • 第四级:使用带有所有编译优化的 Client 编译器
  • 第五级:使用 Server 编译器

分层编译技术在 Java 7 中并不是特别成熟,因此 JVM 默认是不开启的,我们需要使用 -XX:+TieredCompilation 参数来开启分层编译
随着技术演进,分层编译技术在 Java 8 中变得稳定成熟,因此 JVM 默认开启分层编译技术,并且无论开启还是关闭分层编译,-client 和 -server 参数都不再有效
当分层编译技术被关闭时,JVM 直接选择使用 Server 编译器

3、热点探测

前面提到只有当代码多次运行,被判定为热点代码时,JVM 才会触发 JIT 编译,那么具体什么样的代码才是热点代码呢?

  • 被多次执行的方法
  • 被多次执行的循环

需要注意的是,JIT 编译的对象是方法,对于 "被多次执行的循环",编译器对包含这个循环的方法进行编译,而非只编译循环这一小部分代码

HotSpot 虚拟机使用计数器来统计方法或循环的执行次数,以此来判断方法或循环是否是热点代码,JVM 对每个方法维护两个计数器:方法调用计数器和回边计数器

  • 方法调用计数器用来统计:方法的执行次数
  • 回边计数器用来统计:方法内循环的执行次数

当某个方法的方法调用计数器的值和回边计数器的值的总和超过某个阈值时,虚拟机就会对这个方法进行 JIT 编译

  • 在 Client 编译器下,这个阈值默认为 1500
  • 在 Server 编译器下,这个阈值默认为 10000
  • 我们也可以通过参数 -XX:CompileThreshold 指定阈值
    如果虚拟机开启了分层编译,那么虚拟机不再使用以上固定的阈值,转而使用动态阈值
    动态阈值根据当前编译方法数以及编译线程数动态计算得到

从上述描述我们可以发现,随着运行时间的增长,只要某个代码一直在执行,就总是会出现调用次数高于阈值的那一刻
这就会导致一些不怎么频繁运行的代码也会被判定为热点代码,为了解决这个问题,虚拟机引入了 "热度衰减机制"
在超过一定的时间限制之后(通过参数 -XX:CounterHalfLifeTime 来设置),如果某个方法没有达到触发 JIT 编译的阈值要求,那么这个方法的方法计数器的值就减半
我们可以通过设置 -XX:-UseCounterDecay 来关闭热度衰减机制,需要注意的是,只有方法计数器存在热度衰减机制,回边计数器不存在热度衰减机制

4、编译优化

不管是前端编译、JIT 编译、还是 AOT 编译,编译器要做的工作除了基本的编译之外,还有另外一项非常关键的工作,那就是编译优化
编译优化:编译器在编译代码时,对代码进行优化,减少无效、冗余代码,以便生成更加高效的机器码,在一定程度上,编译优化的质量决定了编译器是否优秀、编程语言是否高效

JIT 编译的编译优化策略有很多,比如:经典的方法内联、逃逸分析、无用代码消除、循环展开、消除公共子表达式、范围检查消除、空值检查消除等等
在这个网页中罗列了 HotSpot 虚拟机中的 JIT 编译器所用到的编译优化策略
对于这些编译优化策略,我们没有必要去深入研究,毕竟编译优化对于大部分程序员来说都是黑盒子,没有感知
不过为了让你对编译优化策略有一些直观的认识,我们拿其中的方法内联和逃逸分析作为示例来讲解

4.1、方法内联

在第 2 节中,讲到函数调用的底层原理时我们讲到:函数调用会涉及到栈帧的压栈、出栈,现场的保存和恢复,相对于普通的代码执行要慢很多
为了减少函数调用以提高代码执行效率,对于一些比较短小的函数,比如 getter、setter 函数,虚拟机在编译代码时,直接将这类函数代码嵌入到函数调用处
这会导致同一份函数代码被复制到各个地方,内存消耗增多,因此这是一种空间换时间的优化手段

public int getArrayMax(List<Integer> list) {
int maxVal = Integer.MIN_VALUE;
for (Integer data : list) {
maxVal = max(maxVal, data);
}
return maxVal;
}
public int max(int a, int b) {
return a >= b ? a : b;
}
// max() 函数内联到 getArrayMax() 函数
public int getArrayMax(List<Integer> list) {
int maxVal = Integer.MIN_VALUE;
for (Integer data : list) {
maxVal = (maxVal >= data ? maxVal : data);
}
return maxVal;
}

并不是所有的函数都可以被内联的,除了刚刚讲到的函数比较短小的要求之外,函数的调用次数也要达到一定阈值要求

  • 如果方法调用次数多(默认 >= 100 次),默认情况下,方法的字节码大小 < 325 字节就会进行内联
  • 如果方法调用次数少(< 100次),默认情况下,方法的字节码大小 < 35 字节才会进行内联

除了可以减少函数调用之外,应用方法内联还可以为其他编译优化打基础,如下示例代码所示

  • 在没有将 print() 函数内联到 doLogic() 函数之前,我们无法对 print() 函数进行优化,doLogic() 调用 print() 函数,仍然需要执行分支判断语句
  • 在将 print() 函数内联到 doLogic() 函数之后,因为 type 值明确为 1
    那么编译器便可以触发无用代码删除这一编译优化,将其他无用代码分支删除,只保留一条 print 语句
public void doLogic() {
//....
print("wangzheng", 1);
//...
}
public void print(String rawStr, int type) {
if (type == 1) {
System.out.println("hey " + rawStr);
} else if (type == 2) {
System.out.println("hi " + rawStr);
} else {
System.out.println("hello " + rawStr);
}
}
// print() 函数内联到 doLogic() 函数, 并做优化, 移除无用的代码分支
public void doLogic() {
//....
System.out.println("hey wangzheng");
//...
}

在平时的开发中我们经常会听到这样的说法,将方法设置为 final 会触发方法内联,实际上这样的说法是不对
但是将方法设置为 final 确实有助于触发方法内联,特别是在应用多态的情况下,我们举例解释一下,示例代码如下所示

public void func(B b) {
//...
b.f();
//...
}

基于 Java 中多态的运行原理,b.f() 执行的是 b 所引用的对象上的 f() 函数,而 b 所引用的对象有可能是 B 类对象,也有可能是 B 类的任意子类对象
在执行 JIT 编译时,编译器需要分析 B 类的继承关系,查看 f() 函数是否存在重载

  • 如果 f() 函数存在重载:编译器将无法确定在 func() 函数中内联哪个 f() 函数,也就无法进行方法内联
  • 如果 f() 函数不存在重载:编译器将 B 类的 f() 函数内联到 func() 函数中

如果某个函数被设置为 final,那就说明这个函数无法被重载,于是编译器便无须耗费时间去分析继承关系、查看重载情况,直接进行内联即可,从而节省了方法内联编译优化的时间
这就是刚刚提到的 final 有助于触发方法内联的原因,也是很多人误以为将方法设置为 final 就会触发方法内联的来由

4.2、逃逸分析

逃逸分析指的是,JIT 编译器通过分析对象的使用范围,来优化对象的内存存储方式和访问方式,以提高代码的执行效率
针对不同的逃逸分析结果,编译器有 3 种不同的优化策略,它们分别是:栈上分配、标量替换、锁消除

1、栈上分配

我们知道,函数内的局部变量分配在栈上,只能在函数内部访问,对象分配在堆上,可以被多个函数访问
相对而言,堆上对象的创建和回收的过程,涉及复杂的分配和回收算法,要比栈上数据的创建和回收慢很多
如果编译器经过分析以后,发现某个对象的使用范围局限于某个函数内部(专业的说法是没有逃逸到方法外)
那么编译器便可以启动栈上分配编译优化,将对象作为局部变量直接分配在栈上,相应的创建和回收的对象的耗时就减少了很多

2、标量替换

如果某个对象只在某个函数内使用,并且函数内只访问对象的基本类型成员变量等标量数据,那么编译器就可以启动标量替换这一编译优化,使用基本类型变量替代对象

public void func() {
Student stu = new Student();
stu.age = 19;
stu.score = 89;
// ... 后续也只访问了 stu 的基本类型成员变量
}
// 标量替换
public void func() {
int age = 19;
int score = 89;
//...
}

3、锁消除

在第 35 节讲解 synchronized 关键字的性能优化手段时,我们已经讲到过锁消除
对不存在多线程并发访问的代码,编译器会去掉其中保证线程安全的加锁逻辑,如下示例代码所示

为了保证多线程操作的安全性,StringBuffer 中的 append() 函数在设计实现时加了锁
但是在下面的代码中,strBuffer 是局部变量,不会被多线程共享,更不会在多线程环境下调用它的 append() 函数(专业的说法是没有逃逸到线程外)
因此 append() 函数的锁可以被优化消除

public class Demo {
public String concat(String s1, String s2) {
StringBuffer strBuffer = new StringBuffer();
strBuffer.append(s1);
strBuffer.append(s2);
return strBuffer.toString();
}
}

5、课后思考题

由于 JIT 编译对于我们来说几乎是一个黑盒子
因此当编写程序对两段代码进行性能测试时,我们无法确定一段代码的性能比另一段代码的性能高,是源于被测代码本身的性能表现,还是因为触发了 JIT 编译
也就是说:测试环境不确定,导致测试结果的正确性无法保障,对于这样一个问题,你有什么好的解决方法呢?

可以强制关闭 JIT 编译,或者在测试代码前面加入预热代码(即触发 JIT 编译的代码),保证测试结果均为测试代码在 JIT 编译下的性能表现

posted @   lidongdongdong~  阅读(59)  评论(0编辑  收藏  举报
点击右上角即可分享
微信分享提示
评论
收藏
关注
推荐
深色
回顶
展开