likes
comments
collection
share

Java编译过程

作者站长头像
站长
· 阅读数 4

高级语言可以分为三类:编译型语言、解释型语言、混合型语言。java是混合型语言,融合了编译型语言和解释型语言的特点,即兼顾了可移植性,兼顾了执行效率。本文主要学习一下java的编译过程。

java编译过程

Java编译过程

总的来说,java编译过程大致分为:

  • .java文件经过javac命令编译之后,编译成.class文件。

    • 词法分析
    • 语法分析
    • 语义分析
    • 注解处理
    • 解语法糖
  • .class文件通过java命令,启动jvm来运行程序,jvm会启动类加载器,加载到内存里。

    • 加载
    • 验证
    • 准备
    • 解析
    • 初始化
  • 字节码再通过JIT编译成机器码,或者解释成机器码,运行java程序。

其中javac编译过程与其他编译型语言的编译过程类似,两者的区别主要在于:

  1. javac是编译成字节码文件,其他编译型语言是直接编译成机器码。
  2. javac的编译过程中除了词法分析,语法分析,语义分析等其他编译型语言都有的编译过程外,还有两个特殊的步骤:注解处理和解语法糖

注解处理需要遵循JSR269规范,例如Lombok插件。

常见的语法糖有:

  1. 自动装箱和自动拆箱
Integer obj = 12; // 底层实现为: Integer Obj = Integer.valueOf(12);
int i = obj; // 底层实现为:int i = obj.intValue();
  1. for-each底层依赖迭代器
List<String> list = Arrays.asList("1", "2");
// for-each
for(String s: list){
  
}

Iterator<String> itr = list.iterator();
while(itr.hasNext()){
  String s = itr.next();
}
  1. 范型擦除

List, List在字节码中都是List,里面存储的是Object类型,范型中的类型仅仅是编译器在做类型检查时使用。

  1. 内部类
public class A{ // A.class
    public class B{ // 内部类:A$B.class
        
    }

    public void f(){
        Thread t = new Thread(new Runnable(){ // 匿名内部类:A$1.class
            @Override
            public void run(){
                
            }
        });
    }
}
  1. Lambda表达式
  2. 枚举

具体的请参考:

www.51cto.com/article/596…

解释执行

对于C/C++这类的编译型语言,代码会被编译成计算机可以直接执行的机器码指令(可执行文件)。但对于Java这种混合型语言,需要先将代码编程成字节码文件,然后JVM会边解释字节码文件,边交给CPU执行。

JIT编译

边解释边执行,会影响程序的执行效率,为了解决这个问题,java引入了JIT(Just In Time)编译。对于一些经常运行的热点代码,比如多次调用的方法或者多次执行的循环,JIT编译器在代码的运行过程中,将其编译为机器代码并存储下来,当下次执行这些热点代码时,虚拟机直接将对应的机器码交由CPU执行即可,不需要边解释边执行,执行效率匹敌编译型语言。

可以通过java -version来查看当前jvm是通过哪种模式来执行的。也可以修改模式

  • java -Xint -version仅使用解释执行
  • java -Xcomp -version仅使用编译执行
(base) ➜  blog java -Xint -version
java version "1.8.0_321"
Java(TM) SE Runtime Environment (build 1.8.0_321-b07)
Java HotSpot(TM) 64-Bit Server VM (build 25.321-b07, interpreted mode)
(base) ➜  blog java  -version
java version "1.8.0_321"
Java(TM) SE Runtime Environment (build 1.8.0_321-b07)
Java HotSpot(TM) 64-Bit Server VM (build 25.321-b07, mixed mode)
(base) ➜  blog java  -Xcomp -version
java version "1.8.0_321"
Java(TM) SE Runtime Environment (build 1.8.0_321-b07)
Java HotSpot(TM) 64-Bit Server VM (build 25.321-b07, compiled mode)

分层编译

Hotspot虚拟机支持两种JIT编译器:Client编译器和Server编译器。Client编译器也叫C1编译器,Server编译器也叫C2编译器。两种编译器的主要区别在于编译时间和编译优化程度不同,编译优化程度越高,需要的编译时间则越长。Client编译器是仅局部编译,编译时间短,编译优化程度低,而Server编译器是局部和全局编译,编译时间长,编译优化程度高。

