Java编译过程、JIT编译详解、类加载过程

Java编译执行过程

提到编译,可能大多数人想到的就是将**.java编译成***.class文件,但其实Java代码的编译执行是一个非常复杂的过程,将**.java编译成**.class的过程叫做前端编译.

前端编译后的字节码可以由JVM解释器进行解释执行,但是这种执行效率是比较低的,因为它是在软件层面解释执行的,那是否可以有选择性的将运行次数较多的方法直接编译成二进制代码,运行在底层硬件上呢?JIT即时编译器正是承担这个作用.

将字节码编译成机器码的过程叫做运行时编译,它可以将Java字节码变成高度优化的机器代码,从而提高执行效率.

这一篇文章我们就来理一理这整个过程,以及其中的思想方法.
(主要讲解类加载以及之后的流程, 即类加载->运行时编译->机器码)

在这里插入图片描述

类加载过程

类的加载过程主要包括加载、连接、初始化三个部分

加载:

当一个类被创建实例或者被其它对象引用时,虚拟机在没有加载过该类的情况下,会通过类加载器将字节码文件加载到内存中。

不同的实现类由不同的类加载器加载,JDK 中的本地方法类一般由根加载器(Bootstrp loader)加载进来,JDK 中内部实现的扩展类一般由扩展加载器(ExtClassLoader )实现加载,而程序中的类文件则由系统加载器(AppClassLoader )实现加载。

在类加载后,class 类文件中的常量池信息以及其它数据会被保存到 JVM 内存的方法区中。

连接:

类在加载进来之后,会进行连接、初始化,最后才会被使用. 在连接过程中,有包括验证、准备和解析三个部分.

  • 验证: 验证类是否符合Java规范和JVM规范,在保证符合规范的前提下,避免危害虚拟机安全.
  • 准备: 为类的静态变量分配内存,初始化为系统的初始值.对于final static修饰的变量,直接赋值为用户的定义值. 例如: private final static int value=123; 这个语句,就会在准备阶段分配内存,并初始化值为123, 而如果是private static int value=123;这个阶段valued的值仍然为0.
  • 解析: 将符号引用转为直接引用的过程. 我们知道,在编译时,Java类并不知道所引用类的实际地址,因此只能使用符号引用来代替. 类结构文件的常量池中存储了符号引用,包括类和接口的全限定名、类引用、方法引用以及成员变量引用等,如果要使用这些类和方法,就需要把他们转化成JVM可以直接获取的内存地址和指针,即直接引用.

初始化:

初始化过程是类加载的最后阶段, 在这个阶段,JVM首先将执行构造器方法,编译器会在将.java文件编译成.class文件时,收集所有类初始化代码,包括静态变量赋值语句、静态代码块、静态方法收集在一起组成方法(这个方法执行还是遵循原先代码顺序的)

这里补充个额外的知识点:
Class.forName 和 ClassLoader.loadClass 都能加载类,但是在加载时又有具体区别:
1). Class.forName 默认会执行加载,连接以及初始化操作,即会默认执行静态代码块,静态变量等这些.
2). ClassLoader.loadClass默认就只是将.class文件加载到jvm中,不执行初始化操作.只有在newInstance才会去初始化.

即时编译JIT

在初始化完成后,一个类就可以被正常调用执行, 但这里面还存在着一步优化,就是当虚拟机发现某个方法或代码块运行特别频繁时,就会把这些代码定义会"热点代码".

为了提高热点代码的运行效率,在运行时,即时编译器JIT会把这些热点代码编译成与本地平台相关的机器码,并进行各层次的优化,然后保存到内存中.由底层硬件直接执行这些代码.

JIT编译其实是一种概念, 在HotSpot虚拟机(Java最常见的jvm实现)中,内置了两种编译器实现.

  • C1: 编译时间短,优化策略简单.
  • C2: 编译时间长,优化策略复杂

HotSpot虚拟机分client端和server端,准确的说应该是分两种类型或两种运行模式,就是两种适用不同业务场景的虚拟机类型。

  • client VM 使用的是C1编译器
  • server VM 使用的是C2编译器

client,server最大的区别就是C1和C2的区别,主要体现在编译策略上:

  • Client启动快,内存占用少,编译快,针对桌面应用程序优化(比如GUI),为在客户端环境中减少启动时间而优化
  • Server启动慢,但是一旦运行起来后,性能将会有很大的提升,因为编译更完全,效率高,针对服务端应用优化.

我们可以在程序启动时,指定使用client或server模式,如下: 指定server模式运行.
在这里插入图片描述

JIT编译优化中的常见技术

在JIT编译的过程中,运用了一些经典的优化技术,可以智能地编译出运行时的最优性能代码.主要介绍以下几种.

方法内联

调用一个方法通常需要经历压栈和出栈,。调用方法是将程序执行 顺序转移到存储该方法的内存地址,将方法的内容执行完后,再返回到执行该方法前的位置.

这种执行操作要求在执行前保护现场并记忆执行的地址,执行后要恢复现场,并按原来保存的地址继续执行。 因此,方法调用会产生一定的时间和空间方面的开销。

