学习 JVM 相关的知识,必然绕不开即时编译器,因为它太重要了。了解了它的基本原理及优化手段,在编程过程中可以让我们有种打开任督二脉的感觉。比如,很多朋友在面试当中还会遇到这样的问题:Java是基于编译执行还是基于解释执行?当你了解了Java的即时编译器,不仅能够轻松回答上述问题,还能如数家珍的讲出JVM在即时编译器上采用的优化技术,而且在实践过程中更深刻的理解代码背后的原理。
字节码是如何运行的
先来探讨字节码是如何运行的。众所周知,Java 有两种运行模式。第一种叫解释执行,所谓解释执行,它是由解释器一行一行的去翻译执行的。第二种叫编译执行,编译执行它会把字节码编译成机器码,然后去执行机器码。下面我们来对比一下解释执行和编译执行。
解释 VS 编译
解释执行的优势在于没有编译的等待时间,但是它的性能相对会差一些,因为一行一行的去翻译,性能可想而知,不会很高。
那么编译执行的优势在于运行效率会高很多。一般认为它比解释执行会快一个数量级。但是编译执行也不是十全十美的,它带来了额得开销,比如额外的内存开销,CPU 开销等等。
那么怎么样查看自己的 Java 是解释执行还是编译执行的呢?非常简单,只需要用 java -version 命令就可以了。
在这里有一个 mixed mode 表示混合模式,也就是部分代码解释执行,部分代码编译执行。
可以使用 -Xint,把 JVM 的执行模式设置成解释执行模式。
如果你想让你的 Spring boot 的项目以解释执行运行的话,那么只要执行 java -xint -version -xxx.jar 就可以了。
可以使用 -Xcomp,让 JVM 优先以编译模式运行。对于不能编译的代码会继续以解释模式运行。
还可以 -Xmixed,让 JVM 以混合模式运行。默认情况下就是混合模式。
那么一般情况下我们的代码一开始就是由解释器解释执行的。但虚拟机发现某一个方法或者是代码块运行的特别频繁的时候,就会认为这些代码是热点代码。这里大家可以留一个疑问,怎么样找到这些热点代码呢,一会儿来揭晓。
为了提高热点代码的执行效率,就会用即时编译器,也就是平时我们说的 JIT,去把这些热点代码编程和本地平台相关的机器码。并且会执行各种层次的优化。这里本地平台有多种层次的含义,比如操作系统的不同(Linux 操作系统和 Windows 操作系统),它的平台是不一样的。
又比如 CPU 架构的不同, X86 CPU 架构和 AIM CPU 架构也可以认为平台不同。
还可以执行各种层次的优化,这种各种层次的优化究竟是什么呢?一会儿也会详细揭晓。
HotSpot 的即时编译器
那么就目前来说,我们平时比较熟悉的 HotSpot 虚拟机,它内置了两个即时编译器,分别是 C1 编译器(Client Compiler)和 C2 编译器( Server Compiler)。Client Compiler 和 Server Compiler,它们的作用也不同。Client Compiler 注重启动速度和局部的优化,Server Compiler 则更加关注全局的优化,性能会更好,但由于会进行更多的全局分析,所以启动速度会变慢。两种编译器有着不同的应用场景,在虚拟机中同时发挥作用。
C1 编译器(Client Compiler)
先来看一下 C1 编译器是什么。C1 是一个简单快速的编译器,它的主要关注点在于局部性的优化,适合用来执行一些时间比较短,对启动性能有要求的程序。比如带有界面的应用程序,比方说我们的 IDEA。一般来说,我们希望这种程序启动的时候能够快一些,这个时候就比较适合用 C1 编译器。C1 编译器也被俗称为是 Client Complier。Client Complier 可获取更高的编译速度。
C1会做三件事:
第一:局部简单可靠的优化,比如字节码上进行的一些基础优化,方法内联、常量传播等,放弃许多耗时较长的全局优化。
第二:将字节码构造成高级中间表示(High-level Intermediate Representation,以下称为HIR),HIR 与平台无关,通常采用图结构,更适合 JVM 对程序进行优化。
第三:最后将 HIR 转换成低级中间表示(Low-level Intermediate Representation,以下称为LIR),在 LIR 的基础上会进行寄存器分配、窥孔优化(局部的优化方式,编译器在一个基本块或者多个基本块中,针对已经生成的代码,结合 CPU 自己指令的特点,通过一些认为可能带来性能提升的转换规则或者通过整体的分析,进行指令转换,来提升代码性能)等操作,最终生成机器码。
C2 编译器(Server Compiler)
接着来看一 C2 编译器,C2 编译器是为长期运行的服务器端程序做性能调优的。那么它适用于执行时间较长,或者对峰值性能有要求的程序。也被俗称为是 Server Compiler。比如我们的 Spring Boot 的程序,它就是一个长期运行的服务端应用程序,那么 Spring Boot 程序的就比较适合用 C2 去执行。
讲到这里,我们应该知道两件事情。首先,Java 有解释执行以及编译执行。第二 Java 有两个即时编译器,即 C1 编译器以及 C2 编译器,C1 编译器适合用来执行客户端程序,C2 编译器适合用来执行服务端程序。
目前,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 中得的,生成机器码前还会有一些包括寄存器分配、窥孔优化等操作。
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 负责的编译请求。
分层编译
那么前面我们也说过,会执行各种层次的优化。那么所谓的各种层次是怎样的呢,来看一下。
在 Java 7 以前,需要研发人员根据服务的性质去选择编译器。对于需要快速启动的,或者一些不会长期运行的服务,可以采用编译效率较高的 C1,对应参数 -client。长期运行的服务,或者对峰值性能有要求的后台服务,可以采用峰值性能更好的 C2,对应参数 -server。
从 JDK 7 开始正式引入了分层编译的概念,它结合了C1和C2的优势,追求启动速度和峰值性能的一个平衡。
可以细分为五种变异级别编译级别:6
第零个级别是解释执行。
第一个级别是简单 C1 编译,这个级别下会使用 C1 编译器进行一些简单的优化,并且不会开启 Profiling。所谓的 Profiling,也就是 JVM 的性能监控。
第二个级别是受限的 C1 编译,这个级别下仅会执行带有方法调用次数以及循环回边执行次数 Profiling 的 C1 编译。那么方法调用次数以及循环汇编执行次数,一会儿会详细探讨。
第三个级别是完全ce 编译,在这个级别下,会执行带有所有 Profiling 的 C1 代码。
第四个级别也就是最高级别 C2 编译,这个级别会使用 C2 编译器进行优化。并且会启用一些编译耗时比较长的优化。那么某些情况下呢,甚至会根据性能监控的信息进行一些非常激进的性能优化。那么一般来说级别越高,应用的启动也会越慢,优化的开销也会越高。当然了,峰值性能也会越高。
值得注意的是:Profiling 就是收集能够反映程序执行状态的数据。其中最基本的统计数据就是方法的调用次数,以及循环回边的执行次数。
所以现在大家应该知道,你所谓的各种层次的优化是什么意思了。
我们接着看,默认情况下 JDK 8 是开启分层编译的。如果你只想开 C2 编译器不想使用 C1 的话,该怎么办呢?可以使用这个参数:-XX:-TieredCompilation 关闭掉分层编译。关闭分层编译指的是关闭掉中间编译层,也就是前面的 123 层,这样只有 0 和 4 层解释执行和 C2 编译。
那么如果只想使用 C1 编译器不想使用 C2 编译器该怎么办呢?可以这么玩使用这个参数:-XX:+TieredCompilation -XX:TieredStopAtLevel=1 开启分层编译。同时把TieredStopAtLevel 设置成你想要的数字,比如设置 3,这样就会使用前面的 0123 这个级别,但是不会使用到 4。
那么前面说了编译执行主要是针对热点代码的,那么怎么样找到热点代码呢?这目前来说业界找到热点代码的思路有两种:
基于采样的热点探测
第一种是基于采样的热点探测,大致的思路是周期检查各个线程的栈顶,如果发现某一些方法老是出现在栈顶,说明这个方法是热点方法,你想每一个线程都会有自己独立的栈,进入一个方法的时候,会往栈里面加入一个元素,并且放在栈的顶部。如果经过周期性的检查,发现某一些方法总是出现在栈顶的话,说明很多的线程老是在执行这一个方法,那么说明这个方法是热点方法。实现简单、高效,很容易获取方法调用关系。但很难确认方法的 reduce,容易受到线程阻塞或其他外因扰乱。
基于计数器的热点探测
第二种是基于计数器的热点探测,大致思路是为每一个方法甚至是代码块建立计数器,然后统计执行的次数,如果超过一定阈值,就认为它是热点方法。HotSpot 虚拟机使用的是基于计数器的热点探测,它为每个方法准备了两类计数器:
第一种是方法调用计数器(Invocation Counter),方法调用计数器用来统计被调用的次数。在不开启分层编译的情况下,如果使用的是 C1 编译器,那么默认的阈值是 1500 次。如果使用的是 C2 编译器,那么阈值是 10000次,也可以使用这个参数:-XX:CompileThreshold=x 指定你想要的阈值,达到这个阈值就说明是热点方法。
下面来探讨一下方法调用计数器的执行流程。下图描述了方法调用计数器去控制是不是要编译运行的流程。
可以看到一个方法在被调用的时候,会先检查这个方法是不是有编译过的版本。如果已经有编译后的版本,就直接执行编译后的代码。如果还不存在,那么就会把这个方法的调用计数器值 + 1,然后去判断方法调用计数器和回边计数器的和是不是超过阈值,这个阈值是动态调整的,因为默认情况下是开启分层编译,如果已经超过阈值的话,就像编译器提交编译请求。这里的编译器可能是 C1,也可能是 C2,提交完请求之后,执行引擎并不会同步等待编译请求完成,而是继续以解释执行的方式运行代码。当编译完成之后,下一次调用这个方法的时候,就会使用已经编译的代码去执行了。
另外,如果不做任何设置的话,方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的频率,也就是一段时间之内这个方法被调用的次数。如果超过一定的时间限度,这个方法调用的次数还是不能让它提交这里的编译请求的话。那么这个方法的调用计数器值就会被减少一半,这个过程被称为是方法调用计数器热度的衰减。然后这个所谓的时间限度,就被称为是这个方法的统计半衰周期。这个衰减的动作是在虚拟机垃圾回收的时候顺便执行的。你可以使用这个参数: -XX:UseCounterDecay 去关闭这个衰减,关闭掉热度衰减之后,方法计数器统计的就是绝对次数了。这样你的系统只要运行的时间够长,总有一天会超过阈值,这样的绝大部分代码都可以编译执行。另外你也可以使用这个参数:-XX:CounterHalfLifeTime 去设置半衰周期,单位为秒。
第二种计数器是回边计数器(Back Edge Counter),那么回边计数器主要由来统计一个方法中,循环体代码的执行次数,回边是一个术语,在字节码里面遇到控制流向后跳转的指令,那么就称为是“回边”(Back Edge)。那么在不开启分层编译的情况下,如果你使用的是C1 编译器,默认阈值是 13995 次。如果你使用的是 C2 编译器,那么默认阈值是 10700 次。你也可以使用这个参数:-XX:OnStackReplacePercentage 去指定阈值,达到这个阈值就说是热点方法。
注意:HotSpot 虚拟机提供了 - XX:BackEdgeThreshold 来进行设置,但当前的虚拟机实际上使用了 - XX:OnStackReplacePercentage 来间接调整阈值。
建立回边计数器的主要目的是为了触发 OSR 编译,OSR 全称叫 Old Stack Replacement。
所谓的 OSR 编译是一种在运行时替换正在运行的函数的栈帧的技术。
如果你开启了分层编译,那么 JVM 会根据当前待编译的方法数目以及编译的线程数,动态的调整阈值。通过 -XX:CompileThreshold 以及 -XX:OnStackReplacePercentage 参数制定的阈值都会失效。
再来看一下回边计数器是怎样的流程。
当解释器遇到一条回边指令的时候,会先查找将要执行的代码片段是否已经有编译好的版本,如果有的话,就直接执行编译的代码。如果没有,回边计数器值会 +1,然后会判断方法调用计数器和回边计数器的和是不是超过阈值。如果超过阈值的话,就像编译器提交一个 OSR 编译的请求。同时会把回边计数器的值降低一些,然后也是继续以解释方式运行代码,当编译器编译结束之后,后面的请求就可以编译执行了。
回边计数器阈值计算公式:
在 Client 模式下, 公式为 “方法调用计数器阈值(CompileThreshold)X OSR 比率(OnStackReplacePercentage)/ 100” 。其中 OSR 比率默认为 933,那么,回边计数器的阈值为 13995。
在 Server 模式下,公式为 “方法调用计数器阈值(Compile Threashold)X (OSR 比率 (OnStackReplacePercentage) - 解释器监控比率(InterpreterProfilePercent))/100”。
其中 OnStackReplacePercentage 默认值为 140,InterpreterProfilePercentage 默认值为 33,如果都取默认值,那么 Server 模式虚拟机回边计数器阈值为 10700。
总结
简单总结一下本课时的内容。这节课首先介绍了解释运行和编译运行,然后探到了 JVM 内置的两款即时编译器,即 C1 编译器以及 C2 编译器。
其次,探讨了分层编译,一共分成五层,以及 JVM 是如何找到热点代码的,主要通过方法调用计数器以及回边计数器去找到热点代码。
最后,总结一下本课时所涉及到的 JVM 参数。
参数 | 参数作用 |
---|---|
-Xmixed | 设置 JVM 的执行模式为混合模式运行,一般情况是默认的 |
-Xint | 设置 JVM 的执行模式为解释执行模式 |
-Xcomp | 设置 JVM 优先以编译模式运行,不能编译的,以解释模式运行 |
-XX:TieredCompilation | 水查用中间编译层 |
-XX:TieredStopAtLevel | 到哪个分层停止 |
-XX:CompileThreshold=x | 指定方法调用计数器阈值(关闭分层编译时才有效) |
-xX:OnStackReplacePercentage=x | 指定回边计数器阈值(关闭分层编译时才有效) |
-XX:UseCounterDecay | 关闭方法调用计数器热度衰减 |
-XX:CounterHalfLifeTime | 指定方法调用计数器半衰周期(秒) |
总的来说本课时的内容非常的琐碎,同学们有需要花一些精力去记录笔记,去记忆,甚至需要多看几遍才能完全掌握本课时的内容。