JVM学习笔记 -- 类加载机制

  虚拟机把Class文件加载到内存,并对Class文件中的数据进行校验、转换解析和初始化,最终形成可以被虚拟机直接使用的Java类型,这就是JVM的类加载机制,其中的Class文件除了存于磁盘中的文件,以其他形式存在也可以,具体指一串二进制的字节流。

1、生命周期

  类从被加载到虚拟机内存到卸载出内存,整个生命周期为:加载、验证、准备、解析、初始化、使用和卸载,其中验证、准备和解析部分同城为连接。其中加载、验证、准备、初始化和卸载必须按顺序开始(只是开始),解析可以在初始化之后再开始用于支持动态绑定。

2、类加载过程

2.1 加载

   虚拟机外部的二进制字节流按照虚拟机需要的格式存储在方法区中,方法区中的存储格式由虚拟机自行定义,然后在内存中实例化一个Class对象(HotSpot虚拟机Class对象不存放在堆而是在方法区中)。在加载阶段,虚拟机需要完成3件事:

1、通过一个类的全限定名获取定义此类的二进制字节流

2、将这字节流所代表的静态存储结构转化为方法区的运行时数据结构

3、在内存中生成一个代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口。

  非数组类加载阶段获取类的二进制字节流的动作,可以使用系统提供的引导类加载器完成,也可以由用户自定义的类加载器完成。对于数组类本身不通过类加载器创建,是由Java虚拟机直接创建的,不过数组类的元素类型还是由类加载器去创建的。数组类创建过程需要遵循以下规则:

1、如果数组的组件类型(数组去掉一个维度的类型)是引用类型,则需要获取组件类的全限定名去获取Class对象,该数组将在加载该组件类型的类加载器的类名称空间上被标识(二维数组?)。

2、如果数组的组件类型不是引用类型,JVM会把该数组标记为与引导类加载器关联。

3、数据类的可见性和它的组件类型的可见性一致(一致?),如果组件类型不是引用类型,数组类的可见性将默认为public。

扫描二维码关注公众号,回复: 6671900 查看本文章

2.2 验证

  验证的目的是为了确保|Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。主要有下面4个阶段:

1、文件格式验证:

  验证字节流是否符合Class文件格式的规范,确保输入的字节流能正确解析并存储在方法区中,比如检验前4个字节的魔数是否正确。通过这个验证后,字节流进入方法区存储,后面的验证基于方法区的结构进行。

2、元数据检验:

  对类的元数据信息进行语义检验,保证其符合Java语言规范。比如检验类是否继承了final修饰的类。

3、字节码验证:

  对类的方法体进行检验,通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的。比如类型转换不可把对象赋值给没继承关系的数据类型。

4、符号引用验证:

  在解析阶段中,当虚拟机将符号引用转化为直接引用时,触发符号引用验证。符号引用验证是对类自身以外(常量池的各种符号引用)的信息进行匹配性校验,比如验证根据字符串的全限定名是否可以找到对应的类,符号引用中的类、方法和字段是否可以被当前类访问。

2.3 准备

  准备阶段是为类变量分配内存并设置初始值的阶段,使用的内存在方法区中分配(实例变量则在对象实例化时随着对象分配在堆中)。如果类变量是一个常量,即用static和final修饰的类变量,在准备阶段会初始化成该常量值,比如下面这个value在准备阶段会被赋值为123。

public static final int value  = 123;

其他的类变量会初始化成零值。下面这个value变量在准备阶段后值为0,在初始化阶段会从类构造器<clinit>()将其赋值为123。

public static int value = 123;

  各种类型的零值:

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

2.4 解析

  解析阶段是虚拟机将常量池中的符号引用替换为直接引用的过程。符号引用有7种,类或接口、字段、类方法、接口方法、方法类型、方法句柄、调用点限定符,分别对应Class文件常量池中的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandler、CONSTANT_InvokeDynamic_info7中常量类型。直接引用是用于定位引用的目标在内存中的位置(对象在内存的位置),可以是直接指向目标的指针、相对偏移量或是一个能间接定位的句柄,和虚拟机实现的内存布局相关。虚拟机规范没有规定解析阶段发生的具体时间,可以根据需要在类被加载器加载时解析,或是等到一个符号引用要被使用前才去解析。

  对同一个符号引用进行多次解析请求时,除invokedynamic指令之外,虚拟机对第一次解析的结果进行缓存(在运行时常量池中记录直接引用,并把常量标识为已解析状态)避免重复解析,无论解析是否成功都只解析一次。对于invokedynamic指令,由于该指令是用于支持动态语言的,对应的引用称为"动态调用点限定符",需要等到程序实际运行到这条指令时才进行解析。

