深刻理解JVM类加载机制

简述:虚拟机把描述类的数据从class文件加载到内存,并对数据进行校验、转换解析和初始化,最终造成能够被虚拟机直接使用的Java类型,这就是虚拟机的类加载机制。java

下面咱们具体来看类加载的过程:数组

类的生命周期
类的生命周期

类从被加载到内存中开始,到卸载出内存,经历了加载、链接、初始化、使用四个阶段,其中链接又包含了验证、准备、解析三个步骤。这些步骤整体上是按照图中顺序进行的,可是Java语言自己支持运行时绑定,因此解析阶段也能够是在初始化以后进行的。以上顺序都只是说开始的顺序,实际过程当中是交叉进行的,加载过程当中可能就已经开始验证了。缓存

类加载的时机

首先要知道何时类须要被加载,Java虚拟机规范并无约束这一点,可是却规定了类必须进行初始化的5种状况,很显然加载、验证、准备得在初始化以前,下面具体来讲说这5种状况:
安全

类加载时机
类加载时机

其中状况1中的4条字节码指令在Java里最多见的场景是:
1 . new一个对象时
2 . set或者get一个类的静态字段(除去那种被final修饰放入常量池的静态字段)
3 . 调用一个类的静态方法bash

类加载的过程

下面咱们一步一步分析类加载的每一个过程网络

1. 加载

加载是整个类加载过程的第一步,若是须要建立类或者接口,就须要如今Java虚拟机方法区建立于虚拟机实现规定相匹配的内部表示。通常来讲类的建立是由另外一个类或者接口触发的,它经过本身的运行时常量池引用到了须要建立的类,也多是因为调用了Java核心类库中的某些方法,譬如反射等。数据结构

通常来讲加载分为如下几步:多线程

  1. 经过一个类的全限定名获取此类的二进制字节流
  2. 将这个字节流所表明的静态存储结构转化为方法区的运行时数据结构
  3. 在内存中生成一个表明这个类的java.lang.Class对象,做为方法区这个类的各类数据的访问入口

建立名字为C的类,若是C不是数组类型,那么它就能够经过类加载器加载C的二进制表示(即Class文件)。若是是数组,则是经过Java虚拟机建立,虚拟机递归地采用上面提到的加载过程不断加载数组的组件。函数

Java虚拟机支持两种类加载器:布局

  • 引导类加载器(Bootstrap ClassLoader)
  • 用户自定义类加载器(User-Defined Class Loader)

用户自定义的类加载器应该是抽象类ClassLoader的某个子类的实例。应用程序使用用户自定义的类加载器是为了扩展Java虚拟机的功能,支持动态加载并建立类。好比,在加载的第一个步骤中,获取二进制字节流,经过自定义类加载器,咱们能够从网络下载、动态产生或者从一个加密文件中提取类的信息。

关于类加载器,会新开一篇文章描述。

2.验证

验证做为连接的第一步,用于确保类或接口的二进制表示结构上是正确的,从而确保字节流包含的信息对虚拟机来讲是安全的。Java虚拟机规范中关于验证阶段的规则也是在不断增长的,但大致上会完成下面4个验证动做。

验证
验证

1 . 文件格式验证:主要验证字节流是否符合Class文件格式规范,而且能被当前版本的虚拟机处理。
主要验证点:

  • 是否以魔数0xCAFEBABE开头
  • 主次版本号是否在当前虚拟机处理范围以内
  • 常量池的常量是否有不被支持的类型 (检查常量tag标志)
  • 指向常量的各类索引值中是否有指向不存在的常量或不符合类型的常量
  • CONSTANT_Utf8_info型的常量中是否有不符合UTF8编码的数据
  • Class文件中各个部分及文件自己是否有被删除的或者附加的其余信息
    ...
    实际上验证的不只仅是这些,关于Class文件格式能够参考个人深刻理解JVM类文件格式,这阶段的验证是基于二进制字节流的,只有经过文件格式验证后,字节流才会进入内存的方法区中进行存储。

