JVM笔记:Java虚拟机的类加载机制

前言

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

  • 类加载的流程

    类从被加载到虚拟机内存中开始,到卸载出内存位置,它的整个生命周期包括:加载、验证、准备、解析、初始化、使用和卸载,其中验证、准备、解析三个部分统称为链接。这七个阶段的发生顺序如图1-1所示。
    图1-1:类加载流程图

上图中,加载、验证、准备、初始化和卸载这5个阶段的顺序是固定的,类的加载过程必须按照这种顺序循序渐进地开始,可是解析阶段则不必定:他在否种状况下能够再初始化阶段以后再开始,这是为了支持Java语言的运行时绑定。同事,上面这是阶段一般都是互相交叉地混合进行的,一般会在一个阶段执行的过程当中调用、激活另外一个阶段(例如在一个类的内部初始化另外一个类)。数据库

  • 类加载的时机

    什么状况下须要开始类加载过程的第一个阶段:加载?Java虚拟机规范中并无进行强制约束,这点交给虚拟机的具体实现来自由把握。可是对于初始化阶段,虚拟机规范则是严格规定了有且只有5中状况必须当即对类进行初始化(加载、验证、准备天然须要在此以前开始)。数组

    • 遇到new 、getstatic、putstatic、invokestatic这四条字节码指令时,若是类没有进行国储石化,则须要先触发其初始化。生成这四条指令的场景是:使用new关键字实例化对象,读取或这只一个类的静态变量(被final修饰、已在编译期把结果放入常量池的静态字段除外)的时候,以及调用一个类的静态方法的时候。
    • 使用java.lang.reflect包的方法对类进行反射调用的时候,若是类没有通过初始化,则须要先触发其初始化
    • 当初始化一个类的时候,若是其父类尚未通过初始化,则须要先触发其父类的初始化。
    • 虚拟机启动时,用户须要制定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。
    • 当使用JDK1.7的动态语言支持时,若是一个java.lang.invoke.MethodHandle实例最后的解析结果REF_getStatic、REF_putStatic、REF_invokeStatic 的方法句柄,而且这个方法句柄所对应的类没有通过初始化,则须要先触发其初始化。

    对于以上5种会触发类进行初始化的场景,虚拟机规范中使用了一个很强烈的现定于:有且只有,这5种场景中的行为被称为对一个类进行主动引用,可是除此以外,全部引用类的方式都不会触发初始化,称为被动引用,以下面例子:缓存

public class Parent {
    public static int a = 1;
    static {
        System.out.println("Parent init");
    }
}
public class Son extends Parent{
    static {
        System.out.println("Son init");
    }
}
   public static void main(String[] args) {
        System.out.println("args = [" + Son.a + "]");
    }
输出结果:
Parent init
args = [1]
复制代码

对于静态字段,只有直接定义这个字段的类才会被初始化,所以经过其子类来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化,至因而否要触发子类的加载和验证,在虚拟机规范中并未明确规定,这点取决于虚拟机的具体实现,对于Sun HotSpot虚拟机来讲,可经过-XX:_TraceClassLoading参数观察到次操做会致使子类的加载。安全

除此以外,经过数组定义来引用类,不会触发此类的初始化。bash

public static void main(String[] args) {
        Parent[] parentArry = new Parent[10];
    }
复制代码

