JIT编译器
Posted 未来极客流
tags:
篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了JIT编译器相关的知识,希望对你有一定的参考价值。
实时(JIT)编译器是Java虚拟机的大脑。JVM中的任何内容都不会比JIT编译器更多地影响性能。
让我们退一步看看编译和非编译语言的例子。
Go,C和C ++等语言称为编译语言,因为它们的程序是作为二进制(编译)代码分发的,目标是特定的CPU。
另一方面,php和Perl等语言是解释语言。只要机器有解释器,就可以在任何CPU上运行相同的程序源代码。解释器在执行该行时将程序的每一行转换为二进制代码。
Java试图在解释器和编译器之间寻找一个平衡。编译Java应用程序,但不是将其编译为特定CPU的特定二进制文件,而是将它们编译为字节码。这为Java提供了解释语言的平台独立性。但Java并不止于此。
在典型的程序中,只有一小部分代码经常执行,而应用程序的性能主要取决于这些代码段的执行速度。这些关键部分被称为应用的热点。
JVM执行特定代码段的次数越多,它所拥有的信息就越多。这允许JVM做出智能/优化的决策,并将小的热代码编译成CPU特定的二进制代码。此过程称为实时编译(JIT)。
代码优化
当选择一种方法进行编译时,JVM将其字节码提供给即时编译器(JIT)。在正确编译方法之前,JIT需要理解字节码的语义和语法。为了帮助JIT编译器分析该方法,首先在一个名为的内部表示中重新构造其字节码树,它比字节码更接近机器码。然后对该方法的树执行分析和优化。最后,树被翻译成本机代码。JIT编译器可以使用多个编译线程来执行JIT编译任务。使用多个线程可以帮助Java应用程序更快地启动。实际上,只有在系统中存在未使用的处理核心的情况下,多个JIT编译线程才会显示性能改进。编译线程的默认数量由JVM标识,并且取决于系统配置。如果生成 的线程数不是最佳,则可以使用 XcompilationThreads
选项来设置来替换默认值。有关使用此选项的信息,请参阅JIT和AOT命令行选项。
编译模式
在Java HotSpot VM中,实际上有两种独立的JIT编译器模式,称为C1和C2。C1用于需要快速启动和坚如磐石优化的应用; GUI应用程序通常是此编译器的理想选择。另一方面,C2最初用于长期运行,主要是服务器端应用程序。在一些后来的Java SE 7版本之前,这两种模式分别可以使用 -client
和 -server
开关。
这两种编译器模式使用不同的JIT编译技术,它们可以为同一Java方法输出不同的机器代码。但是,现代Java应用程序通常可以使用两种编译模式。为了利用这一事实,从一些后来的Java SE 7版本开始,一个名为分层编译的新功能变得可用。此功能在开始时使用C1编译器模式以提供更好的启动性能。一旦应用程序正确预热,C2编译器模式就会接管以提供更积极的优化,并且通常会提供更好的性能。Java SE 8之后,分层编译已经是JVM默认的编译策略。
客户端编译器
众所周知的优化编译器是C1,即通过 -client
JVM启动选项启用的编译器。正如其名称所示,C1是一个客户端编译器。它专为具有较少可用资源且在许多情况下对应用程序启动时间敏感的客户端应用程序而设计。C1使用性能计数器进行代码分析,以实现简单,相对不引人注目的优化。
服务器端编译器
对于长期运行的应用程序(如服务器端企业Java应用程序),客户端编译器可能还不够。可以使用像C2这样的服务器端编译器。C2通常通过将JVM启动选项添加 -server
到启动命令行来启用。由于大多数服务器端程序预计会运行很长时间,因此启用C2意味着您将能够收集比使用短期运行的轻量级客户端应用程序更多的分析数据。因此,您将能够应用更高级的优化技术和算法。
分层编译
分层编译结合了客户端和服务器端编译。分层编译利用JVM中的客户端和服务器编译器优势。客户端编译器在应用程序启动期间最活跃,并处理由较低性能计数器阈值触发的优化。客户端编译器还插入性能计数器并为更高级的优化准备指令集,服务器端编译器将在稍后阶段解决这些优化。分层编译是一种资源效率非常高的分析方法,因为编译器能够在低影响编译器活动期间收集数据,以后可以用于更高级的优化。这种方法也会产生比单独使用解释的代码配置文件计数器更多的信息。
一些JIT编译技术
Java HotSpot VM使用的最常见的JIT编译技术之一是内联,就是将方法体替换为调用该方法的位置的做法。内联节省了调用方法的成本; 不需要创建新的栈帧。默认情况下,Java HotSpot VM将尝试内联包含少于35个字节的JVM字节码的方法。
Java HotSpot VM所做的另一个常见优化是单态调度,它依赖于观察到的事实,即通常没有通过方法的路径导致对象引用在大多数时间是一种类型而在另一些调用中又是另一种类型。
您可能认为通过不同的代码路径使用不同的类型将被Java的静态类型排除,但请记住,子类的实例始终是父类的有效实例(此原则称为Liskov替换原则,在Barbara Liskov之后)。这种情况意味着可能有两个路径进入一个方法 - 例如,一个传递父类的实例,一个传递一个子类的实例 - 这将是Java静态类型的规则是合法的(并且确实发生在实践中)。
然而,在通常情况下(单形情况),不会发生具有不同的路径依赖类型。因此,我们知道在传递的对象上调用方法时将调用的确切方法定义,因为我们不需要检查实际使用的是哪个覆盖。这意味着我们可以消除进行虚方法查找的开销,因此JIT编译器可以发出优化的机器代码,这通常比等效的C ++调用快(因为在C ++情况下,虚拟查找不容易被消除)。
两个Java HotSpot VM编译器模式使用不同的JIT编译技术,它们可以为同一Java方法输出非常不同的机器代码。但是,现代Java应用程序通常可以混合使用两种编译模式。
Java HotSpot VM使用许多其他技术来优化JIT编译生成的代码。循环优化,类型锐化,死代码(无用)消除和函数内联这些都是Java HotSpot VM尝试尽可能多地优化代码的方式。技术经常一个接一个地叠代,因此一旦应用了一个优化,编译器就可以看到可以执行的更多优化。
内联
内联是一种在运行时优化字节码的方法,将最常执行的方法的调用替换为其主体。
虽然涉及编译,但它不是由传统的javac编译器执行的,而是由JVM本身执行的。更确切地说,它是Just-In-Time(JIT)编译器的责任,它是JVM的一部分; javac只生成字节码,让JIT发挥魔力并进行优化。
内联条件
本质上,JIT编译器尝试内联我们经常调用的方法,以便我们可以避免方法调用的开销。在决定是否内联方法时,需要考虑两件事。
首先,它使用计数器来跟踪我们调用方法的次数。当该方法被调用超过特定次数时,将它标记为“热”方法。默认情况下,此阈值设置为10,000,但我们可以在Java启动期间通过JVM标志配置它。我们绝对不想内联所有内容,因为它会耗费时间并产生巨大的字节码。
我们应该记住,只有在我们达到稳定状态时才会进行内联。这意味着我们需要多次重复执行,以便为JIT编译器提供足够的分析信息。
此外,“热”并不能保证该方法将被内联。如果它太大,JIT就不会内联它。可接受的大小受*-XX:FreqInlineSize *标志的限制,该标志指定内联方法的最大字节码指令数。
尽管如此,强烈建议不要更改此标志的默认值,除非我们确定知道它可能产生什么影响。默认值取决于平台 - 对于64位Linux,它是325。
JIT一般内联static,private,或final等方法。虽然public方法也会被内联,但并不是每个public方法都必须内联。JVM需要确定这种方法只有一个实现。任何其他子类都会阻止内联,性能将不可避免地降低。
寻找”热“方法
我们当然不想猜测JIT在做什么。因此,我们需要一些方法来查看内联或未内联的方法。我们可以通过在启动期间设置一些额外的JVM标志来轻松实现这一点,并将所有这些信息记录到标准输出:
-XX:+PrintCompilation -XX:+UnlockDiagnosticVMOptions -XX:+PrintInlining
JIT编译发生时,第一个标志将起作用。第二个标志用来启用其他标志,包括-XX:+ PrintInlining,PrintInlining启用时日志将打印内联的方法和位置。
日志将以树的形式向我们展示内联方法。叶子已注释并标有以下选项之一:
inline (hot) - 此方法标记为"热"并且发生内联
too big- 方法不"热",但它生成的字节码太大,所以它没有内联
hot method too big- 这是一个很"热"的方法,但由于字节码太大,所以没有内联
我们应该注意第三个值“hot method too big”,并尝试优化方法。
一般情况下,如果我们找到一个热点方法并且它具有非常复杂的条件语句,我们应该尽量用if-语句将内容分开,并增加粒度,使JIT可以优化代码。这同样适用于switch和for的循环语句。
我们可以得出结论,我们不需要手动去触发方法内联。因为JVM可以更有效地完成它,并且我们可能会使代码变得冗长且难以维护。
例子
现在让我们看看我们如何在实践中验证这一点。我们将首先创建一个简单的类来计算前N个连续正整数的总和:
public class ConsecutiveNumbersSum {
private long totalSum;
private int totalNumbers;
public ConsecutiveNumbersSum(int totalNumbers) {
this.totalNumbers = totalNumbers;
}
public long getTotalSum() {
totalSum = 0;
for (int i = 0; i < totalNumbers; i++) {
totalSum += i;
}
return totalSum;
}
}
接下来,我们用一个简单的方法调用该类来执行计算:
private static long calculateSum(int n) {
return new ConsecutiveNumbersSum(n).getTotalSum();
}
最后,我们将调用该方法多次,看看会发生什么:
for (int i = 1; i < NUMBERS_OF_ITERATIONS; i++) {
calculateSum(i);
}
在第一次运行中,我们将运行1,000次(小于上面提到的10,000的阈值)。如果我们在输出中搜索calculateSum()方法,我们将找不到它。这是预料之中的,因为我们调用它的次数没有达到阈值。
如果我们现在将迭代次数更改为15,000并再次搜索输出,我们将看到如下内容:
664 262 % com.baeldung.inlining.InliningExample::main @ 2 (21 bytes)
@ 10 com.baeldung.inlining.InliningExample::calculateSum (12 bytes) inline (hot)
我们可以看到,这次方法满足内联的条件,JVM内联了它。
值得注意的是,如果方法太大,JIT将不会内联它,无论迭代多少次数。我们可以通过在运行应用程序时添加另一个标志来检查:
-XX:FreqInlineSize=5
正如我们在前面的输出中看到的,我们的方法的大小是12个字节。的-XX:FreqInlineSize标志将限制方法大小可内联到5个字节。因此,这次不会进行内联。事实上,我们可以通过输出来证实这一点:
330 266 % com.baeldung.inlining.InliningExample::main @ 2 (21 bytes)`` ``@ 10 com.baeldung.inlining.InliningExample::calculateSum (12 bytes) hot method too big
虽然为了便于说明我们在这里更改了标志值,但我们建议不要更改-XX:FreqInlineSize标志的默认值,除非真的有必要。
逃逸分析
逃逸分析是Java Hotspot Server编译器可以分析新对象使用范围并决定是否在Java堆上分配它的技术。
默认情况下,Java SE 6u23及更高版本支持并启用逃逸分析。
基于逃逸分析,对象的逃逸状态可能是以下之一:
GlobalEscape
- 对象逃逸方法和线程。例如,存储在静态字段中的对象,或者存储在逃逸对象中的字段,或者作为当前方法的结果返回的对象。ArgEscape
- 作为参数传递或由参数引用但在调用期间不会全局逃逸的对象。通过分析被调用方法的字节码来确定该状态。NoEscape
- 标量可替换对象,意味着可以从生成的代码中删除其分配。
在逃逸分析之后,服务器编译器从生成的代码中消除了标量可替换对象分配和关联锁。服务器编译器还消除了所有非全局逃逸对象的锁。它不与非全局逃逸对象堆栈分配代替堆分配。
优化
编译器可以使用逃逸分析的结果作为优化的基础:
将堆分配转化为栈分配。如果某个对象在子程序中被分配,并且指向该对象的指针永远不会逃逸,该对象就可以在分配在栈上,而不是在堆上。在有垃圾收集的语言中,这种优化可以降低垃圾收集器运行的频率。
同步消除。如果发现某个对象只能从一个线程可访问,那么在这个对象上的操作可以不需要同步。
分离对象或标量替换。如果某个对象的访问方式不要求该对象是一个连续的内存结构,那么对象的部分(或全部)可以不存储在内存,而是存储在CPU寄存器中。
接下来描述一些逃逸分析的场景。
服务器编译器可能会消除某些对象分配。考虑以下示例:
虚拟机配置参数:-XX:+PrintGC -Xms5M -Xmn5M -XX:+DoEscapeAnalysis
-XX:+DoEscapeAnalysis表示开启逃逸分析,JDK8是默认开启的
-XX:+PrintGC 表示打印GC信息
-Xms5M -Xmn5M 设置JVM内存大小是5M
运行结果是没有GC。
把虚拟机参数改成 -XX:+PrintGC -Xms5M -Xmn5M -XX:-DoEscapeAnalysis。关闭逃逸分析得到结果的部分截图是,说明了进行了GC。
这说明了JVM在逃逸分析之后,将对象分配在了方法createObject()方法栈上。方法栈上的对象在方法执行完之后,栈桢弹出,对象就会自动回收。这样的话就不需要等内存满时再触发内存回收。这样的好处是程序内存回收效率高,并且GC频率也会减少,程序的性能就提高了。
[GC (Allocation Failure) 4096K->504K(5632K), 0.0012864 secs]
[GC (Allocation Failure) 4600K->456K(5632K), 0.0008329 secs]
[GC (Allocation Failure) 4552K->424K(5632K), 0.0006392 secs]
[GC (Allocation Failure) 4520K->440K(5632K), 0.0007061 secs]
[GC (Allocation Failure) 4536K->456K(5632K), 0.0009787 secs]
[GC (Allocation Failure) 4552K->440K(5632K), 0.0007206 secs]
[GC (Allocation Failure) 4536K->520K(5632K), 0.0009295 secs]
[GC (Allocation Failure) 4616K->512K(4608K), 0.0005874 secs]
public static void main(String[] args){
for(int i = 0; i < 5_000_000; i++){
createObject();
}
}
public static void createObject(){
new Object();
}
如果服务器编译器确定对象是线程本地的,则它可能会消除同步块(锁定省略)。例如,
StringBuffer
和Vector
类的方法是同步的,因为它们可以被不同的线程访问。但是,在大多数情况下,它们以线程本地方式使用。如果用法是线程本地的,则编译器可能会优化并删除同步块。我们来看一个例子。虚拟机配置参数:-XX:+PrintGC -Xms500M -Xmn500M -XX:+DoEscapeAnalysis。配置500M是保证不触发GC。
运行结果
把逃逸分析关掉:-XX:+PrintGC -Xms500M -Xmn500M -XX:-DoEscapeAnalysis
运行结果
逃逸分析把锁消除了,从而使得性能得到了很大的提升。这里说明一下Java的逃逸分析是方法级别的,因为JIT的即时编译是方法级别。
cost = 270ms
cost = 6ms
public static void main(String[] args){
long start = System.currentTimeMillis();
for(int i = 0; i < 5_000_000; i++){
createObject();
}
System.out.println("cost = " + (System.currentTimeMillis() - start) + "ms");
}
public static void createObject(){
synchronized (new Object()){
}
}
什么是栈,为什么它比堆快?
显然,栈在任何程序中都扮演着重要的角色。因此,对栈操作进行大量优化并不奇怪。栈几乎总是在快速L1缓存中。从缓存中存储和检索数据的操作非常快。此外,指向缓存的指针是一个CPU寄存器,可以进一步加快缓存访问速度。
本地优化
本地优化一次分析和改进一小部分代码。许多本地优化实现了经典静态编译器中使用的经过验证的技术。
控制语句优化
控制语句优化分析方法(或其特定部分)内的控制流,并重新排列代码路径以提高其效率。
全局优化
全局优化立即对整个方法起作用。它们更“昂贵”,需要更多的编译时间,但可以大大提高性能。
native代码生成
native代码生成过程因平台架构而异。通常,在编译的这个阶段,方法的树被翻译成机器代码指令; 根据体系结构特征执行一些小的优化。
以上是关于JIT编译器的主要内容,如果未能解决你的问题,请参考以下文章