关于java:基本功-Java即时编译器原理解析及实践

7次阅读

共计 17923 个字符,预计需要花费 45 分钟才能阅读完成。

一、导读

常见的编译型语言如 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 的执行状态分为了五个档次。五个层级别离是:

  1. 解释执行。
  2. 执行不带 profiling 的 C1 代码。
  3. 执行仅带办法调用次数以及循环回边执行次数 profiling 的 C1 代码。
  4. 执行带所有 profiling 的 C1 代码。
  5. 执行 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 = 0
if 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 虚拟机的即时编译器会对新建的对象进行逃逸剖析,判断对象是否逃逸出线程或者办法。即时编译器判断对象是否逃逸的根据有两种:

  1. 对象是否被存入堆中(动态字段或者堆中对象的实例字段),一旦对象被存入堆中,其余线程便能取得该对象的援用,即时编译器就无奈追踪所有应用该对象的代码地位。
  2. 对象是否被传入未知代码中,即时编译器会将未被内联的代码当成未知代码,因为它无奈确认该办法调用会不会将调用者或所传入的参数存储至堆中,这种状况,能够间接认为办法调用的调用者以及参数是逃逸的。

逃逸剖析通常是在办法内联的根底上进行的,即时编译器能够依据逃逸剖析的后果进行诸如锁打消、栈上调配以及标量替换的优化。上面这段代码的就是对象未逃逸的例子:

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)官网微信公众号。

正文完
 0