JIT即时编译与编译优化

写在前面:

1,java是解释型语言还是编译型语言?

          像C、C++ 他们经过一次编译之后直接可以编译成操作系统了解的类型,可以直接执行的,所以他们是编译型的语言。没有经过第二次的处理。而Java不一样,他首先由编译器编译成.class类型的文件,这个是java自己类型的文件 然后在通过虚拟机(JVM)从.class文件中读一行解释执行一行,所以他是解释型的语言,而由于java对于多种不同的操作系统有不同的JVM,所以,Java实现了真正意义上的跨平台! 

一、你可以说它是编译型的:因为所有的Java代码都是要编译的,.java不经过编译就什么用都没有。 
二、你可以说它是解释型的:因为java代码编译后不能直接运行,它是解释运行在JVM上的,所以它是解释运行的,那也就算是解释的了。 
三、但是,现在的JVM为了效率,都有一些JIT优化。它又会把.class的二进制代码编译为本地的代码直接运行,所以,又是编译的。

2,即时编译

Java程序最初是通过①解释器进行解释执行的,当虚拟机发现某个方法或代码块运行的特别频繁时,会把这些代码认定为“热点代码”(Hot Spot Code)。为了提高热点代码的执行效率,在运行时,虚拟机会把这些代码编译成本地平台相关的机器码,并进行各种层次的优化,完成这个任务的②编译器称为即时编译器(JIT编译器,不是Java虚拟机内必须的部分)。是在运行时期把字节码编译成原生机器码的技术,一句一句翻译源代码,但是会将翻译过的代码缓存起来以降低性能耗损。这项技术是被用来改善虚拟机的性能的

在HotSpot实现中有多种选择:C1、C2和C1+C2,分别对应client、server和分层编译。
1、C1编译速度快,优化方式比较保守;
2、C2编译速度慢,优化方式比较激进;
3、C1+C2在开始阶段采用C1编译,当代码运行到一定热度之后采用G2重新编译;
在1.8之前,分层编译默认是关闭的,可以添加-server -XX:+TieredCompilation参数进行开启。

要了解HotSpot虚拟机内的即时编译器的运作过程,要解决几个问题:

  • 为何HotSpot虚拟机要使用解释器和编译器并存的架构?
  • 为何HotSpot虚拟机要实现两个不同的即时编译器?
  • 程序何时使用解释器执行?何时使用编译器执行?
  • 哪些程序代码会被编译成本地代码?如何编译?
  • 如何从外部观察即时编译器的编译过程和编译结果?

一,解释器与编译器

编译器:

Java Compiler (Java 编译器)
Java compiler reads source files written in the Java programming language, and compiles them into bytecode class files.
Java编译器读取java源文件(*.java)并将它们编译为java字节码文件(*.class)。
Windows系统中的javac.exe可以简单看成是Java编译器。

解释器:

Java Interpreter(Java 解释器)
Java compilers generate machine-independent bytecodes instead of machine instructions. The interpreter is like a CPU implemented in software. It decodes and executes bytecodes, independent of what computer they were compiled on.
Java编译器生成的是与机器码不同的java字节码,并不能被硬件中的CPU直接执行。而java解释器就像植根于软件中的CPU,能够解析并执行java字节码。
Windows系统中的java.exe可以简单看成是Java解释器。

JIT即时编译与编译优化_第1张图片

这是最原始的解释器与编译器,还没有涉及即时编译,那即时编译是啥东西呢?

在最前面的已经解释了,这里再来回顾一下。

Java程序最初是通过解释器进行解释执行的,当虚拟机发现某个方法或代码块运行的特别频繁时,会把这些代码认定为“热点代码”(Hot Spot Code,如循环,高频度使用的方法等)。为了提高热点代码的执行效率,在运行时,虚拟机会把这些代码编译成本地机器码,并进行各种层次的优化,完成这个任务的编译器称为JIT编译器。JIT编译器是JRE的一部分。

简单的说,java解释器一行一行的运行java字节码还是太慢了,而机器码运行起来最快。JIT编译器把被多次调用的方法和被多次执行的循环体相关的字节码编译成本地机器码并运行,就可以大大提高运行效率。

顺带说一句,解释器 + JIT编译器+垃圾回收器就是JVM执行引擎(Execute Engine):注意:是jit编译器 而不是普通的编译器,例如javac.exe,因为javac不涉及程序运行时的操作,何谈执行引擎,而即使编译却参与了执行java程序的操作。

