当我们在写代码时,一个方法内部的行数自然是越少越好,这样逻辑清晰、方便阅读,其实好处远不止如此,通过即时编译,甚至可以提高执行时的性能,今天就让我们好好来了解一下其中的原理。
当 JVM 的初始化完成后,类在调用执行过程中,执行引擎会把字节码转为机器码,然后在操作系统中才能执行。在字节码转换为机器码的过程中,虚拟机中还存在着一道编译,那就是即时编译
。
最初,JVM 中的字节码是由解释器( Interpreter )完成编译的,当虚拟机发现某个方法或代码块的运行特别频繁的时候,就会把这些代码认定为热点代码
。
为了提高热点代码的执行效率,在运行时,即时编译器(JIT,Just In Time)会把这些代码编译成与本地平台相关的机器码,并进行各层次的优化,然后保存到内存中。
在 HotSpot 虚拟机中,内置了两种 JIT,分别为C1 编译器
和C2 编译器
,这两个编译器的编译过程是不一样的。
C1 编译器是一个简单快速的编译器,主要的关注点在于局部性的优化,适用于执行时间较短或对启动性能有要求的程序,也称为Client Compiler
,例如,GUI 应用对界面启动速度就有一定要求。
C2 编译器是为长期运行的服务器端应用程序做性能调优的编译器,适用于执行时间较长或对峰值性能有要求的程序,也称为Server Compiler
,例如,服务器上长期运行的 Java 应用对稳定运行就有一定的要求。
在 Java7 之前,需要根据程序的特性来选择对应的 JIT,虚拟机默认采用解释器和其中一个编译器配合工作。
Java7 引入了分层编译,这种方式综合了 C1 的启动性能优势和 C2 的峰值性能优势,我们也可以通过参数 -client
或者-server
强制指定虚拟机的即时编译模式。
分层编译将 JVM 的执行状态分为了 5 个层次:
第 0 层:程序解释执行,默认开启性能监控功能(Profiling),如果不开启,可触发第二层编译; 第 1 层:可称为 C1 编译,将字节码编译为本地代码,进行简单、可靠的优化,不开启 Profiling; 第 2 层:也称为 C1 编译,开启 Profiling,仅执行带方法调用次数和循环回边执行次数 profiling 的 C1 编译; 第 3 层:也称为 C1 编译,执行所有带 Profiling 的 C1 编译; 第 4 层:可称为 C2 编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。
对于 C1 的三种状态,按执行效率从高至低:第 1 层、第 2层、第 3层。
通常情况下,C2 的执行效率比 C1 高出30%以上。
在 Java8 中,默认开启分层编译,-client
和 -server
的设置已经是无效的了。如果只想开启 C2,可以关闭分层编译(-XX:-TieredCompilation
),如果只想用 C1,可以在打开分层编译的同时,使用参数:-XX:TieredStopAtLevel=1
。
你可以通过 java -version
命令行可以直接查看到当前系统使用的编译模式:
C:\Users\Administrator>java -version
java version "1.8.0_45"
Java(TM) SE Runtime Environment (build 1.8.0_45-b14)
Java HotSpot(TM) 64-Bit Server VM (build 25.45-b02, mixed mode)
mixed mode
代表是默认的混合编译模式,除了这种模式外,我们还可以使用-Xint
参数强制虚拟机运行于只有解释器的编译模式下,这时 JIT 完全不介入工作;也可以使用参数-Xcomp
强制虚拟机运行于只有 JIT 的编译模式下。例如:
C:\Users\Administrator>java -Xint -version
java version "1.8.0_45"
Java(TM) SE Runtime Environment (build 1.8.0_45-b14)
Java HotSpot(TM) 64-Bit Server VM (build 25.45-b02, interpreted mode)
C:\Users\Administrator>java -Xcomp -version
java version "1.8.0_45"
Java(TM) SE Runtime Environment (build 1.8.0_45-b14)
Java HotSpot(TM) 64-Bit Server VM (build 25.45-b02, compiled mode)
在 HotSpot 虚拟机中,热点探测
是 JIT 的触发标准。
热点探测是基于计数器的热点探测,采用这种方法的虚拟机会为每个方法建立计数器统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法” 。
虚拟机为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阈值,当计数器超过阈值溢出了,就会触发 JIT 编译。
方法调用计数器用于统计方法被调用的次数,默认阈值在 C1 模式下是 1500 次,在 C2 模式在是 10000 次,可通过-XX: CompileThreshold
来设定;而在分层编译的情况下-XX: CompileThreshold
指定的阈值将失效,此时将会根据当前待编译的方法数以及编译线程数来动态调整。当方法计数器和回边计数器之和超过方法计数器阈值时,就会触发 JIT 编译器。
回边计数器用于统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”(Back Edge),该值用于计算是否触发 C1 编译的阈值,在不开启分层编译的情况下,C1 默认为 13995,C2 默认为 10700,可通过-XX: OnStackReplacePercentage=N
来设置;而在分层编译的情况下,-XX: OnStackReplacePercentage
指定的阈值同样会失效,此时将根据当前待编译的方法数以及编译线程数来动态调整。
建立回边计数器的主要目的是为了触发 OSR(On StackReplacement)编译,即栈上编译。在一些循环周期比较长的代码段中,当循环达到回边计数器阈值时,JVM 会认为这段是热点代码,JIT 编译器就会将这段代码编译成机器语言并缓存,在该循环时间段内,会直接将执行代码替换,执行缓存的机器语言。
JIT 编译运用了一些经典的编译优化技术来实现代码的优化,即通过一些例行检查优化,可以智能地编译出运行时的最优性能代码。主要有两种:方法内联
、逃逸分析
。
调用一个方法通常要经历压栈和出栈。调用方法是将程序执行顺序转移到存储该方法的内存地址,将方法的内容执行完后,再返回到执行该方法前的位置。
这种执行操作要求在执行前保护现场并记忆执行的地址,执行后要恢复现场,并按原来保存的地址继续执行。因此,方法调用会产生一定的时间和空间方面的开销(其实可以理解为一种上下文切换
的精简版)。
那么对于那些方法体代码不是很大,又频繁调用的方法来说,这个时间和空间的消耗会很大。
方法内联的优化行为就是把目标方法的代码复制到发起调用的方法之中,避免发生真实的方法调用。
JVM 会自动识别热点方法,并对它们使用方法内联进行优化。我们可以通过-XX:CompileThreshold
来设置热点方法的阈值。但要强调一点,热点方法不一定会被 JVM 做内联优化,如果这个方法体太大了,JVM 将不执行内联操作。而方法体的大小阈值,我们也可以通过参数设置来优化:
-XX:MaxFreqInlineSize=N
来设置大小值;-XX:MaxInlineSize=N
来重置大小值。之后我们就可以通过配置 JVM 参数来查看到方法被内联的情况:
// 在控制台打印编译过程信息
-XX:+PrintCompilation
// 解锁对 JVM 进行诊断的选项参数。默认是关闭的,开启后支持一些特定参数对 JVM 进行诊断
-XX:+UnlockDiagnosticVMOptions
// 将内联方法打印出来
-XX:+PrintInlining
热点方法的优化可以有效提高系统性能,一般我们可以通过以下几种方式来提高方法内联:
此处就联系到了最开始提出的观点,一个方法中的内容越少,当该方法经常被执行时,则容易进行方法内联,从而优化性能。
逃逸分析(Escape Analysis)是判断一个对象是否被外部方法引用或外部线程访问的分析技术,编译器会根据逃逸分析的结果对代码进行优化。
可以通过JVM参数进行设置:
-XX:+DoEscapeAnalysis 开启逃逸分析(jdk1.8 默认开启)
-XX:-DoEscapeAnalysis 关闭逃逸分析
其具体优化方法主要有三种:栈上分配
、锁消除
、标量替换
。
在 Java 中默认创建一个对象是在堆中分配内存的,而当堆内存中的对象不再使用时,则需要通过垃圾回收机制回收,这个过程相对分配在栈中的对象的创建和销毁来说,更消耗时间和性能。
这个时候,逃逸分析如果发现一个对象只在方法中使用,就会将对象分配在栈上。
但是,HotSpot 虚拟机目前的实现导致栈上分配实现比较复杂,可以说,在 HotSpot 中暂时没有实现这项优化,所以大家可能暂时无法体会到这种优化(我看的资料显示在 Java8 中还没有实现,如果大家有什么其他的发现,欢迎留言)。
如果是在单线程环境下,其实完全没有必要使用线程安全的容器,但就算使用了,因为不会有线程竞争,这个时候 JIT 编译会对这个对象的方法锁进行锁消除。例如:
public static String getString(String s1, String s2) {
StringBuffer sb = new StringBuffer();
sb.append(s1);
sb.append(s2);
return sb.toString();
}
可以通过JVM参数进行设置:
-XX:+EliminateLocks 开启锁消除(jdk1.8 默认开启)
-XX:-EliminateLocks 关闭锁消除
逃逸分析证明一个对象不会被外部访问,如果这个对象可以被拆分的话,当程序真正执行的时候可能不创建这个对象,而直接创建它的成员变量来代替。将对象拆分后,可以分配对象的成员变量在栈或寄存器上,原本的对象就无需分配内存空间了。这种编译优化就叫做标量替换。
例如:
public void foo() {
TestInfo info = new TestInfo();
info.id = 1;
info.count = 99;
// to do something
}
逃逸分析后,代码会被优化为:
public void foo() {
id = 1;
count = 99;
// to do something
}
可以通过JVM参数进行设置:
-XX:+EliminateAllocations 开启标量替换(jdk1.8 默认开启)
-XX:-EliminateAllocations 关闭就可以了
今天的内容,由最基本的常识方法内部行数和逻辑需要尽可能简单
引出,了解了 JVM 通过即时编译对热点代码进行优化的过程。如果你有什么想法,欢迎在下方留言。