JVM程序编译与代码优化(JIT)

2,353 阅读13分钟

编译期优化是什么?

Java语言的编译期是一段操作过程,具体的可以分为三类:

  • Javac(前端静态编译器):把*.java编译为*.class文件
  • JIT编译器(后端运行编译器):把*.class文件转变成机器码的过程
  • AOT编译器(静态提前编译器):把*.java文件编译成本地机器码的过程

所以编译期优化是上者三者共同做出的贡献。

源代码,字节码,机器码,本地代码?

源文件就是.java文件。字节码就是.class文件。机器码和本地代码是计算机能够直接识别运行的代码,就是机器指令。众所周知,java的特点之一就是跨平台性,跨平台的结果是运行效率慢,JVM为了增快速度,将某些代码会编译成机器码,以此提高运行效率。

Javac编译器

我之前做过一篇关于javac编译器的博客,较为详细的讲了Javac编译的各步骤及作用,如果看过那篇文章的小伙伴们就知道Javac对代码的运行效率几乎没有优化措施,但是,有一些“语法糖”是靠javac编译器实现的,例如foreach语法、注解等。

那么关于Javac编译器的部分可以去看之前的博客,这里就不耽误大家时间了。

JIT编译器(即时编译器)

举个JIT编译器优化的例子,当虚拟机发现某个方法被频繁运行时(或一个多次执行的循环体),就会把这些代码认定为“热点代码”,为提高效率,运行时,就会把这些代码编译成与本地平台相关的机器码,而完成这个任务的,就是JIT编译器。

可以通过java -verison来查看自己的JIT模式,如图,我的是Server模式,并且采用的mixed mode。下面来解释一下什么是mixed mode和Server,先来分析mixed mode。

image

解释器和编译器

HotSpot虚拟机采用解释器和编译器的架构。

解释器

这里的解释器作用就是将字节码一条一条翻译为机器码,它的特点是立即执行,节约内存

编译器

它的作用是把源程序的每一条语句都编译成机器语言,并保存为二进制文件。

那么为什么要同时使用解释器和编译器呢?因为解释器很慢,但节约内存;而编译器编译成本地代码后执行效率更高。

还有一点,当编译器使用激进优化不成立时(即优化过后发现并没有起到优化作用),例如加载了新类后继承结构变化,这时可以逆优化退回到解释状态继续执行。那么这里的mixed mode也就是解释器和编译器组合的混合模式了。

JIT编译器分类

JIT编译器可以分为两类,Client和Servcer又名C1,C2。

C1编译器将字节码编译为本地代码,进行简单可靠的优化。C2编译器则会启动一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。

在jdk1.8之后,引入了分层编译的策略,在运行初期开启C1编译器编译,随时间的推移执行频率高的代码会再次被C2编译器编译:

  • 第0层:程序解释执行,解释器不开启性能监控功能,
  • 第1层,C1编译,将字节码编译为本地代码,进行简单、可靠的优化,有必要的话加入性能监控的逻辑
  • 第2层,C2编译,将字节码编译为本地代码,会启用一些编译耗时长的优化,甚至激进优化。

JIT优化的对象和触发条件

JIT编译的热点代码有两类

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

那么这个多次是多少次呢?这就需要进行热点探测。目前主要的热点探测方式有以下两种:

(1)基于采样的热点探测

采用这种方法的虚拟机会周期性地检查各个线程的栈顶,如果发现某些方法经常出现在栈顶,那这个方法就是“热点方法”。这种探测方法的好处是实现简单高效,还可以很容易地获取方法调用关系(将调用堆栈展开即可),缺点是很难精确地确认一个方法的热度,容易因为受到线程阻塞或别的外界因素的影响而扰乱热点探测。

(2)基于计数器的热点探测

采用这种方法的虚拟机会为每个方法(甚至是代码块)建立计数器,统计方法的执行次数,如果执行次数超过一定的阀值,就认为它是“热点方法”。这种统计方法实现复杂一些,需要为每个方法建立并维护计数器,而且不能直接获取到方法的调用关系,但是它的统计结果相对更加精确严谨。