JIT即时编译与编译优化_第2张图片

解释器和即使编译器的优缺点:

解释器优点:当程序需要迅速启动的时候,解释器可以首先发挥作用,省去了编译的时间,立即执行。解释执行占用更小的内存空间。同时,当编译器进行的激进优化失败的时候,还可以进行逆优化来恢复到解释执行的状态。

编译器优点:在程序运行时,随着时间的推移,编译器逐渐发挥作用,把越来越多的代码编译成本地代码之后,可以获得更高的执行效率。

HotSpot中内置了两个即时编译器,分别称为 Client Compiler和 Server Compiler ,或者简称为 C1 编译器和 C2 编译器。目前的 HotSpot 编译器默认的是解释器和其中一个即时编译器配合的方式工作,具体是哪一个编译器,取决于虚拟机运行的模式,HotSpot 虚拟机会根据自身版本与计算机的硬件性能自动选择运行模式,用户也可以使用 -client 和 -server 参数强制指定虚拟机运行在 Client 模式或者 Server 模式。这种配合使用的方式称为“混合模式”(Mixed Mode),用户可以使用参数 -Xint 强制虚拟机运行于 “解释模式”(Interpreted Mode),这时候编译器完全不介入工作。另外,使用 -Xcomp 强制虚拟机运行于 “编译模式”(Compiled Mode),这时候将优先采用编译方式执行,但是解释器仍然要在编译无法进行的情况下接入执行过程。通过虚拟机 -version 命令可以查看当前默认的运行模式。

  • 解释模式:即时编译c1/c2都不参与工作。
  • 编译模式:优先使用c1/c2介入工作,但是无法编译的情况下,解释器必须介入工作。

C1 编译器:

C1 编译器是一个简单快速的编译器,主要的关注点在于局部性的优化,适用于执行时间较短或对启动性能有要求的程序,也称为Client Compiler,例如,GUI 应用对界面启动速度就有一定要求。

C2 编译器:

C2 编译器是为长期运行的服务器端应用程序做性能调优的编译器,适用于执行时间较长或对峰值性能有要求的程序,也称为Server Compiler,例如,服务器上长期运行的 Java 应用对稳定运行就有一定的要求。

分层编译:

在 Java7 之前,需要根据程序的特性来选择对应的 JIT,虚拟机默认采用解释器和其中一个编译器配合工作。

Java7 引入了分层编译,这种方式综合了 C1 的启动性能优势和 C2 的峰值性能优势,我们也可以通过参数 -client或者-server 强制指定虚拟机的即时编译模式。使用分层编译后,C1和C2同时工作,有些代码可能多次编译,用C1获取更高的编译速度,C2获取更好的编译质量

分层编译将 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

 

二,即时编译的编译对象与触发标准

2.1,编译对象

在运行过程中被即时编译器编译的“热点代码”有两类,即:

  • 被多次调用的方法
  • 被多次执行的循环体

对第一种情况,由于是方法调用触发的编译,因此编译器会以整个方法作为编译对象,即标准的JIT编译方式。后一种,虽然是循环体触发的编译动作,但编译器依然按照整个方法(而不是单独的循环体)作为编译对象。这种编译方式称为栈上替换(On Stack Replacement,简称为OSR编译)。

2.2,触发标准(触发使用的技术)

 

热点探测是基于计数器的热点探测,采用这种方法的虚拟机会为每个方法建立计数器统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法” 。

 

判断一段代码是不是热点代码,是不是需要触发即时编译,这样的行为称为热点探测(Hot Spot Detection),目前有两种方法:

  1. 基于采样的热点探测:采用这样的方法的虚拟机会周期性的检查各个线程的栈顶,如果发现某个(或某些)方法经常出现在栈顶,那这个方法就是“热点方法”。其好处就是实现简单、高效,还可以很容易的获取方法调用关系(将调用栈展开即可),缺点是很难精确的确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响。
  2. 基于计数器的热点探测:虚拟机为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阈值,当计数器超过阈值溢出了,就会触发 JIT 编译。缺点是实现起来更麻烦,需要为每个方法建立并维护计数器,并且不能直接获取到方法的调用关系,优点是它的统计结果相对来说更加精确和严谨。

HotSpot虚拟机使用第二种,它为每个方法准备了两类计数器:方法调用计数器(Invocation Counter)和回边计数器(Back Edge Counter)。

方法调用计数器:方法调用计数器用于统计方法被调用的次数,默认阈值在 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即时编译与编译优化_第3张图片

