深入探讨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();p.setX(p.getX() * 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 10000002: istore_13: iload_14: newarray long6: astore_27: new #3 // class java/util/Random10: dup11: invokespecial #4 // Method java/util/Random."<init>":()V14: astore_315: iconst_016: istore 418: iload 420: iload_121: if_icmpge 3824: aload_225: iload 427: aload_328: invokevirtual #5 // Method java/util/Random.nextLong:()J31: lastore32: iinc 4, 135: goto 1838: 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 r9d0x00007fefb0a4bb7b: mov r9d,DWORD PTR [r11+0x10]// JUMP TO END OF LOOP TO CHECK COUNTER AGAINST LIMIT0x00007fefb0a4bb7f: jmp 0x00007fefb0a4bb90// BACK BRANCH TARGET - SUM ACCUMULATES IN r140x00007fefb0a4bb81: add r14,QWORD PTR [r11+r10*8+0x18]// INCREMENT LOOP COUNTER IN rbx0x00007fefb0a4bb86: add rbx,0x1// SAFEPOINT POLL0x00007fefb0a4bb8a: test DWORD PTR [rip+0x9f39470],eax// IF LOOP COUNTER >= 1_000_000 THEN JUMP TO EXIT CODE0x00007fefb0a4bb90: cmp rbx,0xf42400x00007fefb0a4bb97: jge 0x00007fefb0a4bbc9// MOVE LOW 32 BITS OF LOOP COUNTER INTO r10d0x00007fefb0a4bb99: mov r10d,ebx// ARRAY BOUNDS CHECK AND BRANCH BACK TO LOOP START0x00007fefb0a4bb9c: cmp r10d,r9d0x00007fefb0a4bb9f: 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:# SafepointSynchronize::begin: Timed out while spinning to reach a safepoint.# SafepointSynchronize::begin: Threads which did not reach the safepoint:# "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 {// bodyi += 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-LOOPif (index > Array.length) { // RANGE CHECKthrow new ArrayIndexOutOfBoundsException();}Array[index] = 0;}for (; index < MidLimit; index++) { // MAIN LOOPArray[index] = 0; // NO RANGE CHECK}for (; index < Limit; index++) { // POST-LOOPif (index > Array.length) { // RANGE CHECKthrow 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 UnitsScalarReplacement.single avgt 15 1.919 ± 0.002 ns/opScalarReplacement.single:·gc.alloc.rate avgt 15 ≈ 10⁻⁴ MB/secScalarReplacement.single:·gc.alloc.rate.norm avgt 15 ≈ 10⁻⁶ B/opScalarReplacement.single:·gc.count avgt 15 ≈ 0 countsScalarReplacement.split avgt 15 3.781 ± 0.116 ns/opScalarReplacement.split:·gc.alloc.rate avgt 15 2691.543 ± 81.183 MB/secScalarReplacement.split:·gc.alloc.rate.norm avgt 15 16.000 ± 0.001 B/opScalarReplacement.split:·gc.count avgt 15 1460.000 countsScalarReplacement.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 xaload xmul
aload 1dupmul
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深度集成,提供一体化的实时数仓联合解决方案,实现了数仓分层之间实时数据的高效流动,解决实时数仓分层问题。本方案能够支撑实时推荐、实时风控等多种实时数仓应用场景,满足企业的实时分析需求,具有中间层数据可查、支持数仓分层复用和架构简单等优势。
点击阅读原文查看详情。