运行上述代码后什么输出也没有,说明并无触发Parent类的初始化阶段。可是这段代码里面触发了另外一个名为[Lxxx.xxx.Parent(前面的xxx指代类的包名)的类的初始化,这里是否是看起来有点眼熟,在前面字节码的文章里能够知道[L这里表示的是一个对象数组。它是由虚拟机自动生成的、直接继承与Object的类,建立动做由字节码指令newarray触发。 这个类表示了一个元素类型为Parent的一维数组,数组中应有的属性和方法(可被用户直接调用的方法只有length和clone)都实如今这个类里。在Java语言中,当检查到数组越界时会抛出ArrayIndexOutOfBoundsException异常,可是这个异常检测不是封装在数组元素访问的类中,而是封装在数组访问的xaload、xastore字节码指令中。服务器

当引用一个类的静态且被final修饰的常量时,不会触发此类的初始化网络

public class Parent {
    public static final int a = 1;
    static {
        System.out.println("Parent init");
    }
}
  public static void main(String[] args) {
        System.out.println("args = [" + Son.a + "]");
    }
输出结果:
args = [1]
复制代码

由于做为final修饰的常量时一个不可变的值,因此在编译阶段会经过常量传播优化,将此常量的值1存储到了主类(main方法所在的类)的常量池中,因此之后主类中对常量1的引用实际都被转化了主类对自身常量池的引用,也就是说,实际上主类的Class文件中并无Parent类得符号引用,这两个类在编异常Class以后就不存在任何联系了。数据结构

接口的架子啊过程与类加载过程稍有不一样,针对接口须要作一些特殊说明:接口也有初始化过程,这点和类是一致的,可是接口中不能使用static{}语句块,可是编译器仍然会为接口生成<client>类构造器,用于初始化接口中所定义的成员变量。接口与类正则有所区别的是前面讲述的须要初始化场景的第三种:当一个类在初始化时,要求其父类所有都已经初始化过了。可是一个接口在初始化时,并不要求其负借口所有都完成了初始化,只有在真正使用到负借口的时候(如引用接口中定义的常量)才会被初始化。多线程

  • 类加载的步骤

接下来详细讲解一下类加载的全过程,也就是加载、验证、准备、解析、初始化这5个阶段锁执行的具体动做。

  • 加载

加载是类加载过程的一个阶段,在加载阶段,虚拟机主要完成一下三件事

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

加载阶段没有规定加载的内容从哪来,由于它加载的是一个类的全限定名来获取定义此类的二进制字节流。因此,虚拟机根本没有制定要从那里获取,怎样获取,可是常见的获取方式有下面几种:

  • 从zip包中获取,也就是常见的JAR,EAR,WAR
  • 从网络中获取,最典型的场景应用就是Applet
  • 运行时计算生成,主要用于动态代理技术,在java.lang.reflect.Proxy中就是用了ProxyGenerator.gengrateProxyClass来为特定接口生成形式为*$Proxy的代理类的二进制字节流
  • 由其余文件生成,例如由JSP文件生成对应的Class类
  • 从数据库中读取,例若有些中间件服务器能够选择把程序安装到数据库中来完成程序代码在集群间的分发。 ......

对于类加载过程的其余阶段,一个非数组的加载阶段(准确的说,是加载阶段中获取类的二进制字节流的动做)是开发人员可控性最强的,由于加载阶段可使用系统提供的引导类加载器来完成,也能够由用户自定义的类加载器去完成(例如对字节码加密,而后经过自定义类加载器来解密后加载类),开发人员能够经过定义本身的类加载器去控制字节流的获取方式。

可是数组类并非经过类加载器建立的,它是由Java虚拟机直接建立的。不过数据类型与类加载器仍然有很密切的关系,由于数组类的元素类型最终仍是要考类加载器去建立,一个数组类的建立过程就遵循如下规则:

  • 1 . 若是数组的类型时一个引用类型,那就须要去加载这个组件类型,而后在加载该组件类型的类加载器的类名称空间上被标识,这一点在后续的类加载器中会讲述到。
  • 2 . 若是数组的类型时基础数据类型,Java虚拟机会把数组标记为与引导类加载器关联。
  • 3 . 数组类的可见性与它的组件类型可见性一致,若是组件类型不是引用类型,那数组的可见性将默认为public。

加载阶段完成后,虚拟机将外部的二进制字节流按照虚拟机所需的格式存储在方法区之中,方法区中的数据存储格式由虚拟机自行定义,而后在内存中实例化一个Class类的对象(并无明确是在Java堆中,对于HotSpot虚拟机而言mClass对象比较特殊,他虽然是对象,可是存放在方法区里面),这个对象将做为程序访问方法区中的这些类型数据的外部接口。

加载阶段和后续的链接阶段的部份内容是交叉进行的,加载阶段还没有完成时,链接阶段可能已经开始了,可是这些夹在加载阶段的动做仍然属于链接阶段。

  • 验证

    验证是链接阶段的第一部,这一步的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,而且不会危害虚拟机自身的安全。虚拟机若是不检查输入的字节流,对其彻底信任的话,极可能会由于载入了有害的字节流而致使系统崩溃,因此验证是虚拟机对自身保护的一项重要工做。 从2011年发布的《Java虚拟机规范(JSE 7版)》中从总体上上看,研制阶段大体上会完成下面4个阶段的校验动做:文件格式校验、元数据校验、字节码校验、符号引用验证。

  • 1 . 文件格式验证 验证字节流是否符合Class文件格式的规范,而且能被当前版本的虚拟机处理,可能包含下面这些验证点:

    • 是否以魔数0xCAFEBABY开头。
    • 主次版本号是否在当前虚拟机处理范围以内。
    • 常量池的常量中是否有不被支持的常量类型(检查常量的tag标志)。
    • 指向常量的各类索引值是否有指向不存在的常量或不符合类型的常量。
    • CONSTANT_Utf8_info类型的常量中是否有不符合UTF8编码的数据。
    • Class文件中各个部分及文件自己是否有被删除的或附加的其余信息。 ......

上面只是验证的一小部分点,目的是包在输入的字节流能正确地解析而且格式上符合一个Java类型的数据要求。只有经过这个阶段的兖州,字节流才会进入内存的方法区进行存储,后面的三个验证阶段所有是基于方法取得存储结构进行的,不会再直接操做字节流。

  • 2 . 元数据验证 第二步是对字节码描述的信息进行语义分析,保证其描述的信息符合Java语言规范的要求,这个阶段的包含的验证点以下:

    • 这个类是否有父类(除了Object,全部的类都应该有父类)。
    • 这个类的父类是否继承了不被容许继承的类(被final修饰的类)。
    • 若是这个类不是抽象类,是否实现了其父类或接口中的要求实现的全部方法。
    • 类中的字段、方法是否和父类产生了矛盾(例如覆盖了父类的final字段)。 ......
  • 3 .字节码验证 这是验证过程当中最复杂的一个阶段,主要目的是经过数据流和控制流分析,肯定程序语义是合法的、符合逻辑的。交验完元数据后,这个阶段将对类的方法体进行校验分析,保证被校验类的方法在运行时不会作出危害虚拟机安全的事件,例如:

    • 操做数栈的数据类型和指令代码序列能配合工做,例如不会出现操做数栈存入了int类型数据,加载时却用long类型。
    • 保证跳转指令(goto)不会跳转到方法体之外的字节码指令上。
    • 保证方法体中的类型转换时有效的。 ......

    若是一个类方法体没经过校验,那确定是有问题的,可是经过了校验也不必定是彻底安全的,即经过程序去校验程序逻辑是没法作到绝对准确的

    虚拟机设计团队为了不过多的时间消耗在字节码校验阶段,在JDK1.6以后Javac虚拟机中进行了一项优化,给方法体的Code属性的属性表中增长了一项名为StackMapTable的属性,这项属性描述了方法体中全部的基本亏啊开始时本地变量表和操做数栈应有的状态,字节码校验期间,就不须要根据程序推导这些状态的合法性,只须要检查StackMapTable属性中的记录是否合法便可,这样将字节码验证的类型推导转换为类型检查,从而节省一些时间。

  • 4 .符号引用验证

    最后一个阶段校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动做将在链接的第三阶段解析中发生,符号引用验证能够看作是对类自身之外(常量池中的各类符号引用)的信息进行匹配性校验,一样须要校验下列内容:

    • 符号引用中经过字符串描述的全限定名是否能找到对应的类。
    • 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段。
    • 符号引用中的类、字段、方法的访问类型是否可被当前类访问。 ...... 符号引用验证的目的是确保解析动做能正常执行,若是没法经过符号引用,那么会抛出一个IncompatibleClassChangeError异常的子类,例如NoSuchField(Method)Error

    对于虚拟机来讲,验证阶段是一个重要,但不是必要的阶段,若是你的代码已经被反复使用和验证过了,那么在实施阶段就能够考虑用-Xverify:none参数来关闭大部分的类验证措施,以缩短类加载的时间。

  • 准备

    准备阶段是正式为类变量分配内存并设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。这个阶段有两个容易混淆的概念须要强调一下:首先,这个时候进行内存分配的仅包含类变量(static变量),而不包括实例变量,实例变量将会在对象实例化时随着对象一块儿分配在Java堆中;其次,这里所说的初始值他那个场状况下是数据类型的零值。

public static  int number= 1;
  public static final int numberFinal= 123;
复制代码

上面例子中number在准备阶段后的初始值为0而不是1,由于这个时候还没有开始执行仍和Java方法,而把number赋值为1的putstatic指令时程序被编译后,存放于类构造器<clinit>()方法之中,因此把number赋值为1的动做将在初始化阶段才会执行。

可是在特殊状况下,若是类字段的字段属性表中存在ConstantValue属性(被final修饰),那在准备阶段变量numberFinal就会被初始化为指定的值。编译时Javac将会为numberFinal生成ConstantValue属性,在准备阶段虚拟机就会根据ConstantValue的设置将值设为123。

  • 解析

    解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,符号引用在JVM笔记:Java虚拟机的常量池提到过不少次了,在Class文件中他以CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info等类型的常量出现,那解析阶段中所说的直接引用和符号引用又有什么关联呢?

    符号引用(SymbolicReferences):符号引用以一组符号来描述所引用的目标,符号能够是任何形式的字面量,只要使用时可以无歧义地定位到目标便可。可是引用的目标并不必定已经加载到内存中,它在不少状况下相似一个占位符,表示未来须要指向这么一个内容,而后在后续阶段将其替换为直接引用。各类虚拟机所能接受的符号引用必须是一致的,没由于符号引用的字面量形式明肯定义在Java虚拟机规范的Class文件格式中。

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

    虚拟机规范中并未规定解析阶段发生的具体时间,只要求了在执行anewarray、multianewarray、checkcast、getfield、getstatic、instanceof、invoke(dynamic,interfance,special,static,virtual)、ldc、ldc_w、new、putfield、putstatic这16个字节码以前,先对他们所使用的符号引用进行解析。因此虚拟机实现能够根据须要来判断究竟是在类被加载器加载时就对常量池中的符号引用进行解析,仍是等到一个符号引用将要被使用前才去解析它。

    除了invokedynamic指令之外,虚拟机实现了对第一次解析的结果进行缓存,在运行时常量池中记录直接引用,并把常量标识为已解析状态,从而避免解析动做重复,若是一个符号引用解析成功或失败,那么后续对其的引用解析也应该收到成功或者异常告知。

    对于invokedynamic指令,当碰到某个前面已经由invokedynamic指令触发过解析的符号引用时,并不意味着这个解析结果对于其余invokedynamic指令也一样生效。由于invokedynamic指令的目的原本就是用于动态语言支持,它所对应的引用称为动态调用点限定符,这里动态的含义就是必须等到程序运行到这条指令的时候,解析动做才能进行。相对的,其他可触发解析的指令都是静态的,便可以在刚刚完成加载阶段,尚未开始执行代码时就开始进行解析。

    解析动做主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行,这里主要介绍前面4种,后面三种与JDK新增的动态语言支持息息相关,暂时这里很少作赘述,前面三种分别对于常量池的CONSTANT_(Class、Fieldref、Methodref、InterfaceMethodref)_info

    1 . 类或接口的解析

    假设在类W要把一个从未解析过的符号引用N解析为一个类或接口O的直接引用,那虚拟机完成整个过程主要分为如下三个步骤。

    • 若是O不是一个数组类型,那虚拟机将会把表明N的全限定名传递给W的类加载器中去加载这个类O。在加载过程当中,因为元数据验证,字节码验证的须要,有可能触发其余相关类的加载动做,一旦这个加载过程出现了异常,解析过程就宣告失败。

    • 若是O是一个数组类型,而且数组类型为对象(描述符为[Lxxx/xxx),那将会按照上面的规则加载数组元素类型,若是N的描述符如前面锁假设的形式,那么就会加载该元素类型的对象,接着由虚拟机生成一个表明此数组维度和元素的数组对象。

    • 若是上面两步没有出现异常,那么在c虚拟机中实际上已经成为一个有效的类或接口了,但在解析完成前还要进行符号引用验证,确认W是否具有对O的访问权限,若是发现不具有访问权限,将抛出IlleagalAccessError异常。

    2 . 字段解析

    解析一个未被解析过得字段符号引用,首先将会对字段表内class_index项中索引的CONSTANT_Class_info符号引用进行解析,也就是字段所属的类和接口的符号引用,也就是说,欲解字段,必先解其所在类。

    • 解析完类后,若是类自己包含了简单名称和字段描述符都与目标匹配的字段,则直接返回该字段的直接引用

    • 若是该类实现了接口,将会按照继承关系递归搜索各个接口和他的父接口,若是接口中包含了简单名称和字段描述符都与目标匹配的字段,则直接返回该字段的直接引用。

    • 若是该类不是Object的话,将会按照继承关系递归搜索其父类,若是父类中包含了简单名称和字段描述符都与目标匹配的字段,则直接返回该字段的直接引用。

    • 若是以上步骤都失败,那么抛出NoSuchFieldError异常。

    • 一样的若是不具有对返回的字段引用的访问权限,抛出IlleagalAccessError异常。

    • 若是一个同名字段同时出如今类的接口和父类中,或者在本身父类的多个接口中出现,那么编译器将可能拒绝编译。

    3 . 类方法解析

    类方法解析第一个步骤和字段解析同样,也须要先解析出该方法所在的类。而后按照下面步骤进行后续的类方法搜索。

    • 1)类方法和接口方法符号引用的常量类型定义是分开的(一个是Methodref,一个是InterfaceMethodref),若是类方法表中发现索引的是一个接口,那么会抛出IncompatibleClassChangeError异常。

    • 2)若是经过第一步,接着在类中查找是否包含了简单名称和字段描述符都与目标匹配的方法,则直接返回该方法的直接引用。

    • 3)不然,在类的父类中递归查找是否包含了简单名称和字段描述符都与目标匹配的方法,则直接返回该方法的直接引用。

    • 4)不然,在类实现的接口列表和他们的父接口中递归查找是否包含了简单名称和字段描述符都与目标匹配的方法,若是存在,说明该类是一个抽象类(若是不是抽象类,该类中会查找到这个方法),这时候抛出AbstractMethodError异常。

    • 5)以上步骤都不行,抛出NoSuchMethodError异常。

    • 6)一样的若是不具有对返回的方法引用的访问权限,抛出IlleagalAccessError异常。

    4 . 类方法解析

    老样子,接口方法也须要先解析出接口方法表class_info想中索引的方法所属的类或接口的符号引用。而后按照下面步骤进行后续的接口方法搜索。

    • 1)与类方法解析相反,若是在接口方法表中发现该接口所对应的是一个类而不是接口,抛出IncompatibleClassChangeError异常。

    • 2)若是经过第一步,接着在接口中查找是否包含了简单名称和字段描述符都与目标匹配的方法,则直接返回该方法的直接引用。

    • 3)不然,在接口的父接口中递归查找,直到Object类为止,看是否包含了简单名称和字段描述符都与目标匹配的方法,则直接返回该方法的直接引用。

    • 4)以上步骤都不行,抛出NoSuchMethodError异常。

    • 5)由于接口方法默认都是public的没因此不存在访问权限,因此接口方法不会抛出IlleagalAccessError异常。

  • 初始化

    类初始化时类加载过程的最后一步,前面的类加载过程当中,除了在加载阶段用户应用程序能够经过自定义类加载器参与以外,其他动做彻底由虚拟机主导和控制。到了初始化阶段,才真正开始执行类中定义的Java程序代码(或者说字节码)。

    在准备阶段,变量已经赋值过一次系统要求的初始值,而在初始化阶段,则根据程序制定的计划去初始化类变量和其余资源,从另外一个角度来表达:初始化阶段是指向类构造器<clinit>()方法的过程。

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

