JVM复习(二):类编译加载执行过程、即时编译

六、类编译加载执行过程

Java从编译到运行的整个过程如下图:

在这里插入图片描述

1、类编译

.java文件编译成.class文件的过程中,包括词法分析、填充符号表、注解处理、语义分析以及生成class文件

使用javap反编译一个class文件结构中主要包含了如下信息:

在这里插入图片描述

编译后的字节码文件主要包括常量池方法表集合这两部分

常量池主要记录的是类文件中出现的字面量以及符号引用。字面常量包括字符串常量,声明为final的属性以及一些基本类型(例如,范围在 -127-128 之间的整型)的属性。符号引用包括类和接口的全限定名、类引用、方法引用以及成员变量引用等

方法表集合中主要包含一些方法的字节码、方法访问权限(public、protect、prviate等)、方法名索引(与常量池中的方法引用对应)、描述符索引、JVM执行指令以及属性集合等

2、类加载

1)、类加载的过程

类加载的过程包括加载、连接、初始化。在连接过程中,又包括验证、准备和解析三个部分

1)加载

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

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

2)验证

验证类符合Java规范和JVM规范,在保证符合规范的前提下,避免危害虚拟机安全

3)准备

为类的静态变量分配内存,初始化为系统的初始值。对于final static修饰的变量,直接赋值为用户的定义值。例如,private final static int value=123,会在准备阶段分配内存,并初始化值为123,而如果是private static int value=123,这个阶段value的值仍然为 0

4)解析

将符号引用转为直接引用的过程。在编译时,Java类并不知道所引用的类的实际地址,因此只能使用符号引用来代替。类结构文件的常量池中存储了符号引用,包括类和接口的全限定名、类引用、方法引用以及成员变量引用等。如果要使用这些类和方法,就需要把它们转化为JVM可以直接获取的内存地址或指针,即直接引用

5)初始化

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

类初始化的时机:

  • 遇到new、读取一个类的静态字段、设置一个类的静态字段、调用一个类的静态方法
  • 使用java.lang.reflect包的方法对类进行反射调用时
  • 当类初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化(如果是接口,则不必触发其父接口初始化)
  • 当虚拟机执行一个main方法时,会首先初始化main所在的这个主类
  • 如果一个接口定义了default方法,那么直接实现或者间接实现该接口的类的初始化,会触发该接口的初始化
  • 当初次调用MethodHandle实例时,初始化该MethodHandle指向的方法所在的类

这5种场景中的行为称为对一个类进行主动引用。除此之外,所有引用类的方式都不会触发初始化,称为被动引用

以下几种情况不会导致类的初始化:

  • 通过子类引用父类的静态字段,不会导致子类初始化
  • 通过数组定义来引用类,不会触发此类的初始化
  • 常量在编译阶段会存入调用类的常量池中,本质上并没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化

初始化类的静态变量和静态代码块为用户自定义的值,初始化的顺序和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

子类初始化时会首先调用父类的<clinit>()方法,再执行子类的<clinit>()方法,运行以下代码:

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会保证<clinit>()方法的线程安全,保证同一时间只有一个线程执行。JVM在初始化执行代码时,如果实例化一个新对象,会调用<init>方法对实例变量进行初始化,并执行对应的构造方法内的代码

2)、类加载器

对于任意一个类,都需要由加载它的类加载器和这个类本身一同确定其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。比较两个类是否相等,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个Class文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那这两个类就必定不相等

这里的相等包括代表类的Class对象的equals()方法、isAssignableFrom()方法、isInstance()方法的返回结果,也包括使用instanceof关键字做对象所属关系判定等情况