三,即时编译的优化 

这里的优化不是指优化即时编译技术,而是指在即时编译过程中,使用编译优化技术来实现代码的优化,即通过一些例行检查优化,可以智能地编译出运行时的最优性能代码。主要有两种:方法内联逃逸分析,其他还有公共子表达式消除、数组边界检查消除

 

3.1方法内联

调用一个方法通常要经历压栈和出栈。调用方法是将程序执行顺序转移到存储该方法的内存地址,将方法的内容执行完后,再返回到执行该方法前的位置。(这里要结合程序计数器与中断)

这种执行操作要求在执行前保护现场并记忆执行的地址,执行后要恢复现场,并按原来保存的地址继续执行。 因此,方法调用会产生一定的时间和空间方面的开销(其实可以理解为一种上下文切换的精简版)。

那么对于那些方法体代码不是很大,又频繁调用的方法来说,这个时间和空间的消耗会很大。

方法内联的优化行为就是把目标方法的代码复制到发起调用的方法之中,避免发生真实的方法调用。

JVM 会自动识别热点方法,并对它们使用方法内联进行优化。我们可以通过-XX:CompileThreshold来设置热点方法的阈值。但要强调一点,热点方法不一定会被 JVM 做内联优化,如果这个方法体太大了,JVM 将不执行内联操作。而方法体的大小阈值,我们也可以通过参数设置来优化:

  1. 经常执行的方法,默认情况下,方法体大小小于 325 字节的都会进行内联,我们可以通过-XX:MaxFreqInlineSize=N来设置大小值;
  2. 不是经常执行的方法,默认情况下,方法大小小于 35 字节才会进行内联,我们也可以通过-XX:MaxInlineSize=N来重置大小值。

之后我们就可以通过配置 JVM 参数来查看到方法被内联的情况:

// 在控制台打印编译过程信息
-XX:+PrintCompilation
// 解锁对 JVM 进行诊断的选项参数。默认是关闭的,开启后支持一些特定参数对 JVM 进行诊断
-XX:+UnlockDiagnosticVMOptions
// 将内联方法打印出来
-XX:+PrintInlining

热点方法的优化可以有效提高系统性能,一般我们可以通过以下几种方式来提高方法内联:

  1. 通过设置 JVM 参数来减小热点阈值或增加方法体阈值,以便更多的方法可以进行内联,但这种方法意味着需要占用更多地内存;
  2. 在编程中,避免在一个方法中写大量代码,习惯使用小方法体;
  3. 尽量使用 final、private、static 关键字修饰方法,编码方法因为继承,会需要额外的类型检查。

此处就联系到了最开始提出的观点,一个方法中的内容越少,当该方法经常被执行时,则容易进行方法内联,从而优化性能。

3.1.1 方法内联版本问题

问题:由于Java的默认实例方法是虚方法,需要在运行时进行方法接收者的多态选择,可能存在多于一个版本的方法接收者,对于虚方法,编译器静态地做内联很难确定应该使用哪个方法版本?

为了解决虚方法和内联的矛盾,Java虚拟机引入了类型继承关系分析(CHA),用于确定在目前已经加载的类中,某个接口是否有多于一种的实现,某个类是否存在子类,某个子类是否覆盖了父类的某个虚方法等。这样,编译器在进行内联时会根据不同情况进行处理,如果是非虚方法,则直接内联;如果是虚方法,则向CHA查询该方法在当前状态下是否有多个方法版本,如果只有一个版本,就以当前的方法进行内联,这称为守护内联,不过由于Java是动态连接的,可能随时加载到新的类型改变CHA的结论,因此需要给这个内联留逃生门,当出现新的加载类时,必须抛弃已经编译的代码,退回到解释状态执行,或者重新编译。

       如果CHA查询出来有多个方法版本可供选择,则使用内联缓存来缩减方法调用的开销,在这种状态下方法调用真的发生了,但是比起直接查询虚方法更快一些,内联缓存是一个建立在目标方法正常入口之前的缓存,记录下方法接收者的版本信息,并且在每次进行方法调用时比较接收者的版本。如果以后每次调用的方法版本都相同,则就是一种单态内联缓存,通过该缓存,比不内联的非虚方法调用仅多了依次类型判断开销;如果方法接收者不一致,则退化成超多态内联缓存,开销相当于查找虚方法表进行方法分派。

 

3.2,逃逸分析

