JAVA类的加载机制

  • 2020 年 8 月 23 日
  • 筆記

一、类的加载机制

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

  类的加载指的是将类的.class文件中的二进制数据读入到内存中,将其放在运行时数据区的方法区内,然后在堆区创建一个java.lang.Class对象,用来封装类在方法区内的数据结构。类的加载的最终产品是位于堆区中的Class对象,Class对象封装了类在方法区内的数据结构,并且向Java程序员提供了访问方法区内的数据结构的接口。 

  类加载器并不需要等到某个类被“首次主动使用”时再加载它,JVM规范允许类加载器在预料某个类将要被使用时就预先加载它,如果在预先加载的过程中遇到了.class文件缺失或存在错误,类加载器必须在程序首次主动使用该类时才报告错误(LinkageError错误)如果这个类一直没有被程序主动使用,那么类加载器就不会报告错误

 加载.class文件的方式
– 从本地系统中直接加载
– 通过网络下载.class文件,这种场景最典型的应用就是Applet
– 从zip,jar等归档文件中加载.class文件
– 从专有数据库中提取.class文件
– 将Java源文件动态编译为.class文件 

 二、类的加载时机

  类从被加载到虚拟机内存中开始,直到卸载出内存为止,它的整个生命周期包括了:加载、验证、准备、解析、初始化、使用和卸载这7个阶段。其中,验证、准备和解析这三个部分统称为连接(linking)
  其中,加载、验证、准备、初始化和卸载这五个阶段的顺序是确定的,类的加载过程必须按照这种顺序按部就班的“开始”(仅仅指的是开始,而非执行或者结束,因为这些阶段通常都是互相交叉的混合进行,通常会在一个阶段执行的过程中调用或者激活另一个阶段),而解析阶段则不一定(它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定(也称为动态绑定或晚期绑定)。

三、类的加载过程

  接下来详细讲解一下Java虚拟机中类加载的全过程,也就是加载、验证、准备、解析和初始化这五个阶段所执行的具体动作。

  3.1 加载

   “加载”(Loading)阶段是“类加载”(Class Loading)过程的第一个阶段,在此阶段,虚拟机需要完成以下三件事情:

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

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

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

  上面的第一步获取二进制字节流,并没有限定只能从编译好的.class文件中获取,也可以是zip包,jar,war,网络流(Applet),运行时计算生成(如动态代理,通过反射在运行时动态生成代理类),其他文件(如jsp,因jsp最终会编译成class),数据库(用的场景较少)。

  对于数组类的加载,和普通类的加载有所不同。数组类本身不通过类加载器加载,而是由虚拟机直接完成。但是数组类的元素类型(指数组类去除维度之后的类型,如String[] 数组的元素类型就是 String)是靠类加载器加载的。

  加载阶段完成之后,虚拟机就会把外部的二进制字节流(不论从何处获取的)按照一定的数据格式存储在运行时数据区中的方法区。然后在内存中实例化一个java.lang.Class对象(Class这个对象比较特殊,它存放在方法区中而不是堆中),这个对象将作为程序访问方法区中的这些数据的外部接口。

      加载阶段即可以使用系统提供的类加载器在完成,也可以由用户自定义的类加载器来完成。加载阶段与连接阶段的部分内容(如一部分字节码文件格式验证动作)是交叉进行的,加载阶段尚未完成,连接阶段可能已经开始,但这些夹在加载阶段之中进行的动作,仍然属于连接阶段的内容,这两个阶段的开始时间仍然保持着固定的先后顺序。 

  3.2 验证 

       验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全。

       Java语言本身是相对安全的语言,使用Java编码是无法做到如访问数组边界以外的数据、将一个对象转型为它并未实现的类型等,如果这样做了,编译器将拒绝编译。但是,Class文件并不一定是由Java源码编译而来,可以使用任何途径,包括用十六进制编辑器(如UltraEdit)直接编写。如果直接编写了有害的“代码”(字节流),而虚拟机在加载该Class时不进行检查的话,就有可能危害到虚拟机或程序的安全。

      不同的虚拟机,对类验证的实现可能有所不同,但大致都会完成下面四个阶段的验证:文件格式验证、元数据验证、字节码验证和符号引用验证。

       1、文件格式验证,是要验证字节流是否符合Class文件格式的规范,并且能被当前版本的虚拟机处理。如验证魔数是否0xCAFEBABE;主、次版本号是否正在当前虚拟机处理范围之内;常量池的常量中是否有不被支持的常量类型……该验证阶段的主要目的是保证输入的字节流能正确地解析并存储于方法区中,经过这个阶段的验证后,字节流才会进入内存的方法区中存储,所以后面的三个验证阶段都是基于方法区的存储结构进行的。

       2、元数据验证,是对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求。可能包括的验证如:这个类是否有父类;这个类的父类是否继承了不允许被继承的类;如果这个类不是抽象类,是否实现了其父类或接口中要求实现的所有方法……

       3、字节码验证,主要工作是进行数据流和控制流分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的行为。如果一个类方法体的字节码没有通过字节码验证,那肯定是有问题的;但如果一个方法体通过了字节码验证,也不能说明其一定就是安全的。

       4、符号引用验证,发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在“解析阶段”中发生。验证符号引用中通过字符串描述的权限定名是否能找到对应的类;在指定类中是否存在符合方法字段的描述符及简单名称所描述的方法和字段;符号引用中的类、字段和方法的访问性(private、protected、public、default)是否可被当前类访问

验证阶段对于虚拟机的类加载机制来说,不一定是必要的阶段。如果所运行的全部代码确认是安全的,可以使用-Xverify:none参数来关闭大部分的类验证措施,以缩短虚拟机类加载时间。

  3.3 准备       

  准备阶段是类变量分配内存并设置初始值的阶段。这里的类变量指的是被static修饰的变量,而不包括实例变量。类变量被分配到方法区中,而实例变量存放在堆中。

  这里的初始值指的是数据类型的默认值,而不是代码中所赋的值。例如

  publicstaticintvalue = 1 ;

  在准备阶段之后,value值为0,而不是1。赋值为1的动作发生在初始化阶段。

  但是,也要特殊情况,如果变量被static 和 final同时修饰,则准备阶段直接赋值为指定值。如

  public finallystaticintvalue = 1 ;

  在准备阶段之后,value的值即为1.

  各数据类型的初始默认值如下:

  3.4 解析       

  解析阶段是将常量池中的符号引用转换为直接引用的过程。那什么是符号引用和直接引用呢?

  符号引用是用一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义的定位到目标即可(前面JVM的模型中,也提到了符号引用,它存在于常量池中,包括类和接口的全限定名、字段的名称和描述符、方法的名称和描述符)。看概念可能比较抽象,可以理解为它就是一个代号,就像你有一个大名,同时也有一个小名,但是不管怎么叫指代的都是你本人。

  直接引用可以是直接指向目标的指针、相对偏移量或是一个能间接定位到目标的句柄。

  解析动作主要针对类或接口、字段、类方法、接口方法、方法属性、方法句柄、调用点限定符7类符号引用。此处分别介绍一下前四种的解析过程。

  1、类或接口的解析

  如果类C不是数组类型,那么虚拟机会把类C直接传给类加载器。如果类C是数组类型并且元素类型是对象(如String[]),那么先用类加载器加载元素类型(String类型),再由虚拟机创建代表此数组维度和元素的数组对象。判断调用类是否有权限访问被加载类,如果不允许的话,就抛出IllegalAccessError异常。

  2、字段的解析

  首先解析字段所属的类或接口的符号引用。如果类中有字段的符号引用(字段的名称和描述符)和目标字段相匹配,则返回这个字段的直接引用。如果没有,则自下而上查找其实现的接口和父接口,若匹配到,则返回这个字段的直接引用。如果还没有,就自下而上查找其继承的父类,若匹配到,则返回这个字段的直接引用。否则,查找失败,抛出NoSuchFieldError异常。最后如果查找成功的话,会判断字段访问权限,如果该字段不允许访问,则抛出 IllegalAccessError异常。

  3、类方法解析

  类方法解析第一步同字段解析一样,也需要先解析方法所属的类或接口的符号引用。类方法和接口方法符号引用的常量类型是分开的。如果,在类方法中解析出来的是一个接口,则会抛出 IncompatibleClassChangeError 异常。如果在类中有方法的符号引用(方法的名称和描述符)和目标方法相匹配,则返回这个方法的直接引用,查找结束。否则,在类的父类中递归查找,若找到则返回,查找结束。否则,查找它实现的接口和父接口,如果找到,说明此类是一个抽象类,抛出 AbstractMethodError异常。若都找不到,就抛出NoSuchMethodError 异常。最后,如果查找成功,会判断此方法是否有访问权限,若没有,则抛出 IllegalAccessError异常。

  4、接口方法的解析

  首先解析方法所属的类或接口的符号引用,和类方法解析同理,如果发现解析出来是一个类方法,则会抛出 IncompatibleClassChangeError 异常。如果所属接口中匹配到目标方法,则返回此方法的直接引用。否则,在父接口中查找,若找到,则返回。否则,查找失败,抛出 NoSuchMethodError 异常。由于接口的方法都是public的,所以不存在访问权限的问题。

  3.5 初始化       

  这是类加载的最后一步,到这才真正开始执行Java代码。在准备阶段,已经为类变量分配内存,并赋值了默认值。在初始阶段,则可以根据需要来赋值了。可以说,初始化阶段是执行类构造器 < clinit > 方法的过程。

  首先说下类构造器 < clinit > 方法和实例构造器 < init > 方法有什么区别。< clinit > 方法是在类加载的初始化阶段执行,是对静态变量、静态代码块进行的初始化。而< init > 方法是new一个对象,即调用类的 constructor方法时才会执行,是对非静态变量进行的初始化。

  类构造器方法有如下特点:

  保证父类的 < clinit > 方法执行完毕,再执行子类的 < clinit > 方法。由于父类的 < clinit > 方法先执行,所以父类的静态代码块也优于子类执行。如果类中没有静态代码块,也没有为变量赋值,则可以不生成 < clinit > 方法。执行接口的 < clinit > 方法时,不需要先执行父接口的 < clinit > 方法。只有父接口中定义的变量使用时,父接口才会初始化。另外,接口的实现类在初始化时也不执行接口的 < clinit > 方法。虚拟机会保证在多线程环境下 < clinit > 方法能被正确的加锁、同步。如果有多个线程同时请求加载一个类,那么只会有一个线程去执行这个类的 < clinit > 方法,其他线程都会阻塞,直到方法执行完毕。同时,其他线程也不会再去执行 < clinit > 方法了。这就保证了同一个类加载器下,一个类只会初始化一次。(这也是为什么说饿汉式单例模式是线程安全的,因为类只会加载一次。)类的初始化时机:只有对类主动使用的时候才会触发初始化,主动使用的场景如下:

  使用new关键词创建对象时,访问某个类的静态变量或给静态变量赋值时,调用类的静态方法时。反射调用时,会触发类的初始化(如Class.forName())初始化一个类的时候,如其父类未初始化,则会先触发父类的初始化。虚拟机启动时,会先初始化主类(即包含main方法的类)。另外,也有些场景并不会触发类的初始化:

  通过子类调用父类的静态变量,只会触发父类的初始化,而不会触发子类的初始化(因为,对于静态变量,只有直接定义这个变量的类才会初始化)。通过数组来创建对象不会触发此类的初始化。(如定义一个自定义的Person[] 数组,不会触发Person类的初始化)通过调用静态常量(即static final修饰的变量),并不会触发此类的初始化。因为,在编译阶段,就已经把final修饰的变量放到常量池中了,本质上并没有直接引用到定义常量的类,因此不会触发类的初始化。

四、题目分析

  上面很详细的介绍了类的加载时机和类的加载过程,通过上面的理论来分析本文开门见上的题目
class SingleTon {
    private static SingleTon singleTon = new SingleTon();
    public static int count1;
    public static int count2 = 0;
 
    private SingleTon() {
        count1++;
        count2++;
    }
 
    public static SingleTon getInstance() {
        return singleTon;
    }
}
 
public class Test {
    public static void main(String[] args) {
        SingleTon singleTon = SingleTon.getInstance();
        System.out.println("count1=" + singleTon.count1);
        System.out.println("count2=" + singleTon.count2);
    }
}

 分析:

  1、SingleTon singleTon = SingleTon.getInstance();调用了类的SingleTon调用了类的静态方法,触发类的初始化
  2、类加载的时候在准备过程中为类的静态变量分配内存并初始化默认值 singleton=null count1=0,count2=0
  3、类初始化化,为类的静态变量赋值和执行静态代码快。singleton赋值为new SingleTon()调用类的构造方法
  4、调用类的构造方法后count=1;count2=1
  5、继续为count1与count2赋值,此时count1没有赋值操作,所有count1为1,但是count2执行赋值操作就变为0
 

参考:

//www.cnblogs.com/ityouknow/p/5603287.html

//www.cnblogs.com/javaee6/p/3714716.html

//baijiahao.baidu.com/s?id=1658299223471945338&wfr=spider&for=pc

《深入理解Java虚拟机:JVM高级特性与最佳实践》 周志明 著