public class Parent {
    static {
        a=2;
        System.out.println("Parent init"+a);
    }
    public static  int a = 1;
}
复制代码

上面代码中能够在代码块中对a进行赋值,可是没啥做用,由于会被后面的a从新赋值为1,并且代码块内不能调用下面的类变量,会显示illeagal forward reference错误

<clinit>()方法与类的构造方法,也就是实例构造器 <init>()不一样,它不须要显示地调用它父类构造器,虚拟机会保证在子类的 <clinit>()方法执行以前,父类的 <clinit>()方法已经执行完毕,也就是说,父类中定义的静态语句块要因为子类的变量赋值操做,所以在虚拟机中第一个被执行的 <clinit>()方法的类确定是Object。

下面例子中输出的结果就是2,由于父类的静态赋值操做比子类先执行

public class Parent {
    public static  int a = 1;
    static {
        a=2;
    }
}
public class Son extends Parent{
      public static int b=a;
}
 public static void main(String[] args) {
        System.out.println("args = [" + Son.b + "]");
    }
复制代码

<clinit>()方法不是必须的,若是一个类中没有静态语句块,也没有对类变量的赋值操做,那么编译器能够不为这个类生成 <clinit>()方法。

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

虚拟机会保证一个类的 <clinit>()方法在多线程环境中被正确的加锁,用不,若是多个线程同时去初始化一个类,那么只有一个线程回去执行这个类 <clinit>()方法,其余线程都须要阻塞等待,这也是静态单例实现的原理。

  • 总结

    本文内容来自于《深刻Java虚拟机》,感兴趣的朋友能够入这本书看看。
相关文章
相关标签/搜索