阅读 57

认识JIT编译器

JVM架构图 

今天带大家了解下JIT编译器,,首先来看看JVM的整体架构图,JIT编译器存在于执行引擎中.

java程序执行流程图

一个类的执行流程需要从.java源文件需要编译成.class文件,然后通过类加载器加载到内存中,但是此时操作系统还不能识别.class文件,它只能认识机器码.所以要通过JVM将.class文件解释成机器码,才能够被操作系统执行.流程图如下

JIT即时编译器

可以看到JAVA解释器和JIT即时编译器是平行的.那么有了解释器就可以将.class转换为机器码为什么还需要即时编译器呢?当操作系统执行.class文件时,解释器需要逐行将.class代码解释成机器语言,这个过程会很快,但若相同的代码被多次执行,例如一个方法被多线程调用,或者代码存在于for循环中,此时在逐行解释就会重复性的执行相同工作.所以出现了即时编译.通过将热点代码一次编译成机器码.大概流程如下图

热点代码

那么什么样的代码算是热点代码呢?运行过程中会被即时编译器编译的热点代码有两类.

1被多次调用的方法

2被多次执行的循环体.

两种情况,编译器都会以整个方法作为编译对象.这种编译方法因为编译发生在方法执行过程中,因此形象的称之为栈上替换,即方法帧栈还在栈上,方法就被替换了.

什么样的代码是热点代码

热点检查的方式一般有两种.

1基于采样的热点探测

采用这种方法的虚拟机会周期性地检查各个线程的栈顶,如果发现某些方法经常出现在栈顶,那这个方法就是“热点方法”。这种探测方法的好处是实现简单高效,还可以很容易地获取方法调用关

系(将调用堆栈展开即可),缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。

2基于计数器的热点探测

采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阀值,就认为它是“热点方法”。这种统计方法实现复杂一些,需要为每个方法

建立并维护计数器,而且不能直接获取到方法的调用关系,但是它的统计结果相对更加精确严谨。

HotSpot虚拟机采用的热点探测

在HotSpot虚拟机中使用的是第二种——基于计数器的热点探测方法,因此它为每个方法准备了两个计数器:方法调用计数器和回边计数器。在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的

阈值,当计数器超过阈值溢出了,就会触发JIT编译。 

方法调用计数器

顾名思义,这个计数器用于统计方法被调用的次数。在JVM client模式下的阀值是1500次,Server是10000次。可以通过虚拟机参数: -XX:

CompileThreshold设置。但是JVM还存在热度衰减,时间段内调用方法的次数较少,计数器就减小。

回边计数器

它的作用就是统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”。

JIT编译优化

JIT不仅能做到一次编译重复使用,它还对即时编译的代码做了优化,优化部分为以下六种

1公共子表达式的消除

公共子表达式消除是一个普遍应用于各种编译器的经典优化技术,他的含义是:如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就成为了公共

子表达式。对于这种表达式,没有必要花时间再对他进行计算,只需要直接用前面计算过的表达式结果代替E就可以了。

如果这种优化仅限于程序的基本块内,便称为局部公共子表达式消除(Local Common Subexpression Elimination)如果这种优化范围涵盖了多个基本块,那就称为全局公共子表达式消除(Global Common

Subexpression Elimination)。举个简单的例子来说明他的优化过程,假设存在如下代码:

int d = (c*b)*12+a+(a+b*c); 

如果这段代码交给Javac编译器则不会进行任何优化,那生成的代码如下所示,是完全遵照Java源码的写法直译而成的。 

iload_2 // b

imul // 计算b*c

bipush 12 // 推入12

imul // 计算(c*b)*12

iload_1 // a

iadd // 计算(c*b)*12+a

iload_1 // a

iload_2 // b

iload_3 // c

imul // 计算b*c

iadd // 计算a+b*c

iadd // 计算(c*b)*12+a+(a+b*c)

istore 4 

当这段代码进入到虚拟机即时编译器后,他将进行如下优化:编译器检测到”cb“与”bc“是一样的表达式,而且在计算期间b与c的值是不变的。因此,这条表达式就可能被视为: 

int d = E*12+a+(a+E);

这时,编译器还可能(取决于哪种虚拟机的编译器以及具体的上下文而定)进行另外一种优化:代数化简(Algebraic Simplification),把表达式变为: 

int d = E*13+a*2; 

表达式进行变换之后,再计算起来就可以节省一些时间了。

2方法内联

一个方法要被执行,首先要从方法区找到类的元数据,在找到Method对象,将该对象压栈到栈顶,改方法执行完毕后会弹栈.如下图

