Java代码的编译,大家都知道是将.java代码编译成.class文件,这个过程是我们常说的编译,也称为前端编译。实际上Java程序的编译和运行不仅仅是将代码编译成.class文件就可以的,因为机器无法直接运行.class文件,java培训还需要JIT或者解释器将.class文件转换成机器码,这个过程称为运行时编译。今天我们就来深入学习一下运行时编译器是怎么实现对Java代码的优化。
类的编译加载执行过程
类编译
我们编写完代码之后,需要用JDK自带的javac工具将.java文件编译成.class文件,才能在虚拟机上运行。我们可以用javap反编译来看class文件包含了哪些信息。
前期编译虽然是javac工具就可以来完成,其实这其中是一个非常复杂的过程,包含了词法分析、填充符号表、注解处理、语义分析以及生成class文件,我们只需要关注常量池和方法表集合两部分就可以了。
常量池主要记录的是类文件中出现的字面量以及符号引用。字面常量包括字符串常量、声明为final的属性以及一些基本类型的属性。符号引用包括类和接口的全限定名、类引用、方法引用、成员变量引用等。方法表集合主要包含了一些方法的字节码、方法访问权限、方法名索引、描述符索引、JVM执行指令以及属性集合等。
类加载
当一个类被创建实例或者被其他对象引用时,虚拟机在没有加载该类的情况下,会通过类加载器将字节码文件加载到内存中。不同的实现类由不同的类加载器加载,JDK中的本地方法类一般由跟加载器(Bootstrp loader)加载,JDK中内部实现的扩展类一般由扩展加载器(ExtClassLoader)实现加载,程序中的类文件由系统加载器(AppClassLoader)实现加载。在类加载后,class文件中的常量池信息和其他数据会被保存到JVM内存的方法区中。
类连接
类在加载进来以后,会进行连接、初始化,最后才会被使用,连接又包含了验证、准备、解析三个过程。
- 验证:验证类符合Java规范和JVM规范,在保证规范的前提下,避免危害虚拟机安全。
- 准备:为类的静态变量分配内存,初始化为系统的初始值。对于final static修饰的变量,直接赋值为用户定义的值。
- 解析:将符号引用转为直接引用的过程。在编译时,Java类不知道引用的类的实际地址,只能用符号引用来代替,类结构文件的常量池中存储了符号引用,包括类和接口的全限定名、类引用、方法引用以及成员变量引用等。如果要使用这些类和方法,就需要把它们转化为 JVM可以直接获取的内存地址或指针,即直接引用。
类初始化
类初始化是类加载的最后一步,在这一步中,java培训机构JVM 首先将执行构造器方法,编译器会在将 .java 文件编译成 .class 文件时,收集所有类初始化代码,包括静态变量赋值语句、静态代码块、静态方法,收集在一起成为 () 方法。
初始化类的静态变量和静态代码块为用户自定义的值,初始化的顺序和 Java 源码从上到下的顺序一致。
private static int i=1;
static{
i=0;
}
public static void main(String [] args){
System.out.println(i);
}
运行结果:
0
调整位置看一下:
static{
i=0;
}
private static int i=1;
public static void main(String [] args){
System.out.println(i);
}
运行结果:
1
子类初始化时会首先调用父类的 () 方法,再执行子类的 () 方法:
public class Parent{
public static String parentStr= "parent static string";
static{
System.out.println("parent static fields");
System.out.println(parentStr);
}
public Parent(){
System.out.println("parent instance initialization");
}
}
public class Sub extends Parent{
public static String subStr= "sub static string";
static{
System.out.println("sub static fields");
System.out.println(subStr);
}
public Sub(){
System.out.println("sub instance initialization");
}
public static void main(String[] args){
System.out.println("sub main");
new Sub();
}
}
运行结果:
parent static fields
parent static string
sub static fields
sub static string
sub main
parent instance initialization
sub instance initialization
JVM会保证()方法的线程安全,保证同一时间只有一个线程执行。JVM 在初始化执行代码时,如果实例化一个新对象,会调用 方法对实例变量进行初始化,并执行对应的构造方法内的代码。
即时编译
初始化完成之后,并不是就完了,类在调用执行的过程中,执行引擎会把字节码转为机器码,才能在操作系统中执行。即时编译就存在于字节码转换为机器码的过程中。
刚开始,虚拟机中的字节码是解释器完成编译的,当某个方法或者代码块运行的特别频繁,虚拟机就会把这些代码认定为“热点代码”。为了提高热点代码的执行效率,在运行的时候,即时编译器会把这些代码编译成机器码,并进行各层次的优化,然后保存到内存中。
即时编译器类型
在HotSpot虚拟机中,内置了两个即时编译器,分别是C1编译器和C2编译器,这两个编译器的编译过程是不一样的。
- C1编译器是一个简单快速的编译器,主要关注点在于局部性的优化,适用于执行时间较短或对启动性能有要求的程序,也称为Client Complier。
- C2编译器是为长期运行的服务端应用程序做性能调优的编译器,适用于执行时间较长或对峰值性能有要求的程序,也称为Server Complier。
在Java7之前,需要根据程序的特性来选择合适的即时编译器,虚拟机默认采用解释器和一个即时编译器来配合工作。
在Java7中引入了分层编译,这就兼容了C1的启动性能优势和C2的峰值性能优势,当然,也可以通过参数-clinet和-server强制指定虚拟器的即时编译器。
分层编译
分层编译将JVM的执行状态分了五个层次:
- 第0层:程序解释执行,默认开启性能监控功能(Profiling),如果不开启,可触发第1层编译;
- 第1层:可称为C1编译,将字节码编译为本地代码,进行简单、可靠的优化,不开启Profiling;
- 第3层:也可称为C1编译,开启Profiling,仅执行带方法调用次数和循环回边执行次数profiling的C1编译;
- 第4层:可称为C2编译,也是将字节码编译成本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化。
在Java8,又进行了优化,默认开启分层编译,-client和-server的设置已经是无效,如果只想开启C2,可以关闭分层编译(-XX:-TieredCompilation),如果只想开启C1,可以在开启分层编译时使用参数:-XX:TieredStopAtLevel=1。
热点探测
在HotSpot虚拟机中,热点探测是JIT优化的条件,热点探测是基于计数器的热点探测,采用这种方法的虚拟机会为每个方法建立计数器统计方法的执行次数,如果执行次数超过一定的阈值就认为它是“热点方法”。
虚拟机为每个方法准备了两类计数器分别为方法调用计数器和回边计数器,下边我们分别来看一下。
- 方法调用计数器:用于统计方法被调用的次数,方法调用计数器的默认阈值在C1模式下是1500次,在C2模式下是10000次,我们可以通过-XX:CompileThreshold来手动修改。但是在分层编译的情况下,通过-XX:CompileThreshold指定的阈值是无效的,此时将会根据当前待编译的方法数以及编译线程数来动态调整。当方法计数器和回边计数器之和超过方法计数器阈值时,就会触发 JIT 编译器。
- 回边计数器:用于统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为“回边”,该值用于计算是否触发C1编译的阈值,在不开启分层编译的情况下,C1模式默认是13995次,C2模式默认是10700次,我们可以通过-XX: OnStackReplacePercentage=N手动修改,在分层编译的情况下,-XX: OnStackReplacePercentage指定的阈值同样会失效,此时将根据当前待编译的方法数以及编译线程数来动态调整。
回边计数器主要的目的是触发栈上编译,在一些循环周期比较长的代码段中,当循环达到回边计数器阈值时,JVM会认为这段是热点代码,JIT编译器就会将这段代码编译成机器码并缓存,在该循环时间段内,直接执行缓存的机器码。
编译优化技术
JIT编译器有一些经典的优化技术,通过一些检查优化,可以编译出运行时性能最优的代码,比较常用的是方法内联和逃逸分析。
1、方法内联
方法调用要经历压栈和出栈,调用方法将程序执行顺序转移到存储该方法的内存地址,方法执行完之后,再将方法返回到
该方法之前的位置,因此,方法调用会产生一定的时间和空间的开销。方法内联就是在编译优化的时候把目标方法的代码复制到调用方法中,实际不用真实的方法调用。
举例:
private int add1(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;
}
会被优化为:
private int add1(int x1, int x2, int x3, int x4) {
return x1 + x2+ x3 + x4;
}
JVM会自动识别热点方法,然后判断是否使用方法内联优化,我们可以通过-XX:CompileThreshold来设置热点方法的阈值,热点方法也不是一定会做内联优化。还要看方法体的大小。
- 经常执行的方法,默认情况下方法体小于325字节的都会内联,我们也可以通过参数-XX:MaxFreqInlineSize=N来设置这个值的大小。
- 不经常执行的方法,默认情况下方法体大小小于35字节才会内联,我们也可以通过参数-XX:MaxInlineSize=N来设置这个值的大小。
在日常工作中,我们也可以通过以下方式来提高方法内联:
- 通过修改JVM参数来减小热点阈值,增大方法体阈值,来让更多的方法内联,这种方式会占用更多的内存。
- 避免大方法体的出现,习惯使用小方法体。
- 尽量使用final、private、static关键字修饰方法,编码方法因为继承,会需要额外的类型检查。
2、逃逸分析
逃逸分析(Escape Analysis)是判断一个对象是否被外部方法引用或外部线程访问的分析技术,编译器会根据逃逸分析的结果对代码进行优化。
根据逃逸分析的结果,进行优化的手段主要有三种:栈上分配、锁消除、标量替换。
栈上分配
默认创建一个对象是在堆中分配内存,当堆内存中的对象不再使用的时候,JVM垃圾回收器会回收对象,这个过程的消耗相对分配在栈中的对象的创建和销毁都更消耗时间和性能。逃逸分析发现对象只在方法中使用,就会将对象分配在栈上,
锁消除
在线程安全的情况下,尽量不要使用线程安全容器,比如常用的StringBuffer中的append()被Synchronized关键字修饰,使用到了锁,虽然保证了线程安全,也导致了性能下降。举例,在局部方法中创建的对象,只会被当前线程访问,无法被其他线程访问,所以是线程安全的,JIT编译会把这个对象的方法锁进行锁消除来提高性能。
标量替换
逃逸分析证明一个对象不会被外部访问,如果这个对象可以被拆分的话,当程序真正执行的时候可能不创建这个对象,而直接创建它的成员变量来代替。将对象拆分后,可以分配对象的成员变量在栈或寄存器上,原本的对象就无需分配内存空间了。这种编译优化就叫做标量替换。
JVM参数中有关逃逸分析的参数配置:
-XX:+DoEscapeAnalysis开启逃逸分析(jdk1.8默认开启)
-XX:-DoEscapeAnalysis 关闭逃逸分析
-XX:+EliminateLocks开启锁消除(jdk1.8默认开启)
-XX:-EliminateLocks 关闭锁消除
-XX:+EliminateAllocations开启标量替换(jdk1.8默认开启)
-XX:-EliminateAllocations 关闭就可以了
总结
JVM对于中的编译器,将热点方法优化、编译成二机制代码,直接运行在底层硬件上来提升性能。
文章来源于Java知音