2.4.1 类或接口的解析

  假设当前代码所处类为D,如果要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,需要以下几步:

1、如果C不是数组类型,虚拟机会把N的全限定名传递给D的类加载器去加载这个类,在加载过程中,由于元数据验证、字节码验证,可能会触发其他类的加载动作,比如加载其父类或实现的接口。

2、如果C是一个数组类型,并且数组的元素类型是对象,按照1的规则加载数组元素类型,然后由虚拟机生成一个代表此数组维度和元素的数组对象。

3、上面步骤完成后C在虚拟机中实际上成为一个有效的类或接口,然后进行符号引用验证,验证D是否具备对C的访问权限。

2.4.2 字段解析

  解析一个未被解析过的字段符号引用,需要先解析字段所属的类或接口的符号引用,假设所属类或接口用C表示,解析成功后再进行下面步骤:

1、如果C本身就包含了简单名称和字段描述符都与目标匹配的字段,则返回这个字段的直接引用,查找结束。

2、否则,如果C实现了接口,按继承关系从下往上递归搜索各个接口和其父接口,查看接口中是否包含了简单名称和字段描述符匹配的字段。

3、否则,如果C不是java.lang.Object,按照继承关系从下往上搜索其父类。

4、否则,查找失败。

  如果查找成功返回直接引用,则进行权限验证。如果同名字段同时出现在C的接口或父类中,或是同时在自己和父类的接口中出现,编译器会报错。

2.4.3 类方法解析

  类方法解析需要先解析类方法所属类或接口的符号引用,假设其类或接口用C表示,解析成功后进行类方法搜索:

1、如果C是一个接口,抛出异常。

2、在类C中查找是否有简单名称和描述符都匹配的方法,如果有则返回这个方法的直接引用,查找结束。

3、否则,在类C的父类中饭递归查找。

4、否则,在类C实现的接口列表和他们的父接口之中递归查找,如果查找成功,说明类C是一个抽象类,抛出java.lang.AbstractMethodError异常。

5、否则,查找失败。

  如果查找成功返回直接引用,进行权限验证。

2.4.4 接口方法解析

  先解析方法所属的类或接口的符号引用,如果解析成功,假设其类或接口用C表示,搜索接口方法:

1、如果C是个类而不是接口,抛出异常。

2、否则,在接口C中查找是否有简单名称和描述符都匹配的方法,如果有返回这个方法的直接引用,查找结束。

3、否则,在接口的父接口递归查找。

4、否则,查找失败。

  接口中的所有方法都是public的,不存在访问权限的问题。

2.5 初始化

  前面的类加载过程,除了在加载阶段应用程序可以通过自定义的加载器参与外,其动作完全由虚拟机主导和控制。在初始化阶段才真正开始执行类中定义的Java代码。虚拟机规范严格规定了有且只有5种情况必须对类进行初始化(加载、验证、准备自然在此之前)。

1、遇到new、getstatic、putstatic或invokestatic这4条字节码指令时,如果类没有进行过初始化,则需要触发初始化。生成这4种指令常见场景为:使用new关键字实例化对象、读取或设置类的静态字段(被final修饰即在编译器把结果放入常量池的除外)、调用一个类的静态方法。

2、使用java.lang.refect包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要触发器初始化。

3、当初始化一个类的时候,如果发现其父类还没进行过初始化,则需要先触发父类的初始化。

4、当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类。

5、使用jdk1.7的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例的最后解析结果REF_getStatic、REF_putStatic、REF_invokeStatic的方法句柄,这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化。

  除了以上5种场景,其余被动引用都不会触发初始化。比如调用父类静态字段不会触发子类的初始化:

public class SuperClass {

    public static int value = 12;

    static {
        System.out.println("SuperClass init");
    }

}

public class SubClass extends SuperClass{

    static {
        System.out.println("SubClass init");
    }

}

public class Test1 {

    public static void main(String[] args) {
        System.out.println(SubClass.value);
    }

}

  这段代码只会输出"SuperClass init"而不会输"SubClass init"。

  下面这段代码运行后不会输出"SuperClass init",说明没有触发其初始化,但是这段代码触发了另外一个名为"[Lcom.yue.main.SuperClass"(com.yue.main是SuperClass的包名)的类的初始化。这个一个由虚拟机自动生成、直接继承java.lang.Object的子类,对用户代码来说是不合法的类名称,这个类代表元素类型为com.yue.main.SuperClass的一维数组。