ava7以及之前的版本,需要通过-client或者-server参数来选择使用Client编辑器还是Server编辑器,要么选择Client编译器,要么选择Server编译器,两者不可同时使用。

为了解决这个问题,Java7引入了分层编译,JVM能够根据不同代码,运行时具体的情况自动选择不同的编译器。分层编译在Java7的时候技术还不够成熟,默认不开启,需要通过-XX:+TieredCompilation参数开启。

java8以后默认开启分层编译,同时-client-server参数不再生效。如果关闭了分层编译,JVM默认选择Server编译器。

分层编译主要有5个层级

  1. 解释执行
  2. 使用不带编译优化的Client编译器
  3. 使用仅带部分编译优化的Client编译器
  4. 使用带有所有编译优化的Client编译器
  5. Server编译器

热点代码探测

只有当代码被多次执行,被判定为热点代码,JVM才会执行JIT编译。那么JVM如何探测热点代码呢?热点代码主要分为两类:多次被执行到的方法和循环。

热点代码探测:

  1. 使用两个计数器(方法调用计数器和回边计数器)来统计方法的执行次数和循环的执行次数

  2. 当方法的调用次数和循环回边的次数的和,超过由参数-XX:CompileThreshold指定的阈值时(使用C1时,默认值为1500;使用C2时,默认值为10000),就会触发即时编译。

  3. 开启分层编译的情况下,-XX:CompileThreshold参数设置的阈值将会失效,触发编译会由以下的条件来判断:

    1. 方法调用次数大于由参数-XX:TierXInvocationThreshold 指定的阈值乘以系数。
    2. 方法调用次数大于由参数-XX:TierXMINInvocationThreshold指定的阈值乘以系数,并且方法调用次数和循环回边次数之和大于由参数-XX:TierXCompileThreshold指定的阈值乘以系数时。
    3. i > TierXInvocationThreshold * s || (i > TierXMinInvocationThreshold * s && i + b > TierXCompileThreshold * s) //i为调用次数,b是循环回边次数
  4. 只有方法计数器存在热度衰减机制,回边计数器不存在热度衰减机制。-XX:CounterHalfLifeTime, -XX:-UseCounterDecay

编译优化

编译器除了要做编译的工作以外,还需要进行编译优化,编译器在编译代码的时候,对代码进行优化,减少无效,冗余代码,以便生成更加高效的机器码。

JIT编译优化的优化策略有:

  • 方法内联
  • 逃逸分析
  • 无用代码消除
  • 循环展开
  • 消除公共子表达式
  • 范围检查消除
  • 空值检查消除

方法内联

方法内联,是指在编译过程中遇到方法调用时,将目标方法的方法体纳入编译范围之中,并取代原方法调用的优化手段。JIT大部分的优化都是在内联的基础上进行的,方法内联是即时编译器中非常重要的一环。

Java代码中存在大量getter/setter方法,如果没有方法内联,在调用getter/setter时,程序执行时需要保存当前方法的执行位置,创建并压入用于getter/setter的栈帧、访问字段、弹出栈帧,最后再恢复当前方法的执行。内联了对 getter/setter的方法调用后,上述操作仅剩字段访问。在C2编译器 中,方法内联在解析字节码的过程中完成。当遇到方法调用字节码时,编译器将根据一些阈值参数决定是否需要内联当前方法的调用。

public int getMax(List<Integer> values){
    int maxValue = Integer.MIN_VALUE;
    for(int value: values){
        mavValue = max(value, maxVale);
    }
    return maxValue;
}

public int max(int a, int b){
    return a >= b ? a : b;
}

JIT编译器进行方法內联优化后:

public int getMax(List<Integer> values){
    int maxValue = Integer.MIN_VALUE;
    for(int value: values){
        mavValue = (value >= maxValue ? value : maxValue);
    }
    return maxValue;
}

final为什么能引发方法內联?

在平时的开发中,我们经常会听到这样的说法,将方法设置为final会触发方法内联,实际上,这样的说法是不对,但是,将方法设置为final确实有助于触发方法内联,特别是在应用多态的情况下。

