一、导读
常见的编译型语言如C++,通常会把代码间接编译成CPU所能了解的机器码来运行。而Java为了实现“一次编译,处处运行”的个性,把编译的过程分成两局部,首先它会先由javac编译成通用的两头模式——字节码,而后再由解释器逐条将字节码解释为机器码来执行。所以在性能上,Java通常不如C++这类编译型语言。
为了优化Java的性能 ,JVM在解释器之外引入了即时(Just In Time)编译器:当程序运行时,解释器首先发挥作用,代码能够间接执行。随着时间推移,即时编译器逐步发挥作用,把越来越多的代码编译优化老本地代码,来获取更高的执行效率。解释器这时能够作为编译运行的降级伎俩,在一些不牢靠的编译优化呈现问题时,再切换回解释执行,保障程序能够失常运行。
即时编译器极大地提高了Java程序的运行速度,而且跟动态编译相比,即时编译器能够选择性地编译热点代码,省去了很多编译工夫,也节俭很多的空间。目前,即时编译器曾经十分成熟了,在性能层面甚至能够和编译型语言相比。不过在这个畛域,大家仍然在一直摸索如何联合不同的编译形式,应用更加智能的伎俩来晋升程序的运行速度。
二、Java的执行过程
Java的执行过程整体能够分为两个局部,第一步由javac将源码编译成字节码,在这个过程中会进行词法剖析、语法分析、语义剖析,编译原理中这部分的编译称为前端编译。接下来无需编译间接逐条将字节码解释执行,在解释执行的过程中,虚拟机同时对程序运行的信息进行收集,在这些信息的根底上,编译器会逐步发挥作用,它会进行后端编译——把字节码编译成机器码,但不是所有的代码都会被编译,只有被JVM认定为的热点代码,才可能被编译。
怎么样才会被认为是热点代码呢?JVM中会设置一个阈值,当办法或者代码块的在肯定工夫内的调用次数超过这个阈值时就会被编译,存入codeCache中。当下次执行时,再遇到这段代码,就会从codeCache中读取机器码,间接执行,以此来晋升程序运行的性能。整体的执行过程大抵如下图所示:
1. JVM中的编译器
JVM中集成了两种编译器,Client Compiler和Server Compiler,它们的作用也不同。Client Compiler重视启动速度和部分的优化,Server Compiler则更加关注全局的优化,性能会更好,但因为会进行更多的全局剖析,所以启动速度会变慢。两种编译器有着不同的利用场景,在虚拟机中同时发挥作用。
Client Compiler
HotSpot VM带有一个Client Compiler C1编译器。这种编译器启动速度快,然而性能比拟Server Compiler来说会差一些。C1会做三件事:
- 部分简略牢靠的优化,比方字节码上进行的一些根底优化,办法内联、常量流传等,放弃许多耗时较长的全局优化。
- 将字节码结构成高级两头示意(High-level Intermediate Representation,以下称为HIR),HIR与平台无关,通常采纳图构造,更适宜JVM对程序进行优化。
- 最初将HIR转换成低级两头示意(Low-level Intermediate Representation,以下称为LIR),在LIR的根底上会进行寄存器调配、窥孔优化(部分的优化形式,编译器在一个基本块或者多个基本块中,针对曾经生成的代码,联合CPU本人指令的特点,通过一些认为可能带来性能晋升的转换规则或者通过整体的剖析,进行指令转换,来晋升代码性能)等操作,最终生成机器码。
Server Compiler
Server Compiler次要关注一些编译耗时较长的全局优化,甚至会还会依据程序运行的信息进行一些不牢靠的激进优化。这种编译器的启动工夫长,实用于长时间运行的后台程序,它的性能通常比Client Compiler高30%以上。目前,Hotspot虚拟机中应用的Server Compiler有两种:C2和Graal。
C2 Compiler
在Hotspot VM中,默认的Server Compiler是C2编译器。
C2编译器在进行编译优化时,会应用一种控制流与数据流联合的图数据结构,称为Ideal Graph。 Ideal Graph示意以后程序的数据流向和指令间的依赖关系,依附这种图构造,某些优化步骤(尤其是波及浮动代码块的那些优化步骤)变得不那么简单。
Ideal Graph的构建是在解析字节码的时候,依据字节码中的指令向一个空的Graph中增加节点,Graph中的节点通常对应一个指令块,每个指令块蕴含多条相关联的指令,JVM会利用一些优化技术对这些指令进行优化,比方Global Value Numbering、常量折叠等,解析完结后,还会进行一些死代码剔除的操作。生成Ideal Graph后,会在这个根底上联合收集的程序运行信息来进行一些全局的优化,这个阶段如果JVM判断此时没有全局优化的必要,就会跳过这部分优化。
无论是否进行全局优化,Ideal Graph都会被转化为一种更靠近机器层面的MachNode Graph,最初编译的机器码就是从MachNode Graph中得的,生成机器码前还会有一些包含寄存器调配、窥孔优化等操作。对于Ideal Graph和各种全局的优化伎俩会在前面的章节具体介绍。Server Compiler编译优化的过程如下图所示:
Graal Compiler
从JDK 9开始,Hotspot VM中集成了一种新的Server Compiler,Graal编译器。相比C2编译器,Graal有这样几种要害个性:
- 前文有提到,JVM会在解释执行的时候收集程序运行的各种信息,而后编译器会依据这些信息进行一些基于预测的激进优化,比方分支预测,依据程序不同分支的运行概率,选择性地编译一些概率较大的分支。Graal比C2更加青眼这种优化,所以Graal的峰值性能通常要比C2更好。
- 应用Java编写,对于Java语言,尤其是新个性,比方Lambda、Stream等更加敌对。
- 更深层次的优化,比方虚函数的内联、局部逃逸剖析等。
Graal编译器能够通过Java虚拟机参数-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler启用。当启用时,它将替换掉HotSpot中的C2编译器,并响应本来由C2负责的编译申请。
2. 分层编译
在Java 7以前,须要研发人员依据服务的性质去抉择编译器。对于须要疾速启动的,或者一些不会长期运行的服务,能够采纳编译效率较高的C1,对应参数-client。长期运行的服务,或者对峰值性能有要求的后盾服务,能够采纳峰值性能更好的C2,对应参数-server。Java 7开始引入了分层编译的概念,它联合了C1和C2的劣势,谋求启动速度和峰值性能的一个均衡。分层编译将JVM的执行状态分为了五个档次。五个层级别离是:
- 解释执行。
- 执行不带profiling的C1代码。
- 执行仅带办法调用次数以及循环回边执行次数profiling的C1代码。
- 执行带所有profiling的C1代码。
- 执行C2代码。
profiling就是收集可能反映程序执行状态的数据。其中最根本的统计数据就是办法的调用次数,以及循环回边的执行次数。
通常状况下,C2代码的执行效率要比C1代码的高出30%以上。C1层执行的代码,按执行效率排序从高至低则是1层>2层>3层。这5个档次中,1层和4层都是终止状态,当一个办法达到终止状态后,只有编译后的代码并没有生效,那么JVM就不会再次收回该办法的编译申请的。服务理论运行时,JVM会依据服务运行状况,从解释执行开始,抉择不同的编译门路,直到达到终止状态。下图中就列举了几种常见的编译门路:
- 图中第①条门路,代表编译的个别状况,热点办法从解释执行到被3层的C1编译,最初被4层的C2编译。
- 如果办法比拟小(比方Java服务中常见的getter/setter办法),3层的profiling没有收集到有价值的数据,JVM就会判定该办法对于C1代码和C2代码的执行效率雷同,就会执行图中第②条门路。在这种状况下,JVM会在3层编译之后,放弃进入C2编译,间接抉择用1层的C1编译运行。
- 在C1繁忙的状况下,执行图中第③条门路,在解释执行过程中对程序进行profiling ,依据信息间接由第4层的C2编译。
- 前文提到C1中的执行效率是1层>2层>3层,第3层个别要比第2层慢35%以上,所以在C2繁忙的状况下,执行图中第④条门路。这时办法会被2层的C1编译,而后再被3层的C1编译,以缩小办法在3层的执行工夫。
- 如果编译器做了一些比拟激进的优化,比方分支预测,在理论运行时发现预测出错,这时就会进行反优化,从新进入解释执行,图中第⑤条执行门路代表的就是反优化。
总的来说,C1的编译速度更快,C2的编译品质更高,分层编译的不同编译门路,也就是JVM依据以后服务的运行状况来寻找以后服务的最佳平衡点的一个过程。从JDK 8开始,JVM默认开启分层编译。
3. 即时编译的触发
Java虚拟机依据办法的调用次数以及循环回边的执行次数来触发即时编译。循环回边是一个控制流图中的概念,程序中能够简略了解为往回跳转的指令,比方上面这段代码:
循环回边
public void nlp(Object obj) { int sum = 0; for (int i = 0; i < 200; i++) { sum += i; }}
下面这段代码通过编译生成上面的字节码。其中,偏移量为18的字节码将往回跳至偏移量为4的字节码中。在解释执行时,每当运行一次该指令,Java虚拟机便会将该办法的循环回边计数器加1。
字节码
public void nlp(java.lang.Object); Code: 0: iconst_0 1: istore_1 2: iconst_0 3: istore_2 4: iload_2 5: sipush 200 8: if_icmpge 21 11: iload_1 12: iload_2 13: iadd 14: istore_1 15: iinc 2, 1 18: goto 4 21: return
在即时编译过程中,编译器会辨认循环的头部和尾部。下面这段字节码中,循环体的头部和尾部别离为偏移量为11的字节码和偏移量为15的字节码。编译器将在循环体结尾减少循环回边计数器的代码,来对循环进行计数。
当办法的调用次数和循环回边的次数的和,超过由参数-XX:CompileThreshold指定的阈值时(应用C1时,默认值为1500;应用C2时,默认值为10000),就会触发即时编译。
开启分层编译的状况下,-XX:CompileThreshold参数设置的阈值将会生效,触发编译会由以下的条件来判断:
- 办法调用次数大于由参数-XX:TierXInvocationThreshold指定的阈值乘以系数。
- 办法调用次数大于由参数-XX:TierXMINInvocationThreshold指定的阈值乘以系数,并且办法调用次数和循环回边次数之和大于由参数-XX:TierXCompileThreshold指定的阈值乘以系数时。
分层编译触发条件公式
i > TierXInvocationThreshold * s || (i > TierXMinInvocationThreshold * s && i + b > TierXCompileThreshold * s) i为调用次数,b是循环回边次数
上述满足其中一个条件就会触发即时编译,并且JVM会依据以后的编译办法数以及编译线程数动静调整系数s。
三、编译优化
即时编译器会对正在运行的服务进行一系列的优化,包含字节码解析过程中的剖析,依据编译过程中代码的一些两头模式来做局部优化,还会依据程序依赖图进行全局优化,最初才会生成机器码。
1. 两头表达形式(Intermediate Representation)
在编译原理中,通常把编译器分为前端和后端,前端编译通过词法剖析、语法分析、语义剖析生成两头表达形式(Intermediate Representation,以下称为IR),后端会对IR进行优化,生成指标代码。
Java字节码就是一种IR,然而字节码的结构复杂,字节码这样代码模式的IR也不适宜做全局的剖析优化。古代编译器个别采纳图构造的IR,动态单赋值(Static Single Assignment,SSA)IR是目前比拟罕用的一种。这种IR的特点是每个变量只能被赋值一次,而且只有当变量被赋值之后能力应用。举个例子:
SSA IR
Plain Text{ a = 1; a = 2; b = a;}
上述代码中咱们能够轻易地发现a = 1的赋值是冗余的,然而编译器不能。传统的编译器须要借助数据流剖析,从后至前顺次确认哪些变量的值被笼罩掉。不过,如果借助了SSA IR,编译器则能够很容易辨认冗余赋值。
下面代码的SSA IR模式的伪代码能够示意为:
SSA IR
Plain Text{ a_1 = 1; a_2 = 2; b_1 = a_2;}
因为SSA IR中每个变量只能赋值一次,所以代码中的a在SSA IR中会分成a_1、a_2两个变量来赋值,这样编译器就能够很容易通过扫描这些变量来发现a_1的赋值后并没有应用,赋值是冗余的。
除此之外,SSA IR对其余优化形式也有很大的帮忙,例如上面这个死代码删除(Dead Code Elimination)的例子:
DeadCodeElimination
public void DeadCodeElimination{ int a = 2; int b = 0 if(2 > 1){ a = 1; } else{ b = 2; } add(a,b)}
能够失去SSA IR伪代码:
DeadCodeElimination
a_1 = 2;b_1 = 0if true: a_2 = 1;else b_2 = 2;add(a,b)
编译器通过执行字节码能够发现 b_2 赋值后不会被应用,else分支不会被执行。通过死代码删除后就能够失去代码:
DeadCodeElimination
public void DeadCodeElimination{ int a = 1; int b = 0; add(a,b)}
咱们能够将编译器的每一种优化看成一个图优化算法,它接管一个IR图,并输入通过转换后的IR图。编译器优化的过程就是一个个图节点的优化串联起来的。
C1中的两头表达形式
前文提及C1编译器外部应用高级两头表达形式HIR,低级两头表达形式LIR来进行各种优化,这两种IR都是SSA模式的。
HIR是由很多基本块(Basic Block)组成的控制流图构造,每个块蕴含很多SSA模式的指令。基本块的构造如下图所示:
其中,predecessors示意前驱基本块(因为前驱可能是多个,所以是BlockList构造,是多个BlockBegin组成的可扩容数组)。同样,successors示意多个后继基本块BlockEnd。除了这两局部就是主体块,外面蕴含程序执行的指令和一个next指针,指向下一个执行的主体块。
从字节码到HIR的结构最终调用的是GraphBuilder,GraphBuilder会遍历字节码结构所有代码基本块贮存为一个链表构造,然而这个时候的基本块只有BlockBegin,不包含具体的指令。第二步GraphBuilder会用一个ValueStack作为操作数栈和局部变量表,模仿执行字节码,结构出对应的HIR,填充之前空的基本块,这里给出简略字节码块结构HIR的过程示例,如下所示:
字节码结构HIR
字节码 Local Value operand stack HIR 5: iload_1 [i1,i2] [i1] 6: iload_2 [i1,i2] [i1,i2] ................................................ i3: i1 * i2 7: imul 8: istore_3 [i1,i2,i3] [i3]
能够看出,当执行iload_1时,操作数栈压入变量i1,执行iload_2时,操作数栈压入变量i2,执行相乘指令imul时弹出栈顶两个值,结构出HIR i3 : i1 * i2,生成的i3入栈。
C1编译器优化大部分都是在HIR之上实现的。当优化实现之后它会将HIR转化为LIR,LIR和HIR相似,也是一种编译器外部用到的IR,HIR通过优化打消一些两头节点就能够生成LIR,模式上更加简化。
Sea-of-Nodes IR
C2编译器中的Ideal Graph采纳的是一种名为Sea-of-Nodes两头表达形式,同样也是SSA模式的。它最大特点是去除了变量的概念,间接采纳值来进行运算。为了不便了解,能够利用IR可视化工具Ideal Graph Visualizer(IGV),来展现具体的IR图。比方上面这段代码:
example
public static int foo(int count) { int sum = 0; for (int i = 0; i < count; i++) { sum += i; } return sum;}
对应的IR图如下所示:
图中若干个程序执行的节点将被蕴含在同一个基本块之中,如图中的B0、B1等。B0基本块中0号Start节点是办法入口,B3中21号Return节点是办法进口。红色加粗线条为控制流,蓝色线条为数据流,而其余色彩的线条则是非凡的控制流或数据流。被控制流边所连贯的是固定节点,其余的则是浮动节点(浮动节点指只有能满足数据依赖关系,能够放在不同地位的节点,浮动节点变动的这个过程称为Schedule)。
这种图具备轻量级的边构造。 图中的边仅由指向另一个节点的指针示意。节点是Node子类的实例,带有指定输出边的指针数组。这种示意的长处是扭转节点的输出边很快,如果想要扭转输出边,只有将指针指向Node,而后存入Node的指针数组就能够了。
依赖于这种图构造,通过收集程序运行的信息,JVM能够通过Schedule那些浮动节点,从而取得最好的编译成果。
Phi And Region Nodes
Ideal Graph是SSA IR。 因为没有变量的概念,这会带来一个问题,就是不同执行门路可能会对同一变量设置不同的值。例如上面这段代码if语句的两个分支中,别离返回5和6。此时,依据不同的执行门路,所读取到的值很有可能不同。
example
int test(int x) {int a = 0; if(x == 1) { a = 5; } else { a = 6; } return a;}
为了解决这个问题,就引入一个Phi Nodes的概念,可能依据不同的执行门路抉择不同的值。于是,下面这段代码能够示意为上面这张图:
Phi Nodes中保留不同门路上蕴含的所有值,Region Nodes依据不同门路的判断条件,从Phi Nodes获得以后执行门路中变量应该赋予的值,带有Phi节点的SSA模式的伪代码如下:
Phi Nodes
int test(int x) { a_1 = 0; if(x == 1){ a_2 = 5; }else { a_3 = 6; } a_4 = Phi(a_2,a_3); return a_4;}
Global Value Numbering
Global Value Numbering(GVN) 是一种因为Sea-of-Nodes变得非常容易的优化技术 。
GVN是指为每一个计算失去的值调配一个举世无双的编号,而后遍历指令寻找优化的机会,它能够发现并打消等价计算的优化技术。如果一段程序中呈现了屡次操作数雷同的乘法,那么即时编译器能够将这些乘法合并为一个,从而升高输入机器码的大小。如果这些乘法呈现在同一执行门路上,那么GVN还将省下冗余的乘法操作。在Sea-of-Nodes中,因为只存在值的概念,因而GVN算法将非常简单:即时编译器只需判断该浮动节点是否与已存在的浮动节点的编号雷同,所输出的IR节点是否统一,便能够将这两个浮动节点归并成一个。比方上面这段代码:
GVN
a = 1;b = 2;c = a + b;d = a + b;e = d;
GVN会利用Hash算法编号,计算a = 1时,失去编号1,计算b = 2时失去编号2,计算c = a + b时失去编号3,这些编号都会放入Hash表中保留,在计算d = a + b时,会发现a + b曾经存在Hash表中,就不会再进行计算,间接从Hash表中取出计算过的值。最初的e = d也能够由Hash表中查到而进行复用。
能够将GVN了解为在IR图上的公共子表达式打消(Common Subexpression Elimination,CSE)。两者区别在于,GVN间接比拟值的雷同与否,而CSE是借助词法分析器来判断两个表达式雷同与否。
2.办法内联
办法内联,是指在编译过程中遇到办法调用时,将指标办法的办法体纳入编译范畴之中,并取代原办法调用的优化伎俩。JIT大部分的优化都是在内联的根底上进行的,办法内联是即时编译器中十分重要的一环。
Java服务中存在大量getter/setter办法,如果没有办法内联,在调用getter/setter时,程序执行时须要保留以后办法的执行地位,创立并压入用于getter/setter的栈帧、拜访字段、弹出栈帧,最初再复原以后办法的执行。内联了对 getter/setter的办法调用后,上述操作仅剩字段拜访。在C2编译器 中,办法内联在解析字节码的过程中实现。当遇到办法调用字节码时,编译器将依据一些阈值参数决定是否须要内联以后办法的调用。如果须要内联,则开始解析指标办法的字节码。比方上面这个示例(来源于网络):
办法内联的过程
public static boolean flag = true;public static int value0 = 0;public static int value1 = 1;public static int foo(int value) { int result = bar(flag); if (result != 0) { return result; } else { return value; }}public static int bar(boolean flag) { return flag ? value0 : value1;}
bar办法的IR图:
内联后的IR图:
内联不仅将被调用办法的IR图节点复制到调用者办法的IR图中,还要实现其余操作。
被调用办法的参数替换为调用者办法进行办法调用时所传入参数。下面例子中,将bar办法中的1号P(0)节点替换为foo办法3号LoadField节点。
调用者办法的IR图中,办法调用节点的数据依赖会变成被调用办法的返回。如果存在多个返回节点,会生成一个Phi节点,将这些返回值聚合起来,并作为原办法调用节点的替换对象。图中就是将8号==节点,以及12号Return节点连贯到原5号Invoke节点的边,而后指向新生成的24号Phi节点中。
如果被调用办法将抛出某种类型的异样,而调用者办法恰好有该异样类型的处理器,并且该异样处理器笼罩这一办法调用,那么即时编译器须要将被调用办法抛出异样的门路,与调用者办法的异样处理器相连接。
办法内联的条件
编译器的大部分优化都是在办法内联的根底上。所以一般来说,内联的办法越多,生成代码的执行效率越高。然而对于即时编译器来说,内联的办法越多,编译工夫也就越长,程序达到峰值性能的时刻也就比拟晚。
能够通过虚拟机参数-XX:MaxInlineLevel调整内联的层数,以及1层的间接递归调用(能够通过虚拟机参数-XX:MaxRecursiveInlineLevel调整)。一些常见的内联相干的参数如下表所示:
虚函数内联
内联是JIT晋升性能的次要伎俩,然而虚函数使得内联是很难的,因为在内联阶段并不知道他们会调用哪个办法。例如,咱们有一个数据处理的接口,这个接口中的一个办法有三种实现add、sub和multi,JVM是通过保留虚函数表Virtual Method Table(以下称为VMT)存储class对象中所有的虚函数,class的实例对象保留着一个VMT的指针,程序运行时首先加载实例对象,而后通过实例对象找到VMT,通过VMT找到对应办法的地址,所以虚函数的调用比间接指向办法地址的classic call性能上会差一些。很可怜的是,Java中所有非公有的成员函数的调用都是虚调用。
C2编译器曾经足够智能,可能检测这种状况并会对虚调用进行优化。比方上面这段代码例子:
virtual call
public class SimpleInliningTest{ public static void main(String[] args) throws InterruptedException { VirtualInvokeTest obj = new VirtualInvokeTest(); VirtualInvoke1 obj1 = new VirtualInvoke1(); for (int i = 0; i < 100000; i++) { invokeMethod(obj); invokeMethod(obj1); } Thread.sleep(1000); } public static void invokeMethod(VirtualInvokeTest obj) { obj.methodCall(); } private static class VirtualInvokeTest { public void methodCall() { System.out.println("virtual call"); } } private static class VirtualInvoke1 extends VirtualInvokeTest { @Override public void methodCall() { super.methodCall(); } }}
通过JIT编译器优化后,进行反汇编失去上面这段汇编代码:
0x0000000113369d37: callq 0x00000001132950a0 ; OopMap{off=476} ;*invokevirtual methodCall //代表虚调用 ; - SimpleInliningTest::invokeMethod@1 (line 18) ; {optimized virtual_call} //虚调用曾经被优化
能够看到JIT对methodCall办法进行了虚调用优化optimized virtual_call。通过优化后的办法能够被内联。然而C2编译器的能力无限,对于多个实现办法的虚调用就“无能为力”了。
比方上面这段代码,咱们减少一个实现:
多实现的虚调用
public class SimpleInliningTest{ public static void main(String[] args) throws InterruptedException { VirtualInvokeTest obj = new VirtualInvokeTest(); VirtualInvoke1 obj1 = new VirtualInvoke1(); VirtualInvoke2 obj2 = new VirtualInvoke2(); for (int i = 0; i < 100000; i++) { invokeMethod(obj); invokeMethod(obj1); invokeMethod(obj2); } Thread.sleep(1000); } public static void invokeMethod(VirtualInvokeTest obj) { obj.methodCall(); } private static class VirtualInvokeTest { public void methodCall() { System.out.println("virtual call"); } } private static class VirtualInvoke1 extends VirtualInvokeTest { @Override public void methodCall() { super.methodCall(); } } private static class VirtualInvoke2 extends VirtualInvokeTest { @Override public void methodCall() { super.methodCall(); } }}
通过反编译失去上面的汇编代码:
代码块
0x000000011f5f0a37: callq 0x000000011f4fd2e0 ; OopMap{off=28} ;*invokevirtual methodCall //代表虚调用 ; - SimpleInliningTest::invokeMethod@1 (line 20) ; {virtual_call} //虚调用未被优化
能够看到多个实现的虚调用未被优化,仍然是virtual_call。
Graal编译器针对这种状况,会去收集这部分执行的信息,比方在一段时间,发现后面的接口办法的调用add和sub是各占50%的几率,那么JVM就会在每次运行时,遇到add就把add内联进来,遇到sub的状况再把sub函数内联进来,这样这两个门路的执行效率就会晋升。在后续如果遇到其余不常见的状况,JVM就会进行去优化的操作,在那个地位做标记,再遇到这种状况时切换回解释执行。
3. 逃逸剖析
逃逸剖析是“一种确定指针动静范畴的动态剖析,它能够剖析在程序的哪些地方能够拜访到指针”。Java虚拟机的即时编译器会对新建的对象进行逃逸剖析,判断对象是否逃逸出线程或者办法。即时编译器判断对象是否逃逸的根据有两种:
- 对象是否被存入堆中(动态字段或者堆中对象的实例字段),一旦对象被存入堆中,其余线程便能取得该对象的援用,即时编译器就无奈追踪所有应用该对象的代码地位。
- 对象是否被传入未知代码中,即时编译器会将未被内联的代码当成未知代码,因为它无奈确认该办法调用会不会将调用者或所传入的参数存储至堆中,这种状况,能够间接认为办法调用的调用者以及参数是逃逸的。
逃逸剖析通常是在办法内联的根底上进行的,即时编译器能够依据逃逸剖析的后果进行诸如锁打消、栈上调配以及标量替换的优化。上面这段代码的就是对象未逃逸的例子:
pulbic class Example{ public static void main(String[] args) { example(); } public static void example() { Foo foo = new Foo(); Bar bar = new Bar(); bar.setFoo(foo); } } class Foo {} class Bar { private Foo foo; public void setFoo(Foo foo) { this.foo = foo; } }}
在这个例子中,创立了两个对象foo和bar,其中一个作为另一个办法的参数提供。该办法setFoo()存储对收到的Foo对象的援用。如果Bar对象在堆上,则对Foo的援用将逃逸。然而在这种状况下,编译器能够通过逃逸剖析确定Bar对象自身不会对逃逸出example()的调用。这意味着对Foo的援用也不能逃逸。因而,编译器能够平安地在栈上调配两个对象。
锁打消
在学习Java并发编程时会理解锁打消,而锁打消就是在逃逸剖析的根底上进行的。
如果即时编译器可能证实锁对象不逃逸,那么对该锁对象的加锁、解锁操作没就有意义。因为线程并不能取得该锁对象。在这种状况下,即时编译器会打消对该不逃逸锁对象的加锁、解锁操作。实际上,编译器仅需证实锁对象不逃逸出线程,便能够进行锁打消。因为Java虚拟机即时编译的限度,上述条件被强化为证实锁对象不逃逸出以后编译的办法。不过,基于逃逸剖析的锁打消实际上并不多见。
栈上调配
咱们都晓得Java的对象是在堆上调配的,而堆是对所有对象可见的。同时,JVM须要对所调配的堆内存进行治理,并且在对象不再被援用时回收其所占据的内存。如果逃逸剖析可能证实某些新建的对象不逃逸,那么JVM齐全能够将其调配至栈上,并且在new语句所在的办法退出时,通过弹出以后办法的栈桢来主动回收所调配的内存空间。这样一来,咱们便毋庸借助垃圾回收器来解决不再被援用的对象。不过Hotspot虚拟机,并没有进行理论的栈上调配,而是应用了标量替换这一技术。所谓的标量,就是仅能存储一个值的变量,比方Java代码中的根本类型。与之相同,聚合量则可能同时存储多个值,其中一个典型的例子便是Java的对象。编译器会在办法内将未逃逸的聚合量分解成多个标量,以此来缩小堆上调配。上面是一个标量替换的例子:
标量替换
public class Example{ @AllArgsConstructor class Cat{ int age; int weight; } public static void example(){ Cat cat = new Cat(1,10); addAgeAndWeight(cat.age,Cat.weight); }}
通过逃逸剖析,cat对象未逃逸出example()的调用,因而能够对聚合量cat进行合成,失去两个标量age和weight,进行标量替换后的伪代码:
public class Example{ @AllArgsConstructor class Cat{ int age; int weight; } public static void example(){ int age = 1; int weight = 10; addAgeAndWeight(age,weight); }}
局部逃逸剖析
局部逃逸剖析也是Graal对于概率预测的利用。通常来说,如果发现一个对象逃逸出了办法或者线程,JVM就不会去进行优化,然而Graal编译器仍然会去剖析以后程序的执行门路,它会在逃逸剖析根底上收集、判断哪些门路上对象会逃逸,哪些不会。而后依据这些信息,在不会逃逸的门路上进行锁打消、栈上调配这些优化伎俩。
4. Loop Transformations
在文章中介绍C2编译器的局部有提及到,C2编译器在构建Ideal Graph后会进行很多的全局优化,其中就包含对循环的转换,最重要的两种转换就是循环展开和循环拆散。
循环展开
循环展开是一种循环转换技术,它试图以就义程序二进制码大小为代价来优化程序的执行速度,是一种用空间换工夫的优化伎俩。
循环展开通过缩小或打消控制程序循环的指令,来缩小计算开销,这种开销包含减少指向数组中下一个索引或者指令的指针算数等。如果编译器能够提前计算这些索引,并且构建到机器代码指令中,那么程序运行时就能够不用进行这种计算。也就是说有些循环能够写成一些反复独立的代码。比方上面这个循环:
循环展开
public void loopRolling(){ for(int i = 0;i<200;i++){ delete(i); }}
下面的代码须要循环删除200次,通过循环展开能够失去上面这段代码:
循环展开
public void loopRolling(){ for(int i = 0;i<200;i+=5){ delete(i); delete(i+1); delete(i+2); delete(i+3); delete(i+4); }}
这样开展就能够缩小循环的次数,每次循环内的计算也能够利用CPU的流水线晋升效率。当然这只是一个示例,理论进行开展时,JVM会去评估开展带来的收益,再决定是否进行开展。
循环拆散
循环拆散也是循环转换的一种伎俩。它把循环中一次或屡次的非凡迭代分离出来,在循环外执行。举个例子,上面这段代码:
循环拆散
int a = 10;for(int i = 0;i<10;i++){ b[i] = x[i] + x[a]; a = i;}
能够看出这段代码除了第一次循环a = 10以外,其余的状况a都等于i-1。所以能够把非凡状况拆散进来,变成上面这段代码:
循环拆散
b[0] = x[0] + 10;for(int i = 1;i<10;i++){ b[i] = x[i] + x[i-1];}
这种等效的转换打消了在循环中对a变量的需要,从而缩小了开销。
5. 窥孔优化与寄存器调配
前文提到的窥孔优化是优化的最初一步,这之后就会程序就会转换成机器码,窥孔优化就是将编译器所生成的中间代码(或指标代码)中相邻指令,将其中的某些组合替换为效率更高的指令组,常见的比方强度削减、常数合并等,看上面这个例子就是一个强度削减的例子:
强度削减
y1=x1*3 通过强度削减后失去 y1=(x1<<1)+x1
编译器应用移位和加法削减乘法的强度,应用更高效率的指令组。
寄存器调配也是一种编译的优化伎俩,在C2编译器中广泛的应用。它是通过把频繁应用的变量保留在寄存器中,CPU拜访寄存器的速度比内存快得多,能够晋升程序的运行速度。
寄存器调配和窥孔优化是程序优化的最初一步。通过寄存器调配和窥孔优化之后,程序就会被转换成机器码保留在codeCache中。
四、实际
即时编译器状况简单,同时网络上也很少有实战经验,以下是咱们团队的一些调整教训。
1. 编译相干的重* 要参数
- -XX:+TieredCompilation:开启分层编译,JDK8之后默认开启
- -XX:+CICompilerCount=N:编译线程数,设置数量后,JVM会主动调配线程数,C1:C2 = 1:2
- -XX:TierXBackEdgeThreshold:OSR编译的阈值
- -XX:TierXMinInvocationThreshold:开启分层编译后各层调用的阈值
- -XX:TierXCompileThreshold:开启分层编译后的编译阈值
- -XX:ReservedCodeCacheSize:codeCache最大大小
- -XX:InitialCodeCacheSize:codeCache初始大小
-XX:TierXMinInvocationThreshold是开启分层编译的状况下,触发编译的阈值参数,当办法调用次数大于由参数-XX:TierXInvocationThreshold指定的阈值乘以系数,或者当办法调用次数大于由参数-XX:TierXMINInvocationThreshold指定的阈值乘以系数,并且办法调用次数和循环回边次数之和大于由参数-XX:TierXCompileThreshold指定的阈值乘以系数时,便会触发X层即时编译。分层编译开启下会乘以一个系数,系数依据以后编译的办法和编译线程数确定,升高阈值能够晋升编译办法数,一些罕用然而不能编译的办法能够编译优化晋升性能。
因为编译状况简单,JVM也会动静调整相干的阈值来保障JVM的性能,所以不倡议手动调整编译相干的参数。除非一些特定的Case,比方codeCache满了进行了编译,能够适当减少codeCache大小,或者一些十分罕用的办法,未被内联到,连累了性能,能够调整内敛层数或者内联办法的大小来解决。
2. 通过JITwatch剖析编译日志
通过减少-XX:+UnlockDiagnosticVMOptions -XX:+PrintCompilation -XX:+PrintInlining -XX:+PrintCodeCache -XX:+PrintCodeCacheOnCompilation -XX:+TraceClassLoading -XX:+LogCompilation -XX:LogFile=LogPath参数能够输入编译、内联、codeCache信息到文件。然而打印的编译日志多且简单很难间接从其中失去信息,能够应用JITwatch的工具来剖析编译日志。JITwatch首页的Open Log选中日志文件,点击Start就能够开始剖析日志。
如上图所示,区域1中是整个我的项目Java Class包含引入的第三方依赖;区域2是功能区Timeline以图形的模式展现JIT编译的时间轴,Histo是直方图展现一些信息,TopList外面是编译中产生的一些对象和数据的排序,Cache是闲暇codeCache空间,NMethod是Native办法,Threads是JIT编译的线程;区域3是JITwatch对日志剖析后果的展现,其中Suggestions中会给出一些代码优化的倡议,举个例子,如下图中:
咱们能够看到在调用ZipInputStream的read办法时,因为该办法没有被标记为热点办法,同时又“太大了”,导致无奈被内联到。应用-XX:CompileCommand中inline指令能够强制办法进行内联,不过还是倡议审慎应用,除非确定某个办法内联会带来不少的性能晋升,否则不倡议应用,并且过多应用对编译线程和codeCache都会带来不小的压力。
区域3中的-Allocs和-Locks逃逸剖析后JVM对代码做的优化,包含栈上调配、锁打消等。
3. 应用Graal编译器
因为JVM会去依据以后的编译办法数和编译线程数对编译阈值进行动静的调整,所以理论服务中对这一部分的调整空间是不大的,JVM做的曾经足够多了。
为了晋升性能,在服务中尝试了最新的Graal编译器。只须要应用-XX:+UnlockExperimentalVMOptions -XX:+UseJVMCICompiler就能够启动Graal编译器来代替C2编译器,并且响应C2的编译申请,不过要留神的是,Graal编译器与ZGC不兼容,只能与G1搭配应用。
前文有提到过,Graal是一个用Java写的即时编译器,它从Java 9开始便被集成自JDK中,作为试验性质的即时编译器。Graal编译器就是脱身于GraalVM,GraalVM是一个高性能的、反对多种编程语言的执行环境。它既能够在传统的 OpenJDK上运行,也能够通过AOT(Ahead-Of-Time)编译成可执行文件独自运行,甚至能够集成至数据库中运行。
前文提到过数次,Graal的优化都基于某种假如(Assumption)。当假如出错的状况下,Java虚构机会借助去优化(Deoptimization)这项机制,从执行即时编译器生成的机器码切换回解释执行,在必要状况下,它甚至会废除这份机器码,并在从新收集程序profile之后,再进行编译。
这些中激进的伎俩使得Graal的峰值性能要好于C2,而且在Scale、Ruby这种语言Graal体现更加杰出,Twitter目前曾经在服务中大量的应用Graal来晋升性能,企业版的GraalVM使得Twitter服务性能晋升了22%。
应用Graal编译器后性能体现
在咱们的线上服务中,启用Graal编译后,TP9999从60ms -> 50ms ,降落10ms,降落幅度达16.7%。
运行过程中的峰值性能会更高。能够看出对于该服务,Graal编译器带来了肯定的性能晋升。
Graal编译器的问题
Graal编译器的优化形式更加激进,因而在启动时会进行更多的编译,Graal编译器自身也须要被即时编译,所以服务刚启动时性能会比拟差。
思考的解决办法:JDK 9开始提供工具jaotc,同时GraalVM的Native Image都是能够通过动态编译,极大地晋升服务的启动速度的形式,然而GraalVM会应用本人的垃圾回收,这是一种很原始的基于复制算法的垃圾回收,相比G1、ZGC这些优良的新型垃圾回收器,它的性能并不好。同时GraalVM对Java的一些个性反对也不够,比方基于配置的反对,比方反射就须要把所有须要反射的类配置一个JSON文件,在大量应用反射的服务,这样的配置会是很大的工作量。咱们也在做这方面的调研。
五、总结
本文次要介绍了JIT即时编译的原理以及在美团一些实际的教训,还有最前沿的即时编译器的应用成果。作为一项解释型语言中晋升性能的技术,JIT曾经比拟成熟了,在很多语言中都有应用。对于Java服务,JVM自身曾经做了足够多,然而咱们还应该不断深入理解JIT的优化原理和最新的编译技术,从而补救JIT的劣势,晋升Java服务的性能,一直谋求卓越。
六、参考文献
- 《深刻了解Java虚拟机》
- 《Proceedings of the Java™ Virtual Machine Research and Technology Symposium》Monterey, California, USA April 23–24, 2001
- 《Visualization of Program Dependence Graphs》 Thomas Würthinger
- 《深刻拆解Java虚拟机》 郑宇迪
- JIT的Profile神器JITWatch
作者简介
珩智,昊天,薛超,均来自美团AI平台/搜寻与NLP部。
招聘信息
美团搜寻与NLP部,长期招聘搜寻、对话、NLP算法工程师,坐标北京/上海,感兴趣的同学可投递简历至:tech@meituan.com(邮件题目请注明:搜寻与NLP部)。
想浏览更多技术文章,请关注美团技术团队(meituantech)官网微信公众号。