从JVM的角度来讲,只存在两种不同的类加载器:一种是启动类加载器,这个类加载器使用C++语言实现,是虚拟机自身的一部分;另一种就是所有其他的类加载器,这些类加载器都由Java语言实现,独立于虚拟机外部,并且全都继承自抽象类java.lang.ClassLoader

  • 启动类加载器(Bootstrap ClassLoader):负责将放在<JAVA HOME>\lib目录中的,或者被-Xbootclasspath参数所指定的路径中的,并且是虚拟机识别的类库加载到虚拟机内存中。启动类加载器无法被Java程序直接引用,用户在编写自定义类加载器时,如果需要把加载请求委派给引导类加载器,那直接使用null代替即可
  • 扩展类加载器(Extension ClassLoader):由sun.misc.Launcher$ExtClassLoader实现,它负责加载<JAVA HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量所指定的路径中的所有类库,开发者可以直接使用扩展类加载器
  • 应用程序类加载器(Application ClassLoader):由sun.misc.Launcher$AppClassLoader实现。由于这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以一般也称它为系统类加载。它负责加载用户类路径(ClassPath)上所有指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器

在这里插入图片描述

类加载之间的这种层次关系,称为类加载器的双亲委派模型。双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这里类加载器之间的父子关系一般不会以继承的关系来实现,而是都使用组合关系来复用父加载器的代码

双亲委派模型的工作过程:如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器,只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载

使用双亲委派模型来组织类加载器之间的关系,有一个显而易见的好处就是Java类随着它的类加载器一起具备了一种带有优先级的层次关系。例如类java.lang.Object,它存放在rt.jar之中,无论哪一个类加载器要加载这个类,最终都是委派给处于模型最顶端的启动类加载器进行加载,因此Object类在程序的各种类加载器环境中都是同一个类

七、即时编译

1、什么是即时编译

Java字节码可以被不同平台JVM中的解析器解释执行。由于解析器的效率低下,JVM中的即时编译器(JIT)会在运行时有选择性地将运行次数较多的方法(热点代码)编译成与本地平台相关的机器码(二进制代码),直接运行在底层硬件上

在这里插入图片描述

2、即时编译器类型

在HotSpot虚拟机中,内置了两个JIT,分别为C1编译器和C2编译器,这两个编译器的编译过程是不一样的

C1编译器又叫做Client编译器,是一个简单快速的编译器,主要的关注点在于局部性的优化,采用的优化手段相对简单,因此编译时间较短,适用于执行时间较短或对启动性能有要求的程序,例如,GUI应用对界面启动速度就有一定要求

C2编译器又叫做Server编译器,是为长期运行的服务器端应用程序做性能调优的编译器,采用的优化手段相对复杂,因此编译时间较长,但同时生成代码的执行效率较高,适用于执行时间较长或对峰值性能有要求的程序

Java7引入了分层编译,这种方式综合了C1的启动性能优势和C2的峰值性能优势。可以通过参数-client-server强制指定虚拟机的即时编译模式分层编译将JVM的执行状态分为了5个层次:

  • 第0层:程序解释执行,默认开启性能监控功能(Profiling),如果不开启,可触发第二层编译
  • 第1层:可称为C1编译,将字节码编译为本地代码,进行简单、可靠的优化,不开启Profiling
  • 第2层:也称为C1编译,开启Profiling,仅执行带方法调用次数和循环回边执行次数Profiling的C1编译
  • 第3层:也称为C1编译,执行所有带Profiling的C1编译
  • 第4层:可称为C2编译,也是将字节码编译为本地代码,但是会启用一些编译耗时较长的优化,甚至会根据性能监控信息进行一些不可靠的激进优化

在Java8中,默认开启分层编译,-client-server的设置已经是无效的了。如果只想开启C2,可以关闭分层编译(-XX:-TieredCompilation),如果只想用C1,可以在打开分层编译的同时,使用参数:-XX:TieredStopAtLevel=1

除了这种默认的混合编译模式,可以使用-Xint参数强制虚拟机运行于只有解释器的编译模式下,这时JIT完全不介入工作;还可以使用参数-Xcomp强制虚拟机运行于只有JIT的编译模式下

通过java -version命令行可以直接查看到当前系统使用的编译模式。如下图所示:

在这里插入图片描述

3、热点探测