2 . 元数据验证:主要对字节码描述的信息进行语义分析,以保证其提供的信息符合Java语言规范的要求。
主要验证点:

  • 该类是否有父类(只有Object对象没有父类,其他都有)
  • 该类是否继承了不容许被继承的类(被final修饰的类)
  • 若是这个类不是抽象类,是否实现了其父类或接口之中要求实现的全部方法
  • 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,出现不符合规则的方法重载,例如方法参数都一致,可是返回值类型却不一样)
    ...

3 . 字节码验证:主要是经过数据流和控制流分析,肯定程序语义是合法的、符合逻辑的。在第二阶段对元数据信息中的数据类型作完校验后,字节码验证将对类的方法体进行校验分析,保证被校验类的方法在运行时不会作出危害虚拟机安全的事件。
主要有:

  • 保证任意时刻操做数栈的数据类型与指令代码序列都能配合工做,例如不会出现相似的状况:操做数栈里的一个int数据,可是使用时却当作long类型加载到本地变量中
  • 保证跳转不会跳到方法体之外的字节码指令上
  • 保证方法体内的类型转换是合法的。例如子类赋值给父类是合法的,可是父类赋值给子类或者其它毫无继承关系的类型,则是不合法的。
  1. 符号引用验证:最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动做将在链接的第三阶段解析阶段发生。符号引用是对类自身之外(常量池中的各类符号引用)的信息进行匹配校验。
    一般有:
  • 符号引用中经过字符串描述的全限定名是否找到对应的类
  • 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段
  • 符号引用中的类、方法、字段的访问性(private,public,protected、default)是否可被当前类访问
    符号引用验证的目的是确保解析动做可以正常执行,若是没法经过符号引用验证,那么将会抛出一个java.lang.IncompatibleClassChangeError异常的子类,如java.lang.IllegalAccessError、java.lang.NoSuchFieldError、java.lang.NoSuchMethodError等。

验证阶段很是重要,但不必定必要,若是全部代码极影被反复使用和验证过,那么能够经过虚拟机参数-Xverify: none来关闭验证,加速类加载时间。

3.准备

准备阶段的任务是为类或者接口的静态字段分配空间,而且默认初始化这些字段。这个阶段不会执行任何的虚拟机字节码指令,在初始化阶段才会显示的初始化这些字段,因此准备阶段不会作这些事情。假设有:

public static int value = 123;复制代码

value在准备阶段的初始值为0而不是123,只有到了初始化阶段,value才会为0。
下面看一下Java中全部基础类型的零值:

数据类型 零值
int 0
long 0L
short (short)0
char '\u0000'
byte (byte)0
boolean false
float 0.0f
double 0.0d
reference null

一种特殊状况是,若是字段属性表中包含ConstantValue属性,那么准备阶段变量value就会被初始化为ConstantValue属性所指定的值,好比上面的value若是这样定义:

public static final int value = 123;复制代码

编译时,value一开始就指向ConstantValue,因此准备期间value的值就已是123了。

4.解析

解析阶段是把常量池内的符号引用替换成直接引用的过程,符号引用就是Class文件中的CONSTANT_Class_info CONSTANT_Fieldref_infoCONSTANT_Methodref_info等类型的常量。下面咱们看符号引用和直接引用的定义。

符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号能够是任何形式的字面量,只要能够惟必定位到目标便可。符号引用于内存布局无关,因此所引用的对象不必定须要已经加载到内存中。各类虚拟机实现的内存布局能够不一样,可是接受的符号引用必须是一致的,由于符号引用的字面量形式已经明肯定义在Class文件格式中。

直接引用(Direct References):直接引用时直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。直接引用和虚拟机实现的内存布局相关,同一个符号引用在不一样虚拟机上翻译出来的直接引用通常不会相同。若是有了直接引用,那么它必定已经存在于内存中了。