HotSpot采用基于计数器的热点探测方法,他为每个方法准备了方法调用计数器和回边计数器:

方法调用计数器统计的并不是方法被调用的绝对次数,而是一个相对的执行频率,即一段时间内方法被调用的次数。当超过一定的时间限度,如果方法调用次数仍然不足以让它提交给即时编译器,那这个方法的调用计数器就会衰减一般,这个过程称为方法调用计数器热度的衰减。

回边计数器统计的是一个方法中循环体代码执行的次数,它没有热度衰减。建立回边计数器统计的目的是为了处罚OSR编译,OSR即栈上替换,也就是编译发生在方法执行过程之中。

OSR编译:某段循环执行的代码还在不停的循环中,如果在某次循环之后,计数器达到了某一阈值,这时JVM已经认定这段代码是热点代码,此时编译器会将这段代码编译成机器语言并缓存后,但是这段循环仍在执行,JVM就会把执行代码替换掉,那么等循环到下一次时,就会直接执行缓存的编译代码,而不需要必须等到循环结束才进行替换,这个就是所谓的栈上替换

优化技术

下面来看一下JIT生成代码时的代码优化技术:

语言无关的优化技术之一:公共子表达式消除

他的意思是如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就成为了公共子表达式。

int d = (c * b) * 12 + a + (a + b * c);
//优化为:
int d = E * 12 + a + (a+E);

语言相关的优化技术之一:数组边界检查消除

我们知道Java是动态安全的语言,如果访问一个超出数组边界的元素会抛出异常,如果没有优化,那么每一次对数组元素的读写都会进行判断是否越界,这显然是很消耗性能的。但毫无疑问的是数组边界检查是必须做的。

//1.如果数组下标是常量
array[3]
//编译器根据数据流分析确定foo.length的值,并判断下标"3"没有越界,执行的时候就无须判断了

//2.数组下标是循环变量
for(int i...)
    array[i]
/**如果编译器通过数据流分析就可以判定循环变量"0<=i< foo.length",
那在整个循环中就可以把数组的上下界检查消除掉,这可以节省很多次的条件判断操作。*/

最重要的优化技术之一:方法内联

方法内联可以理解为将目标方法的代码“复制”到发起调用的方法之中,避免真实的方法调用。但实际上,我们平时所说的面向接口编程,会使用多态等特性,而多态是要在运行时才能判定到底使用哪个方法的(实际上Java的默认实例方法是虚方法,而虚方法做内联时根本无法确定使用哪个版本),所以我们就可以知道要达到方法内联的条件是比较苛刻的。

那么,方法内联之后能够进行哪些优化呢?

  • 去除方法调用的成本
  • 为其他优化建立良好的基础,方法内联后可以便于在更大范围上采取后续的优化手段

下面举个例子:

//内联前的代码
static class B{
    int value;
    final int get(){
        return value;
    }
}

public void foo(){
    y = b.get();
    z = b.get();
    sum = y + z;
}
//内联后的代码
public void foo(){
    y = b.value;
    z = b.value;
    sum = y + z;
}

内联后采取的其他优化

//冗余访问消除
public void foo(){
    y = b.value;
    z = y;
    sum = y + z;
}
//复写传播
public void foo(){
    y = b.value;
    y = y;
    sum = y + y;
}
//无用代码消除
public void foo(){
    y = b.value;
    sum = y + y;
}
哪些方法可以内联呢?

从字节码的角度来看的话,只有使用invokespecial指令调用的私有方法、实力构造器、父类方法;以及invokestatic指令进行调用的静态方法才是在编译期进行解析的;另外final修饰的方法也是非虚方法。前文也说了虚方法无法在编译期确定调用的具体方法,所以为了解决虚方法的内联问题,JVM设计师们引入了一个“类型继承关系分析”(CHA)的技术。

