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

编译期优化是什么?

Java语言的编译期是一段操做过程,具体的能够分为三类:前端

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

因此编译期优化是上者三者共同作出的贡献。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。 bash

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

相关文章
相关标签/搜索