逃逸分析并不是直接的优化手段,而是一个代码分析,通过动态分析对象的作用域,为其它优化手段如栈上分配、标量替换和同步消除等提供依据,发生逃逸行为的情况有两种:方法逃逸和线程逃逸。

 

  • 方法逃逸:当一个对象在方法中定义之后,作为参数传递到其它方法中;
  • 线程逃逸:如类变量或实例变量,可能被其它线程访问到;

如果不存在逃逸行为,则可以对该对象进行如下优化:同步消除、标量替换和栈上分配。

3.2.1,同步消除

线程同步本身比较耗,如果确定一个对象不会逃逸出线程,无法被其它线程访问到,那该对象的读写就不会存在竞争,则可以消除对该对象的同步锁,通过-XX:+EliminateLocks可以开启同步消除。

例如下面这行代码:

public void add(String str1, String str2) {
    StringBuffer sb = new StringBuffer();
    sb.append(str1).append(str2);
}

众所周知,StringBuffer 的 append 方法是同步方法,但是在这个 add 方法中,StringBuffer 不会存在共享资源竞争的情况,因为其他线程并不会访问到它。这就符合了 “代码上要求同步,但不可能存在共享数据竞争” 的条件。因此虽然这里有锁,但是可以安全地清除掉,避免了锁的获取释放带来的性能消耗。

 

3.2.2,标量替换

1、标量是指不可分割的量,如java中基本数据类型和reference类型,相对的一个数据可以继续分解,称为聚合量;
2、如果把一个对象拆散,将其成员变量恢复到基本类型来访问就叫做标量替换;
3、如果逃逸分析发现一个对象不会被外部访问,并且该对象可以被拆散,那么经过优化之后,并不直接生成该对象,而是在栈上创建若干个成员变量;
通过-XX:+EliminateAllocations可以开启标量替换, -XX:+PrintEliminateAllocations查看标量替换情况。

看如下代码:

  private static int fn(int age) {
        User user = new User(age);
        int i = user.getAge();
        return i;
    }

User对象的作用域局限在方法fn中,可以使用标量替换的优化手段在栈上分配对象的成员变量,这样就不会生成User对象,大大减轻GC的压力

3.2.3,栈上分配

在 Java 中默认创建一个对象是在堆中分配内存的,而当堆内存中的对象不再使用时,则需要通过垃圾回收机制回收,这个过程相对分配在栈中的对象的创建和销毁来说,更消耗时间和性能。这个时候,逃逸分析如果发现一个对象只在方法中使用,就会将对象分配在栈上。

其实目前Hotspot并没有实现真正意义上的栈上分配,实际上是标量替换。

下面通过例子看看逃逸分析的影响。

public class JVM {
    public static void main(String[] args) throws Exception {
        int sum = 0;
        int count = 1000000;
        //warm up
        for (int i = 0; i < count ; i++) {
            sum += fn(i);
        }

        Thread.sleep(500);

        for (int i = 0; i < count ; i++) {
            sum += fn(i);
        }

        System.out.println(sum);
        System.in.read();
    }

    private static int fn(int age) {
        User user = new User(age);
        int i = user.getAge();
        return i;
    }
}

class User {
    private final int age;

    public User(int age) {
        this.age = age;
    }

    public int getAge() {
        return age;
    }
}

分层编译和逃逸分析在1.8中是默认是开启的,例子中fn方法被执行了200w次,按理说应该在Java堆生成200w个User对象。

1、通过java -cp . -Xmx3G -Xmn2G -server -XX:-DoEscapeAnalysis JVM运行代码,-XX:-DoEscapeAnalysis关闭逃逸分析,通过jps查看java进程的PID,接着通过jmap -histo [pid]查看java堆上的对象分布情况,结果如下:

JIT即时编译与编译优化_第4张图片


可以发现:关闭逃逸分析之后,User对象一个不少的都在堆上进行分配。

 

2、通过java -cp . -Xmx3G -Xmn2G -server JVM运行代码,结果如下:

JIT即时编译与编译优化_第5张图片


可以发现:开启逃逸分析之后,只有41w左右的User对象在Java堆上分配,其余的对象已经通过标量替换优化了。

 

3、通过java -cp . -Xmx3G -Xmn2G -server -XX:-TieredCompilation运行代码,关闭分层编译,结果如下:

JIT即时编译与编译优化_第6张图片


可以发现:关闭了分层编译之后,在Java堆上分配的User对象降低到1w多个,分层编译对逃逸分析还是有影响的。
 

 

你可能感兴趣的:(JIT即时编译,逃逸分析,解释器)