深入理解java虚拟机(五)

HotSpot JIT

Posted by Kinsomy on May 10, 2019

1.HotSpot 即时编译器

即时编译器叫做JIT,另一个编译技术叫预编译(AOT),即时编译器是针对频繁被调用的代码块,也就是俗称的热点代码块进行编译,将它编译成本地机器码,有助于提高执行效率。

1.1 解释器和编译器

HotSpot即时编译器包括了解释器和编译器两部分,他们之间配合工作。

那么为什么需要解释器和编译器并存呢?它们分别又是在什么时工作?

解释器顾名思义就是对代码解释执行,省去了编译的时间,在针对需要快速启动和执行的场景下,是有优势的,解释器同样还会节约内存。带来的缺点则是边解释边执行性能地下。

编译器适合在程序运行后,逐渐的把代码编译成本地机器码,这样就不用每次执行到代码的时候都去解释一遍,提高了性能。

“同时,解释器还可以作为编译器激进优化时的一个“逃生门”,让编译器根据概率选择一些大多数时候都能提升运行速度的优化手段,当激进优化的假设不成立,如加载了新类后类型继承结构出现变化、出现“罕见陷阱”(Uncommon Trap)时可以通过逆优化(Deoptimization)退回到解释状态继续执行(部分没有解释器的虚拟机中也会采用不进行激进优化的C1编译器[2]担任“逃生门”的角色),因此,在整个虚拟机执行架构中,解释器与编译器经常配合工作。”

摘录来自: 周志明. “深入理解Java虚拟机:JVM高级特性与最佳实践。” iBooks.

HotSpot虚拟机中有两个即时编译器,分别是C1和C2,C1是客户端编译器,C2是服务端编译器,虚拟机会自动选择,也可以同伙参数”-client“、”-server“去指定。

C1编译器速度更快,C2优化程度更高。

-Xint参数强制指定只使用解释器

-Xcomp参数强制指定只使用编译器

1.2 热点探测技术

上面说到了热点代码会被JIT编译,那么怎么才能判断一段代码块是热点代码呢,就要用到热点探测技术。

1.2.1 基于采样的热点探测技术

“采用这种方法的虚拟机会周期性地检查各个线程的栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是“热点方法”。基于采样的热点探测的好处是实现简单、高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可),缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。”

摘录来自: 周志明. “深入理解Java虚拟机:JVM高级特性与最佳实践。” iBooks.

1.2.2 基于计数器的热点探测

“采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法”。这种统计方法实现起来麻烦一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系,但是它的统计结果相对来说更加精确和严谨。”

摘录来自: 周志明. “深入理解Java虚拟机:JVM高级特性与最佳实践。” iBooks.

HotSpot虚拟机采用了基于计数器的热点探测。

上面讲到的即时编译默认会在后台执行,当计数器达到阈值,就开始JIT编译,在编译还没有完成之前,依然会使用解释器执行。

2. 即时编译优化技术

2.1 优化示例

看下面这个代码可以如何优化:

public void method () {
    y = b.getVal();
    z = b.getVal();
    sum = y + z;
}

一共分四步优化:

2.1.1 方法内联优化

内联优化是优先级比较高的优化内容,作用是为了减少方法调用成本和为其他优化建立基础。

//优化前
public void method () {
    y = b.getVal();
    z = b.getVal();
    sum = y + z;
}
//优化后
public void method () {
    y = b.val;
    z = b.val;
    sum = y + z;
}

2.1.2 冗余访问消除

//优化前
public void method () {
    y = b.val;
    z = b.val;
    sum = y + z;
}
//优化后
public void method () {
    y = b.val;
    z = y;
    sum = y + z;
}

2.1.3 复写传播

省略z变量

//优化前
public void method () {
    y = b.val;
    z = y;
    sum = y + z;
}
//优化后
public void method () {
    y = b.val;
    y = y;
    sum = y + y;
}

2.1.4 无用代码消除

删除y=y;无用代码

//优化前
public void method () {
    y = b.val;
    z = y;
    sum = y + z;
}
//优化后
public void method () {
    y = b.val;
    sum = y + y;
}

2.2 公共子表达式消除

公共子表达式的含义就是在变量范围内,一个表达式得出的变量值已经被计算出来,只要之后不会发生变化,在变量范围内就成了公共子表达式,那么之后想要使用该表达式计算的值就可以直接使用该变量,无需再次计算。

//优化前
int result = (a * b) * 3 + (c + a * b);

//优化后
int result = E * 3 + (c + E);

2.3 逃逸分析

“逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,称为方法逃逸。甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸。” “如果能证明一个对象不会逃逸到方法或线程之外,也就是别的方法或线程无法通过任何途径访问到这个对象,则可能为这个变量进行一些高效的优化”

摘录来自: 周志明. “深入理解Java虚拟机:JVM高级特性与最佳实践。” iBooks.

2.3.1 栈上分配

创建对象会在堆内存上分配内存,堆内存是所有线程共享的,在GC时回收不存活的对象,这是之前讲过的知识点,那么通过逃逸分析,如果一个对象是只属于一个方法,不会被其他地方使用,那就可以在方法栈上给对象分配内存,可以随着出栈入栈进行内存管理,减少系统GC压力。

2.3.2 同步消除

当一个对象经过逃逸分析发现只会在一个线程内部使用,那对该变量的同步操作代码就可以消除。

2.3.3 标量替换

“标量(Scalar)是指一个数据已经无法再分解成更小的数据来表示了,Java虚拟机中的原始数据类型(int、long等数值类型以及reference类型等)都不能再进一步分解,它们就可以称为标量。相对的,如果一个数据可以继续分解,那它就称作聚合量(Aggregate),Java中的对象就是最典型的聚合量。如果把一个Java对象拆散,根据程序访问的情况,将其使用到的成员变量恢复原始类型来访问就叫做标量替换。如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散的话,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用到的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上(栈上存储的数据,有很大的概率会被虚拟机分配至物理机器的高速寄存器中存储)分配和读写之外,还可以为后续进一步[…]”

摘录来自: 周志明. “深入理解Java虚拟机:JVM高级特性与最佳实践。” iBooks.

参考资料