内联步骤:

  • 如果是非虚方法,直接内联
  • 如果是虚方法,向CHA查询
    • 如果查询结果只有一个版本,也可以进行内联; 不过这属于激进优化,需要预留一个"逃生门",称为守护内联(Guarded Inlining); 因为运行加载类可能导致继承关系发生变化,需要退回解释执行,或重新编译;
    • 如果查询结果有多个版本目标,使用内联缓存(Inline Cache)来完成方法内联; 当缓存第一次调用方法接收者信息,以后每次调用都比较,不一致时取消内联;

所以内联优化很多情况下是激进优化,需要有“逃生门”回到解释状态重新执行

最前沿的优化技术之一:逃逸分析

它的基本行为就是分析对象状态作用域:例如如果在一个方法内返回了一个方法内生成的新对象,如果被引用,这是方法逃逸;如果被外部线程访问到,这是线程逃逸。

那么JIT又是如何对逃逸分析进行优化的呢?

  • 栈上分配:一般来说,JVM都将对象创建在堆上,但如果确定一个对象不会逃逸出方法外,那么就将这个对象创建在栈上,随着出栈死亡
  • 同步消除:如果确定一个变量不会逃逸出线程,无法被其他线程访问,那么同步措施也可以取消
  • 标量替换:如果一个对象不会被外部访问,并且这个对象可以被拆散,那程序执行时可以不创建这个对象,而直接创建若干个被这个方法使用到的成员变量替代

事实上,逃逸分析还未成熟,原因也很简单,如果要判断一个对象是否会逃逸,需要进行数据流的一系列分析,而这个逃逸分析带来的消耗未必比逃逸分析带来的优化小。

JIT总结

所以,综上所述,JIT编译器作用时间在程序运行时,作用将运行频繁的代码编译为本地代码,所以因为JIT作用时间在运行时,所以他在优化性能的同时也让java保持了跨平台的特性

AOT编译器

AOT是jdk9才引入的编译方式,和JIT不同,AOT是在程序运行前进行的静态编译,那么为什么有了JIT后还需要AOT呢?

  • 因为JIT是在运行时编译的,所以要占用运行时资源,而AOT在运行前编译,可以避免运行时的编译消耗和内存消耗
  • JIT在编译时要去识别代码是否为热点代码,这就需要占用时间,结果就是让初始编译不能达到最高性能,但AOT在运行时就编译好了,那么再运行初期就达到最高性能

似乎有一个问题,如果AOT的出现是因为JIT优化的时间在运行时,那么为什么不直接在javac编译阶段优化呢,或者为什么不在编译阶段就优化完毕呢?

  • 一开始Sun/Oracle公司没做这方面的打算(这应该也算一个原因)
  • 某些优化只能在运行的时候做,因为java是一门动态类型的语言,前文也说了java大多数方法都是虚方法,完全可以在运行时通过类加载器来改变类的结构,这样,例如逃逸分析这类优化就很难在编译的时候进行

但事实上也有在静态编译期间将优化全做完的,例如Excelsior JET,意思就是可以指定编译模式为不使用动态特性,那如果发生运行时类加载了一个新类,那么就直接回退到重新编译JIT就好了。所以,为了java的动态性的实现,我们很难在静态编译期就完全实现优化。

总结

javac

  • 静态编译器,将.java编译为.class
  • 不做优化,语法糖在这个时候解除

JIT、AOT

  • JIT是动态编译,JIT在运行时进行编译,将热点代码编译为机器码
  • JIT在判断代码是否为热点代码采用的是采样的热点探测和计数器的热点探测
  • HotSpot采用编译器和解释器并存的架构模式
  • JIT能够做公共子表达式消除、函数内联、逃逸分析、数组边界消除
  • 运行时占用内存导致程序卡顿,编译时还要判断是否可以优化占时间

AOT

  • AOT在运行前将部分热点代码编译为机器码
  • AOT是静态编译,运行前编译,避免编译消耗和内存消耗,显著加快程序启动,程序初期就是最高性能
  • 牺牲Java一致性

参考:
《深入理解Java虚拟机》
《深入分析JavaWeb》
《逃逸分析为什么不在编译时间运行》 https://www.zhihu.com/question/27963717/answer/38871719