如果方法的调用层次很深,而方法本身并不复杂则会出现频繁的压栈弹栈,是没必要的.在使用JIT进行即时编译时,将方法调用直接使用方法体中的代码进行替换,这就是方法内联,减少了方法调用过程中压栈与入栈的开销。同时为之后的一些优化手段提供条件。如果JVM监测到一些小方法被频繁的执行,它会把方法的调用替换成方法体本身。 

比如说下面这个: 

private int add4(int x1, int x2, int x3, int x4) {

  return add2(x1, x2) + add2(x3, x4);

}

private int add2(int x1, int x2) {

  return x1 + x2;

可以肯定的是运行一段时间后JVM会把add2方法去掉,并把你的代码翻译成: 

private int add4(int x1, int x2, int x3, int x4) {

  return x1 + x2 + x3 + x4;

逃逸分析

逃逸分析(Escape Analysis)是目前Java虚拟机中比较前沿的优化技术。这是一种可以有效减少Java程序中同步负载和内存堆分配压力的跨函数全局数据流分析算法。通过逃逸分析,Java Hotspot编译

器能够分析出一个新的对象的引用的使用范围从而决定是否要将这个对象分配到堆上。逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他地方中,称为方法逃逸。

逃逸分析包括:

全局变量赋值逃逸

方法返回值逃逸

实例引用发生逃逸

线程逃逸:赋值给类变量或可以在其他线程中访问的实例变量

public class EscapeAnalysis {
  //全局变量
  public static Object object;
  public void globalVariableEscape(){//全局变量赋值逃逸
  object = new Object();
  }
  public Object methodEscape(){ //方法返回值逃逸
    return new Object();
}
  public void instancePassEscape(){ //实例引用发生逃逸
    this.speak(this);
  }
  public void speak(EscapeAnalysis escapeAnalysis){
    System.out.println("Escape Hello");
  }
}
复制代码

View Code

接下来的JIT会针对没有逃逸的对象进行优化

1 对象的栈内存分配

我们知道,在一般情况下,对象和数组元素的内存分配是在堆内存上进行的。但是随着JIT编译器的日渐成熟,很多优化使这种分配策略并不绝对。JIT编译器就可以在编译期间根据逃逸分析的结果,来决

定是否可以将对象的内存分配从堆转化为栈。以下一个简单的代码示例

public class Test {
    public static void main(String[] args) {
        for (int i = 0; i < 1000000; i++) {
            alloc();
        }
        // 为了方便查看堆内存中对象个数,线程sleep
        try {
            Thread.sleep(1000000);
        } catch (InterruptedException e1) {
            e1.printStackTrace();
        }
    }

    private static void alloc() {
        User user = new User();
    }

    static class User {
    }
}
复制代码

View Code

从jdk 1.7开始已经默认开始逃逸分析.代码中我们循环了100W次User对象,而在堆内存中只有10W个,可以看出逃逸分析生效了.

下面把逃逸分析关闭掉.idea启动加入参数,从结果可以看出,没有了逃逸分析,循环100W次,确实是创建了100W个对象在堆内存中.

-Xmx4G -Xms4G -XX:-DoEscapeAnalysis -XX:+PrintGCDetails -XX:+HeapDumpOnOutOfMemoryError

2 标量替换

标量即不可被进一步分解的量,而JAVA的基本数据类型就是标量(如:int,long等基本数据类型以及reference类型等),标量的对立就是可以被进一步分解的量,而这种量称之为聚合量。而在JAVA中对象就是可以被进一步分解的聚合量.标量替换就是.在JIT阶段,如果经过逃逸分析,发现一个对象不会被外界访问的话,那么经过JIT优化,就会把这个对象拆解成若干个其中包含的若干个成员变量来代替。

3 同步锁消除

看以下代码示例,在使用StringBuffer时,我们都知道它是线程安全的,也就是它的方法都加了同步锁.加入同步锁肯定会影响执行效率.JIT在开启逃逸分析后,发现StringBuffer对象并没有逃出方法内,所以会使用同步锁消除,也就是以下代码并不会有同步锁加入.

public class Test {
    public static String getString(String s1, String s2) {
        StringBuffer sb = new StringBuffer();
        sb.append(s1);
        sb.append(s2);
        return sb.toString();
    }

    public static void main(String[] args) {
        long tsStart = System.currentTimeMillis();
        for (int i = 0; i < 10000000; i++) {
            getString("TestLockEliminate ", "Suffix");
        }
        System.out.println("一共耗费:" + (System.currentTimeMillis() - tsStart) + " ms");
    }
}
复制代码

View Code

以下执行结果是默认开启了逃逸分析的执行时间

 

 以下执行结果关闭了逃逸分析,执行时间远远超过上边的开启逃逸分析

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

文章分类
后端
文章标签