如下Java虚拟机指令会将符号引用指向运行时常量池,执行任意一条指令都须要对它的符号引用进行解析:

引发解析的命令
引发解析的命令

对同一个符号进行屡次解析请求是很常见的,除了invokedynamic指令之外,虚拟机基本都会对第一次解析的结果进行缓存,后面再遇到时,直接引用,从而避免解析动做重复。

对于invokedynamic指令,上面规则不成立。当遇到前面已经由invokedynamic指令触发过解析的符号引用时,并不意味着这个解析结果对于其余invokedynamic指令一样生效。这是由invokedynamic指令的语义决定的,它原本就是用于动态语言支持的,也就是必须等到程序实际运行这条指令的时候,解析动做才会执行。其它的命令都是“静态”的,能够再刚刚完成记载阶段,尚未开始执行代码时就解析。

下面来看几种基本的解析:
类与接口的解析: 假设Java虚拟机在类D的方法体中引用了类N或者接口C,那么会执行下面步骤:

  1. 若是C不是数组类型,D的定义类加载器被用来建立类N或者接口C。加载过程当中出现任何异常,能够被认为是类和接口解析失败。
  2. 若是C是数组类型,而且它的元素类型是引用类型。那么表示元素类型的类或接口的符号引用会经过递归调用来解析。
  3. 检查C的访问权限,若是D对C没有访问权限,则会抛出java.lang.IllegalAccessError异常。

字段解析
要解析一个未被解析过的字段符号引用,首先会对字段表内class_index项中索引的CONSTANT_Class_info符号引用进行解析,这边记不清的能够继续回顾深刻理解JVM类文件格式,也就是字段所属的类或接口的符号引用。若是在解析这个类或接口符号引用的过程当中出现了任何异常,都会致使字段解析失败。若是解析完成,那将这个字段所属的类或者接口用C表示,虚拟机规范要求按照以下步骤对C进行后续字段的搜索。

1 . 若是C自己包含了简单名称和字段描述符都与目标相匹配的字段,则直接返回这个字段的直接引用,查找结束。
2 . 不然,若是在C中实现了接口,将会按照继承关系从下往上递归搜索各个接口和它的父接口,若是接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
3 . 再否则,若是C不是java.lang.Object的话,将会按照继承关系从下往上递归搜索其父类,若是在类中包含
了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
4 . 若是都没有,查找失败退出,抛出java.lang.NoSuchFieldError异常。若是返回了引用,还须要检查访问权限,若是没有访问权限,则会抛出java.lang.IllegalAccessError异常。

在实际的实现中,要求可能更严格,若是同一字段名在C的父类和接口中同时出现,编译器可能拒绝编译。

类方法解析
类方法解析也是先对类方法表中的class_index项中索引的方法所属的类或接口的符号引用进行解析。咱们依然用C来表明解析出来的类,接下来虚拟机将按照下面步骤对C进行后续的类方法搜索。
1 . 首先检查方法引用的C是否为类或接口,若是是接口,那么方法引用就会抛出IncompatibleClassChangeError异常
2 . 方法引用过程当中会检查C和它的父类中是否包含此方法,若是C中确实有一个方法与方法引用的指定名称相同,而且声明是签名多态方法(Signature Polymorphic Method),那么方法的查找过程就被认为是成功的,全部方法描述符所提到的类也须要解析。对于C来讲,没有必要使用方法引用指定的描述符来声明方法。
3 . 不然,若是C声明的方法与方法引用拥有一样的名称与描述符,那么方法查找也是成功。
4 . 若是C有父类的话,那么按照第2步的方法递归查找C的直接父类。
5 . 不然,在类C实现的接口列表及它们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,若是存在相匹配的方法,说明类C时一个抽象类,查找结束,而且抛出java.lang.AbstractMethodError异常。

  1. 不然,宣告方法失败,而且抛出java.lang.NoSuchMethodError
    最后的最后,若是查找过程成功返回了直接引用,将会对这个方法进行权限验证,若是发现不具有对此方法的访问权限,那么会抛出 java.lang.IllegalAccessError异常。