在HotSpot虚拟机中的热点探测是JIT优化的条件,热点探测是基于计数器的热点探测,采用这种方法的虚拟机会为每个方法建立计数器统计方法的执行次数,如果执行次数超过一定的阈值就认为它是热点方法

虚拟机为每个方法准备了两类计数器:方法调用计数器回边计数器。在确定虚拟机运行参数的前提下,这两个计数器都有一个确定的阈值,当计数器超过阈值溢出了,就会触发JIT编译

方法调用计数器:用于统计方法被调用的次数,方法调用计数器的默认阈值在C1模式下是1500次,在C2模式在是10000次,可通过-XX: CompileThreshold来设定;而在分层编译的情况下,-XX: CompileThreshold指定的阈值将失效,此时将会根据当前待编译的方法数以及编译线程数来动态调整。当方法计数器和回边计数器之和超过方法计数器阈值时,就会触发JIT编译器

回边计数器:用于统计一个方法中循环体代码执行的次数,在字节码中遇到控制流向后跳转的指令称为回边,该值用于计算是否触发C1编译的阈值,在不开启分层编译的情况下,C1默认为13995,C2默认为10700,可通过-XX: OnStackReplacePercentage=N来设置;而在分层编译的情况下,-XX: OnStackReplacePercentage指定的阈值同样会失效,此时将根据当前待编译的方法数以及编译线程数来动态调整

建立回边计数器的主要目的是为了触发 OSR编译,即栈上编译。在一些循环周期比较长的代码段中,当循环达到回边计数器阈值时,JVM会认为这段是热点代码,JIT编译器就会将这段代码编译成机器语言并缓存,在该循环时间段内,会直接将执行代码替换,执行缓存的机器语言

4、编译优化技术

1)、公共子表达式消除

如果一个表达式E已经计算过了,并且从先前的计算到现在E中所有变量的值都没有发生变化,那么E的这次出现就成为了公共子表达式。对于这种表达式,没有必要花时间在对它进行计算,只需要直接用前面计算过的表达式结果代替E就可以了。如果这种优化仅限于程序的基本块内,便称为局部公共子表达式消除,如果这种优化的范围涵盖了多个基本块,那就称为全局公共子表达式消除

2)、数组边界检查消除

Java语言是一门动态安全的语言,数组边界检查是必须做的,但数组边界检查是不是必须在运行期间一次不漏地检查则是可以商量的事情。比如,数组下标是一个常量,如foo[3],只要在编译期根据数据流分析来确定foo.length的值,并判断下标3没有越界,执行的时候就无须判断了。数组访问发生在循环之中,并且使用循环变量来进行数组访问,如果编译器只要通过数据流分析就可以判定循环变量的取值范围永远在区间[0,foo.length)之内,那在整个循环中就可以把数组的上下界检查消除,这可以节省很多次的条件判断操作

隐式异常处理:Java中空指针检查和算数运算中除数为零的检查都采用了隐式异常处理

if (foo != null) {
    return foo.value;
else {
    throw new NullPointException();
}

在使用隐式异常优化之后,虚拟机会把上面伪代码所表示的访问过程变为如下伪代码

try {
     return foo.value;
} catch (segment_fault) {
    uncommon_trap();
}

虚拟机会注册一个Segment Fault信号的异常处理器(伪代码中的uncommon_trap()),这样当foo不为空的Z候,对value的访问是不会额外消耗一次对foo判空的开销的。代价就是当foo真的为空时,必须转入到异常处理器中恢复并抛出NullPointException异常,这个过程必须从用户态转到内核态中处理,结束后再回到用户态,速度远比一次判空检查慢。当foo极少为空的时候,隐式异常优化是值得的,但假如foo经常为空的话,这样的优化反而会让程序更慢,还好HotSpot虚拟机会根据运行期收集到的 Profile 信息自动选择最优方案

3)、方法内联

方法内联除了消除方法调用的成本之外,还为其他优化手段建立良好的基础

