深入探讨Java的分层编译
阿里妹导读
0x00 前言
一开始接触到分层编译是因为我们这的服务每次发布/重启后都会短暂地出现CPU满线程池满的情况,然后过一段时间又能自动恢复。排查后发现是启动时JVM将部分热点代码编译为机器代码导致的,这个过程中JIT编译器会占用大量的CPU。
一个Java的源代码文件变成可直接执行的机器指令,需要经过两段编译,第一段是把.java文件转换成.class文件。第二段是把.class文件转换为机器指令。
第一段的编译相对固定,也就是将Java代码翻译为字节码并打包为jar的过程。而字节码的执行则有两种方式,第一种是由解释器(Interpreter)即时解释执行,第二种则是由JIT编译器编译执行。具体采用哪种执行方式则主要看当前的代码是否为「热点代码(Hot Spot Code)」。当JVM发现某个方法或代码块运行特别频繁的时候,就会认为这是热点代码。此后,JIT会把部分热点代码的class直接编译为本地机器相关的机器码,并进行优化,然后再把翻译后的机器码缓存起来,以备下次使用。JIT的运行则主要依赖于运行时的profiling信息,由此则被称为Just-in-time Compilation,也就是即时(运行时)编译。
non-method segment:存储JVM内部代码,大小可使用-XX:NonNMethodCodeHeapSize参数配置;
profiled-code segment:C1编译后的代码,特点是生命周期较短(随时都有可能升级到C2编译),大小可使用-XX:ProfiledCodeHeapSize参数配置;
non-profiled segment:C2编译后的代码,特点是生命周期较长(因为极少发生反优化降级),大小可使用-XX:NonProfiledCodeHeapSize参数配置;
0x01 分层编译
0x00 分层编译(Tiered Compilation)
Interpreter:解释执行;
C1 NO profiling:执行不带profiling的C1代码;
C1 LIMITED profiling:执行仅带方法调用次数以及循环回边执行次数profiling的C1代码;
C1 FULL profiling:执行带所有profiling的C1代码;
C2:执行C2代码;
profiling同样是个耗时的过程,因此,从执行速度而言,1层>2层>3层。
如果当前方法过于平凡(trivial),方法体很小或者无从profile,就会从3层流转到1层,直接使用没有profiling的C1代码,并在此终止。
如果C1编译器忙的话,就会直接使用C2编译。同理,如果C2编译器忙的话,就会回转到2层,再流转到3层,并等他不忙的时候,再使用C2编译。之所以先流转到2层的原因是为了减少在3层的时间,因为3层的执行效率相对2层较慢。而且如果C2忙的话,也说明大部分方法仍在3层排队等待C2的编译。
如果C2做了一些比较激进的优化,比如分支预测,然后在实际执行中发现预测出错,这个时候就会进行「去优化」,重新进入解释执行。如下图,在运行过程中,编译器发现a总是小于10,一直在走左侧红色分支。此时,编译器就会笃定未来大概率还是走这个分支,于是就会省去if判断将代码直接组合优化。而对于a≥10的情况,就会重新解释执行。
0x01 性能对比
本节使用了这篇文章里的结论:Startup, containers & Tiered Compilation。这篇文章同时包含了使用JDK Mission Control工具测量JIT编译时间的方法,值得一看!
本节直接搬运了这本书里面的结论:Chapter 4. Working with the JIT Compiler,具体可参考「Basic Tunings: Client or Server (or Both)」一节
TL;DR 这本书里面的实验说实话做得挺奇怪的。如果我既想论证C2优化后性能好于C1,又想论证C1的编译性能好于C2,并以此来说明分层编译的必要性,那么我会用同一个应用测试二者的编译时间和运行性能。而他偏偏用了不同的应用做了这两个实验,我不知道作者在回避什么,还是说对于这个应用来说编译时间上的差距不明显,要换个更明显的测试用例。
0x02 编译优化
0x00 方法内联(Inlining)
public class Point {
private int x, y;
public void getX() { return x; }
public void setX(int i) { x = i; }
}
触发一次函数调用的成本是相对高昂的,因为少不了栈帧的切换。
Point p = getPoint();
* 2);
Point p = getPoint();
p.x = p.x * 2;
0x01 循环优化
0x00 循环展开(Loop Unrolling)
public class LoopUnroll {
public static void main(String[] args) {
int MAX = 1000000;
long[] data = new long[MAX];
java.util.Random random = new java.util.Random();
for (int i = 0; i < MAX; i++) {
data[i] = random.nextLong();
}
}
}
public static void main(java.lang.String[]);
Code:
0: ldc #2 // int 1000000
2: istore_1
3: iload_1
4: newarray long
6: astore_2
7: new #3 // class java/util/Random
10: dup
11: invokespecial #4 // Method java/util/Random."<init>":()V
14: astore_3
15: iconst_0
16: istore 4
18: iload 4
20: iload_1
21: if_icmpge 38
24: aload_2
25: iload 4
27: aload_3
28: invokevirtual #5 // Method java/util/Random.nextLong:()J
31: lastore
32: iinc 4, 1
35: goto 18
38: return
</init>
public class LoopUnroll {
public static void main(String[] args) {
int MAX = 1000000;
long[] data = new long[MAX];
java.util.Random random = new java.util.Random();
for (int i = 0; i < MAX; i += 5) {
data[i] = random.nextLong();
data[i + 1] = random.nextLong();
data[i + 2] = random.nextLong();
data[i + 3] = random.nextLong();
data[i + 4] = random.nextLong();
}
}
}
此处原因主要参考了文章:Optimize loops with long variables in Java
0x01 前向跳转时的安全点去除(Safepoint Check)
private long longStride1()
{
long sum = 0;
for (long l = 0; l < MAX; l++)
{
sum += data[(int) l];
}
return sum;
}
// ARRAY LENGTH INTO r9d
0x00007fefb0a4bb7b: mov r9d,DWORD PTR [r11+0x10]
// JUMP TO END OF LOOP TO CHECK COUNTER AGAINST LIMIT
0x00007fefb0a4bb7f: jmp 0x00007fefb0a4bb90
// BACK BRANCH TARGET - SUM ACCUMULATES IN r14
0x00007fefb0a4bb81: add r14,QWORD PTR [r11+r10*8+0x18]
// INCREMENT LOOP COUNTER IN rbx
0x00007fefb0a4bb86: add rbx,0x1
// SAFEPOINT POLL
0x00007fefb0a4bb8a: test DWORD PTR [rip+0x9f39470],eax
// IF LOOP COUNTER >= 1_000_000 THEN JUMP TO EXIT CODE
0x00007fefb0a4bb90: cmp rbx,0xf4240
0x00007fefb0a4bb97: jge 0x00007fefb0a4bbc9
// MOVE LOW 32 BITS OF LOOP COUNTER INTO r10d
0x00007fefb0a4bb99: mov r10d,ebx
// ARRAY BOUNDS CHECK AND BRANCH BACK TO LOOP START
0x00007fefb0a4bb9c: cmp r10d,r9d
0x00007fefb0a4bb9f: jb 0x00007fefb0a4bb81
private long intStrideVariable(int stride)
{
long sum = 0;
for (int i = 0; i < MAX; i += stride)
{
sum += data[i];
}
return sum;
}
bug示例: 某一次STW的时间特别长 多线程程序跑着跑着成单线程了
SafepointSynchronize::begin: Timeout detected:
while spinning to reach a safepoint. SafepointSynchronize::begin: Timed out
which did not reach the safepoint: SafepointSynchronize::begin: Threads
"pool-1-thread-2" #12 prio=5 os_prio=0 tid=0x0000000019004800 nid=0x1480 runnable [0x0000000000000000]
java.lang.Thread.State: RUNNABLE
# SafepointSynchronize::begin: (End of list)
0x02 Loop Strip Mining
要读这一节,你首先要完整地读完上一节。此外,这是Java 10中才引入的新技术。没写中文名的原因是我还没想出一个比较好的翻译。
for (int i = start; i < stop; i += stride) {
// body
}
i = start;
if (i < stop) {
do {
// LoopStripMiningIter 即为JIT推导出的匹配循环次数
int next = MIN(stop, i + LoopStripMiningIter * stride);
do {
// body
i += stride;
} while (i < next);
// 语义上相当于在原始的循环中间安插安全点
safepoint();
} while (i < stop);
}
0x03 边界检查消除(Range Check Elimination)
for (int index = Start; index < Limit; index++) {
Array[index] = 0;
}
int MidStart = Math.max(Start, 0);
int MidLimit = Math.min(Limit, Array.length);
int index = Start;
for (; index < MidStart; index++) { // PRE-LOOP
if (index > Array.length) { // RANGE CHECK
throw new ArrayIndexOutOfBoundsException();
}
Array[index] = 0;
}
for (; index < MidLimit; index++) { // MAIN LOOP
Array[index] = 0; // NO RANGE CHECK
}
for (; index < Limit; index++) { // POST-LOOP
if (index > Array.length) { // RANGE CHECK
throw new ArrayIndexOutOfBoundsException();
}
Array[index] = 0;
}
循环访问的数组在循环中不发生变化;
循环的步长在循环中是不变的;
访问时的数组下标呈循环计数器的线性关系;
for (int x = Start; x < Limit; x++) {
Array[k * x + b] = 0;
}
当前循环在JIT profiling过程中被判定为热循环
0x04 循环判断外提(Loop Unswitching)
for (i = 0; i < N; i++) {
if (x) {
a[i] = 0;
} else {
b[i] = 0;
}
}
if (x) {
for (i = 0; i < N; i++) {
a[i] = 0;
}
} else {
for (i = 0; i < N; i++) {
b[i] = 0;
}
}
0x02 逃逸分析(Escape Analysis)
如下的这篇文章详细讲解了JIT是怎么利用逃逸分析的结果进行各种优化的,但内容偏原理性,且相对高深不易读懂,有兴趣可以看看: SEEING ESCAPE ANALYSIS WORKING 如果想简单了解下,可以看下面我写的部分
0x00 标量替换(Scalar Replacement)
public void foo() {
MyObject object = new MyObject();
object.x = 1;
...//to do something
}
public void foo() {
int x = 1;
...//to do something
}
public void foo(boolean flag) {
MyObject o;
if (flag) {
o = new MyObject(x);
} else {
o = new MyObject(x);
}
...//to do something
}
Benchmark Mode Cnt Score Error Units
ScalarReplacement.single avgt 15 1.919 ± 0.002 ns/op
ScalarReplacement.single:·gc.alloc.rate avgt 15 ≈ 10⁻⁴ MB/sec
ScalarReplacement.single:·gc.alloc.rate.norm avgt 15 ≈ 10⁻⁶ B/op
ScalarReplacement.single:·gc.count avgt 15 ≈ 0 counts
ScalarReplacement.split avgt 15 3.781 ± 0.116 ns/op
ScalarReplacement.split:·gc.alloc.rate avgt 15 2691.543 ± 81.183 MB/sec
ScalarReplacement.split:·gc.alloc.rate.norm avgt 15 16.000 ± 0.001 B/op
ScalarReplacement.split:·gc.count avgt 15 1460.000 counts
ScalarReplacement.split:·gc.time avgt 15 929.000 ms
这篇文章对上述两例进行了对比测试。ScalarReplacement.single是发生了标量替换后的性能结果,ScalarReplacement.split是加了控制流骗过EA没有发生标量替换后的性能结果。
Java的内存模型中,栈内存只保存原始类型和对象的指针信息,对象的指针指向堆内存中对象实体的地址。在栈内存中分配对象实体,打破了这一模式。
对象的存储不仅包括对象成员变量的存储,还包含了对象本身的头结构。不管在栈上还是在堆上分配,只要分配出这样一个对象,就一定会包含其头结构信息的存储。但如果进行标量替换,就省去了头结构,只完成了对其成员变量的分配。
0x01 锁消除(Lock Elision)
public static String getString(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
0x03 窥孔优化(Peephole Optimization)
0:关闭Intel AVX指令集优化
1:使用Intel AVX level 1指令进行优化(仅支持Sandy Bridge及更高架构CPU)
2:使用Intel AVX level 2指令进行优化(仅支持Haswell及更高架构CPU)
3:使用Intel AVX level 3指令进行优化(仅支持Knights Landing及更高架构CPU)
y = x * 3
=> y = (x << 1) + x;
A && (A || B)
=> A;
A || (A && B)
=> A;
x * x
aload x
aload x
mul
aload 1
dup
mul
int dead()
{
int a=10;
int z=50;
int c=z*5;
a=20;
a=a*10;
return c;
}
int dead()
{
int z=50;
int c=z*5;
return c;
}
参考文献:
Java即时编译器原理解析及实践:
https://tech.meituan.com/2020/10/22/java-jit-practice-in-meituan.html
21 深入JVM即时编译器JIT,优化Java编译:
https://learn.lianglianglee.com/专栏/Java并发编程实战/21%20%20深入JVM即时编译器JIT,优化Java编译.md
Startup, containers & Tiered Compilation:
https://jpbempel.github.io/2020/05/22/startup-containers-tieredcompilation.html
Chapter 4. Working with the JIT Compiler:
https://www.oreilly.com/library/view/java-performance-the/9781449363512/ch04.html
Deep Dive Into the New Java JIT Compiler – Graal:
https://www.baeldung.com/graal-java-jit-compiler
Tiered Compilation in JVM:
https://www.baeldung.com/jvm-tiered-compilation
Loop Unrolling:
https://blogs.oracle.com/javamagazine/post/loop-unrolling
Optimize loops with long variables in Java:
https://developers.redhat.com/articles/2022/08/25/optimize-loops-long-variables-java
JVM源码分析之安全点safepoint:
https://www.jianshu.com/p/c79c5e02ebe6
java code execution yields to different results in debug without breakpoints and normal run. Is ExecutorService broken?:
https://stackoverflow.com/a/38427546
ParNew 应用暂停时间偶尔会出现好几秒的情况:
https://hllvm-group.iteye.com/group/topic/38836
Loop Strip Mining in C2:
https://cr.openjdk.org/~roland/loop_strip_mining.pdf
RangeCheckElimination:
https://wiki.openjdk.org/display/HotSpot/RangeCheckElimination
SEEING ESCAPE ANALYSIS WORKING:
https://www.javaadvent.com/2020/12/seeing-escape-analysis-working.html
JVM Anatomy Quark #18: Scalar Replacement:
https://shipilev.net/jvm/anatomy-quarks/18-scalar-replacement/
JVM Anatomy Quark #19: Lock Elision:
https://shipilev.net/jvm/anatomy-quarks/19-lock-elision/
Flink+Hologres 搭建实时数仓
本方案将Hologres与Flink深度集成,提供一体化的实时数仓联合解决方案,实现了数仓分层之间实时数据的高效流动,解决实时数仓分层问题。本方案能够支撑实时推荐、实时风控等多种实时数仓应用场景,满足企业的实时分析需求,具有中间层数据可查、支持数仓分层复用和架构简单等优势。
点击阅读原文查看详情。
微信扫码关注该文公众号作者