接口方法解析
接口方法也须要解析出接口方法表的class_index项中索引的方法所属的类或接口的符号引用,若是解析成功,依然用C表示这个接口,接下来虚拟机将会按照以下步骤进行后续的接口方法搜索。
1 . 与类方法解析不一样,若是在接口方法表中发现class_index对应的索引C是类而不是接口,直接抛出java.lang.IncompatibleClassChangeError异常。
2 . 不然,在接口C中查找是否有简单名称和描述符都与目标匹配的方法,若是有则直接返回这个方法的直接引用,查找结束。
3 . 不然,在接口C的父接口中递归查找,直到java.lang.Object类为止,看是否有简单名称和描述符都与目标相匹配的方法,若是有则返回这个方法的直接引用,查找结束。
4 . 不然,宣告方法失败,抛出java.lang.NoSuchMethodError异常。

因为接口的方法默认都是public的,因此不存在访问权限问题,也就基本不会抛出java.lang.IllegalAccessError异常。

5.初始化

初始化是类加载的最后一步,在前面的阶段里,除了加载阶段能够经过用户自定义的类加载器加载,其他部分基本都是由虚拟机主导的。可是到了初始化阶段,才开始真正执行用户编写的java代码了。

在准备阶段,变量都被赋予了初始值,可是到了初始化阶段,全部变量还要按照用户编写的代码从新初始化。换一个角度,初始化阶段是执行类构造器<clinit>()方法的过程。

<clinit>()方法是由编译器自动收集类中的全部类变量的赋值动做和静态语句块(static语句块)中的语句合并生成的,编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块以前的变量,定义在它以后的变量,在前面的静态语句块中能够赋值,可是不能访问

public class Test {
  static {
    i=0;  //能够赋值
    System.out.print(i); //编译器会提示“非法向前引用”
  }
  static int i=1;
}复制代码

<clinit>()方法与类的构造函数<init>()方法不一样,它不须要显示地调用父类构造器,虚拟机会宝成在子类的<clinit>()方法执行以前,父类的<clinit>()已经执行完毕,所以在虚拟机中第一个被执行的<clinit>()必定是java.lang.Object的。

也是因为<clinit>()执行的顺序,因此父类中的静态语句块优于子类的变量赋值操做,因此下面的代码段,B的值会是2。

static class Parent {
  public static int A=1;
  static {
    A=2;
  }
}

static class Sub extends Parent{
  public static int B=A;
}

public static void main(String[] args) {
  System.out.println(Sub.B);
}复制代码

<clinit>()方法对于类来讲不是必须的,若是一个类中既没有静态语句块也没有静态变量赋值动做,那么编译器都不会为类生成<clinit>()方法。

接口中不能使用静态语句块,可是容许有变量初始化的赋值操做,所以接口与类同样都会生成<clinit>()方法,可是接口中的<clinit>()不须要先执行父类的,只有当父类中定义的变量使用时,父接口才会初始化。除此以外,接口的实现类在初始化时也不会执行接口的<clinit>()方法

虚拟机会保证一个类的<clinit>()方法在多线程环境中能被正确的枷锁、同步。若是多个线程初始化一个类,那么只有一个线程会去执行<clinit>()方法,其它线程都须要等待。

6.Java虚拟机退出

Java虚拟机退出的通常条件是:某些线程调用Runtime类或System类的exit方法,或者时Runtime类的halt方法,而且Java安全管理器也容许这些exit或者halt操做。除此以外,在JNI(Java Native Interface)规范中还描述了当使用JNI API来加载和卸载(Load & Unload)Java虚拟机时,Java虚拟机退出过程。

相关文章
相关标签/搜索