在JIT编译期间,编译器需要分析类的继承关系,查看函数是否有重载,如果存在重载函数,编译器则无法判断內联哪个子类的函数,也就无法进行內联优化。如果不存在重载,则可以进行內联。

函数如果final修饰,该方法则不会有重载,编译器就不会查看有无继承关系,重载关系,直接內联,减少方法內联编译优化的时间。

编译器的大部分优化都是在方法内联的基础上。所以一般来说,内联的方法越多,生成代码的执行效率越高。但是对于即时编译器来说,内联的方法越多,编译时间也就越长,程序达到峰值性能的时刻也就比较晚。

可以通过虚拟机参数-XX:MaxInlineLevel调整内联的层数,以及1层的直接递归调用(可以通过虚拟机参数-XX:MaxRecursiveInlineLevel调整)。一些常见的内联相关的参数如下表所示:

Java编译过程

逃逸分析

逃逸分析,JIT编译器会分析对象的使用范围,来优化对象的内存存储和访问方式,判断对象是否逃逸出线程或者方法。JIT编译器判断对象是否逃逸的依据有两种:

  1. 对象是否被存入堆中(静态字段或者堆中对象的实例字段),一旦对象被存入堆中,其他线程便能获得该对象的引用,即时编译器就无法追踪所有使用该对象的代码位置。
  2. 对象是否被传入未知代码中,即时编译器会将未被内联的代码当成未知代码,因为它无法确认该方法调用会不会将调用者或所传入的参数存储至堆中,这种情况,可以直接认为方法调用的调用者以及参数是逃逸的。

逃逸分析通常是在方法内联的基础上进行的,即时编译器可以根据逃逸分析的结果进行诸如锁消除、栈上分配以及标量替换的优化。

栈上分配

函数内的局部变量分配在栈上,只能在函数内部访问,对象分配在堆上,可以被多个函数访问。相对而言,堆上对象的创建和回收的过程,涉及复杂的分配和回收算法,要比栈上数据的创建和回收慢很多。如果编译器经过分析以后,发现某个对象的使用范围局限于某个函数内部,那么,编译器便可以启动栈上分配编译优化,将对象作为局部变量直接分配在栈上,相应的,创建和回收的对象的耗时就减少了很多。

标量分配

不过Hotspot虚拟机,并没有进行实际的栈上分配,而是使用了标量替换这一技术。所谓的标量,就是仅能存储一个值的变量,比如Java代码中的基本类型。与之相反,聚合量则可能同时存储多个值,其中一个典型的例子便是Java的对象。编译器会在方法内将未逃逸的聚合量分解成多个标量,以此来减少堆上分配。

public void func(){
    User user = new User();
    user.age = 23;
    user.weight = 145;
    // TODO
}

// 标量分配,使用基本类型代替对象
public void func(){
    int age = 23;
    int weight = 145;
    // TODO
}

锁消除

对不存在多线程并发访问的代码,JIT编译器会去掉保证线程安全的加锁逻辑。

public String concat(String str1, String str2){
    StringBuffer buffer = new StringBuffer();
    buffer.append(str1); // StringBuffer#append()函数加了锁,但由于buffer对象不会被多线程共享,JIT会进行锁消除
    buffer.append(str2);
    return buffer.toString();
}

AOT编译

实际上,跟JIT编译相对应的编译方法称作AOT编译(Ahead Of Time Compile),也叫做提前编译或者运行前编译。C/C++等编译型语言中的编译便是AOT编译,在运行前将代码编译成机器码。实际上,Java除了支持JIT编译之外,也支持AOT编译,只是相对来说用的不多而已。

思考题:既然AOT编译可以在运行前将代码编译成机器码,为什么Java还需要执行JIT编译呢?

AOT编译属于静态编译,JIT编译属于动态编译,动态编译可以给予程序运行时的统计信息,来进行优化,优化效果可观。

总结

本文主要学习了Java代码编译过程,其中JIT编译是JVM主要的优化点,能够显著地增加程序的执行效率,从解释执行到最高层次的C2,一个数量级的性能提升也是有可能的。但JIT编译的过程是非常缓慢的,耗时间也费空间,所以这些优化操作会和解释执行同时进行。