那么对于那些方法体代码不是很大,又频繁调用的方法来说,这个时间和空间的消耗会很大。方法内联的优化行为就是把目标方法的代码复制到发起调用的方法之中,避免发生真实的方法调用
例如以下方法:


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 会自动识别热点方法,并对它们使用方法内联进行优化.但要强调一点,热点方法不一定会被 JVM 做内联优化,如果这个方法体太大了,JVM 将不执行内联操作。而方法体的大小阈值,我们也可以通过参数设置来优化.-XX:CompileThreshold

热点方法的优化可以有效提高系统性能,一般我们可以通过以下几种方式来提高方法内联:

  1. 通过设置 JVM 参数来减小热点阈值或增加方法体阈值,以便更多的方法可以进行内联,但这种方法意味着需要占用更多地内存;
  2. 在编程中,避免在一个方法中写大量代码,习惯使用小方法体;
  3. 尽量使用 final、private、static 关键字修饰方法,编码方法因为继承,会需要额外的类型检查。

逃逸分析

逃逸分析是判断一个对象是否被外部方法或外部线程访问的分析技术,编译器会根据逃逸分析的结果对代码进行优化.

目前根据逃逸分析的结果,笔者所知主要有两种种优化技术.分别是栈上分配、锁消除

   栈上分配

我们知道,在 Java 中默认创建一个对象是在堆中分配内存的,而当堆内存中的对象不再使用时,则需要通过垃圾回收机制回收,这个过程相对分配在栈中的对象的创建和销毁来说,更消耗时间和性能。

而在逃逸分析后,如果发现一个对象只在方法中使用,就可以直接将对象分配在栈上。
以下是通过循环获取学生年龄的案例,方法中创建一个学生对象,我们现在通过案例来看看打开逃逸分析和关闭逃逸分析后,堆内存对象创建的数量对比。


public static void main(String[] args) {
    for (int i = 0; i < 200000 ; i++) {
      getAge();
    }
}

public static int getAge(){
  Student person = new Student("小明",18,30);   
    return person.getAge();
}

static class Student {
    private String name;
    private int age;
   
    public Student(String name, int age) {
        this.name = name;
        this.age = age;
    }

    public String getName() {
        return name;
    }

    public void setName(String name) {
        this.name = name;
    }

    public int getAge() {
        return age;
    }

    public void setAge(int age) {
        this.age = age;
    }
}

然后,我们分别设置 VM 参数:Xmx1000m -Xms1000m -XX:-DoEscapeAnalysis -XX:+PrintGC 以及 -Xmx1000m -Xms1000m -XX:+DoEscapeAnalysis -XX:+PrintGC,使用 VisualVM 工具,查看堆中创建的对象数量。

然而,运行结果却没有达到我们想要的优化效果,也许你怀疑是 JDK 版本的问题,然而我分别在 1.6~1.8 版本都测试过了,效果还是一样的:

(-server -Xmx1000m -Xms1000m -XX:-DoEscapeAnalysis -XX:+PrintGC)
在这里插入图片描述
(-server -Xmx1000m -Xms1000m -XX:+DoEscapeAnalysis -XX:+PrintGC)
在这里插入图片描述
这其实是因为 HotSpot 虚拟机目前的实现导致栈上分配实现比较复杂,可以说,在 HotSpot 中暂时没有实现这项优化。随着即时编译器的发展与逃逸分析技术的逐渐成熟,相信不久的将来 HotSpot 也会实现这项优化功能。

   锁消除

在非线程安全的情况下,尽量不要使用线程安全容器,比如 StringBuffer。由于 StringBuffer 中的 append 方法被 Synchronized 关键字修饰,会使用到锁,从而导致性能下降。

但实际上,在以下代码测试中,StringBuffer 和 StringBuilder 的性能基本没什么区别。这是因为在局部方法中创建的对象只能被当前线程访问,无法被其它线程访问,这个变量的读写肯定不会有竞争,这个时候 JIT 编译会对这个对象的方法锁进行锁消除。


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

小总结

Java 源程序是通过 Javac 编译器编译成 .class 文件,其中文件中包含的代码格式我们称之为 Java 字节码(bytecode)。这种代码格式无法直接运行,但可以被不同平台 JVM 中的 Interpreter 解释执行。由于 Interpreter 的效率低下,JVM 中的 JIT 会在运行时有选择性地将运行次数较多的方法编译成二进制代码,直接运行在底层硬件上。

在 Java8 之前,HotSpot 集成了两个 JIT,用 C1 和 C2 来完成 JVM 中的即时编译。也就分别对应着client和server端, 虽然 JIT 优化了代码,但收集监控信息会消耗运行时的性能,且编译过程会占用程序的运行时间。

到了 Java9,AOT 编译器被引入。和 JIT 不同,AOT 是在程序运行前进行的静态编译,这样就可以避免运行时的编译消耗和内存消耗,且 .class 文件通过 AOT 编译器是可以编译成 .so 的二进制文件的。

今天的分享就到这里了,有问题可以在评论区留言,均会及时回复呀.
我是bling,未来不会太差,只要我们不要太懒就行, 咱们下期见.
在这里插入图片描述

  • 4
    点赞
  • 10
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

程序员bling

义父,感谢支持

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值