只有使用invokespecial指令调用的私有方法、实例构造器、父类方法以及使用invokestatic指令进行调用的静态方法才是在编译期进行解析的,除了上述4中方法之外,其他的Java方法调用都需要在运行时进行方法接收者的多态选择,并且都有可能存在多于一个版本的方法接收者,Java语言中默认的实例方法是虚方法

对于一个虚方法,编译器做内联的时候根本无法确定应该使用哪个版本,为了解决虚方法的内联问题,引入了一种名为类型继承关系分析(CHA)的技术,这是一种基于整个应用程序的类型分析技术,它用于确定在目前已加载的类中,某个接口是否有多于一种的实现,某个类是否存在子类、子类是否为抽象类等信息

编译器在进行内联时,如果是非虚方法,那么直接进行内联就可以了,这时候的内联是有稳定前提保障的。如果遇到虚方法,则会向CHA查询方法在当前程序下是否有多个目标版本可供选择,如果查询结果只有一个版本,那也可以进行内联,不过这种内联就属于激进优化,需要预留一个逃生门称为守护内联。如果程序的后续执行过程中,虚拟机一直没有加载到会令这个方法的接收者的继承关系发生变化的类,那这个内联优化的代码就可以一直使用下去。但如果加载了导致继承关系发生变化的新类,那就需要抛弃已经编译的代码,退回到解释状态执行,或者重新进行编译

如果向CHA查询出来的结果是有多个版本的目标方法可供选择,编译器使用内联缓存来完成方法内联,在未发生方法调用之前,内联缓存状态为空,当第一次调用发生后,缓存记录下方法接收者的版本信息,并且每次进行方法调用时都比较接收者版本,如果以后进来的每次调用的方法接收者版本都是一样的,那这个内联还可以一直用下去。如果发生了方法接收者不一致的情况,就说明程序真正使用了虚方法的多态特性,这时才会取消内联,查找虚方法表进行方法分派

4)、逃逸分析

逃逸分析是为其他优化手段提供依据的分析技术

逃逸分析的基本行为就是分析对象动态作用域:当一个对象在方法中被定义后,它可能被外部方法所引用,例如作为调用参数传递到其他方法中,称为方法逃逸。甚至还有可能被外部线程访问到,譬如赋值给类变量或可以在其他线程中访问的实例变量,称为线程逃逸

如果能证明一个对象不会逃逸到方法或线程之外,也就是别的方法或线程无法通过任何途径访问到这个对象,则可能为这个变量进行一些高效的优化:

  • 栈上分配:如果确定一个对象不会逃逸出方法之外,那让这个对象在栈上分配内存将会是一个很不错的主意,对象所占用的空间内存就可以随栈帧出栈而销毁。在一般应用中,不会逃逸的局部对象所占的比例很大,如果能使用栈上分配,那大量的对象就会随着方法的结束而自动销毁了,垃圾收集系统的压力将会小很多
  • 同步消除:线程同步本身是一个相对耗时的过程,如果逃逸分析能够确定一个变量不会逃逸出线程,无法被其他线程访问,那这个变量的读写肯定就不会有竞争,对这个变量实施的同步措施也就可以消除掉
  • 标量替换:标量是指一个数据已经无法再分解成更小的数据来表示了,Java虚拟机中的原始数据类型(int、long等数值类型以及reference类型等)都不能再进一步分解,它们就可以称为标量。相对的,如果一个数据可以继续分解,那它就称作聚合量,Java中的对象就是最典型的聚合量。如果把一个Java对象拆散,根据程序访问的情况,将其使用到的成员变量恢复原始类型来访问就叫做标量替换。如果逃逸分析证明一个对象不会被外部访问,并且这个对象可以被拆散的话,那程序真正执行的时候将可能不创建这个对象,而改为直接创建它的若干个被这个方法使用的成员变量来代替。将对象拆分后,除了可以让对象的成员变量在栈上分配和读写外,还可以为后续进一步的优化手段创建条件
发布了177 篇原创文章 · 获赞 407 · 访问量 8万+

猜你喜欢

转载自blog.csdn.net/qq_40378034/article/details/103836077
今日推荐