public class Test1 {

    public static void main(String[] args) {
        SuperClass[] arr = new SuperClass[2];
    }

}

  初始化阶段是执行类构造器<clinit>()方法的过程。需要注意一下几个方面:

1、<clinit>()方法是由编译器自动收集类中的所有类变量赋值动作和静态语句块中的语句合并而成,其顺序是由语句在源码中的顺序决定。静态语句块只能访问到定义在静态语句块之前的变量,定义在它之后的变量只能赋值,不能访问。变量尽量写在类的最前面,少搞骚操作。

public class Test1 {

    static {
        i = 0;//正常编译
        System.out.println(i);//编译失败
    }

    static int i = 1;

}

2、<clinit>()方法和类的实例构造器<init>()方法不同,不需要显式调用父类构造器。

3、父类的<clinit>()方法先执行,第一个被执行的<clinit>()方法的类肯定是java.lang.Object,因此父类的静态语句块要优先于子类的变量赋值。

4、<clinit>()方法对于类或接口并不是必需的,如果类中没有静态语句块也没有类变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。

5、接口中不能使用静态语句块,但也会生成<clinit>()方法。执行接口的<clinit>()方法不需要先执行父接口的<clinit>()方法,接口的实现类在初始化时也不会执行接口的<clinit>()方法,只有父接口中定义的变量使用时,父接口才会初始化(接口不是只有常量吗?)。

6、虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确的加锁、同步。因此每次只有一个线程执行这个类的<clinit>()方法,其他线程会被阻塞。

3、类加载器

  类加载阶段中"通过一个类的全限定名来获取描述此类的二进制字节流"动作是在JVM外部实现的,实现这个动作的代码模块称为"类加载器"。

  对于任何一个类,都需要由加载它的类加载器和这个类本身一同确立其在Java虚拟机中的唯一性,每一个类加载器,都拥有一个独立的类名称空间。

3.1 类加载器种类

  从Java虚拟机的角度,类加载器可以分为启动类加载器(Bootstrap ClassLoader)和其他类加载器2种,启动类加载器是虚拟机自身的一部分,而其他类加载器由Java语言实现,全都继承自抽象类java.lang.ClassLoader。从开发人员角度看,系统提供的类加载器可以分为以下3种:

1、启动类加载器:

  这个类加载器负责将放在<JAVA_HOME>\lib目录中的,并且被虚拟机识别的(仅按照文件名识别,如rt.jar)类库加载到虚拟机内存中。启动类加载器无法被Java程序直接引用。

2、扩展类加载器:

  这个类加载器负责加载<JAVA_HOME>\lib\ext目录中的,或者被java.ext.dirs系统变量指定的路径中的所有类库,开发者可以直接使用扩展类加载器。

3、应用程序类加载器:

  这个类加载器是ClassLoader中的getSystemClassLoader()方法的返回值,所以也称为系统类加载器。它负责加载ClassPath上指定的类库,是程序中默认的类加载器。

3.2 双亲委托模型

  双亲委派模型要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。这个的类加载器之间的父子关系不以继承关系实现,而是用组合关系来复用父加载器代码。双亲委派模型如下图所示:

  双亲委派模型的工作过程是:如果一个类加载器收到了类加载的请求,它会把这个请求委派给父类加载器完成,因为所有的加载请求都会传送到顶层的启动类加载器中,如果父加载器的搜索范围中没有找到所需的类,子加载器才尝试自己去加载。

  使用双亲委派模型的其中一个好处是Java类随类加载器一起具备了一种带有优先级的层次关系。比如java.lang.Object类由启动类加载器加载,因此其他所有的类使用的都会是同一个Object类。

3.3 破坏双亲委派模型

  双亲委派模型并不是强制性的约束模型,有几种大规模的非双亲委派模型情况:

1、双亲委派模型很好的解决了各个类加载器的基础类统一问题,但如果基础类又要调用回用户的代码,Java设计团队引入了线程上下文类加载器。这个类加载器可以通过java.lang.Thread类的setContextClassLoaser()方法进行设置,如果创建线程时还未设置,它将会从父线程中继承一个,如果在应用程序的全局范围内都没有设置过,那这个类加载器默认就是应用程序类加载器。

猜你喜欢

转载自www.cnblogs.com/liuwy/p/11093923.html