加载、验证、准备、解析、初始化--Java类加载过程总结分析

关于Java类加载,主要弄清楚三个问题 :

  1. 为什么需要类加载
  2. 什么时候进行类加载
  3. 怎么进行类加载

一、为什么需要类加载

  我们编写好的程序经过编译之后,会形成Class文件,Class文件描述了类的各种信息,而Java虚拟机想要运行程序,就必须把Class文件加载进入虚拟机内部,才能供其所用。

  在JVM中,类的各种信息一般都存储在方法区中,所以需要将类信息加载进入方法区,才能在需要类信息时,比如实例化对象时找到对应的类信息。

二、什么时候进行类加载

  一个类加载进内存一般需要经历 加载(Loading)→验证(Verification)→准备(Preparation)→解析(Resolution)→初始化(Initialization) 五个阶段,才能供程序调用。

  什么时候开始加载阶段,《Java虚拟机规范》没有严格规定,但是存在有六种情况必须对立即对类立即进行初始化,而在初始化之前,必须进行加载、验证、和准备工作。为什么解析不是必须的,因为解析在某些情况下可以在初始化之后在开始,这和Java的动态绑定有关。

  以下六种情况必须立即对类进行初始化:

  1.遇到new、getstatic、putstatic、invokestatic这四条字节码指令时,典型场景为:new实例化对象、读取或设置类的静态变量(final修饰的)、调用一个类的静态方法等。

  2.使用java.lang.reflect包方法对类进行反射调用时

  3.当初始化类的时候,发现其父类没有初始化,则先触发父类的初始化

  4.当虚拟机启动时,主类(包含main()方法类)必须先初始化

  5.当使用动态语言支持,java.lang.invoke.MethodHandle实例最后解析结果为REF_getStatic、REF_putStatic、REF_invokeStatic、REF_newInvokeSpecial四种方法句柄,而这个方法句柄对应的类没有进行初始化,则需要对其进行初始化

  6.当一个接口定义了JDK8新加入的默认方法(default关键字修饰),如果该接口实现类发生初始化,那么该接口需要在其之前进行初始化

  以上六种为对类型的主动引用,除此之外,所有引用类型都不会触发初始化,称为被动引用

  被动引用举例:

    1.通过子类引用父类的静态字段,不会导致子类初始化,但是会加载子类

    2.通过数组定义来引用类,不会触发此类的初始化

    3.常量在编译阶段会存入调用类的常量池,本质上没有直接引用到定义常量的类,因此不会触发定义常量的类的初始化

  接口的加载与类加载稍有不同,最主要区别是类初始化要求父类全部初始化,但是接口并不要求所有父接口都初始化,只有用到父接口的时候,才会进行初始化

三、怎么进行类加载

 3.1 加载  

  类加载的第一阶段就是“加载”,此时JVM会做以下三件事:

  1.通过类的全限定名获取此类的二进制字节流

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

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

  加载阶段既可以由JVM内置的引导类加载器完成,也可以由用户自定义的类加载器完成(重写加载器的findClass()或loadClass()方法)

 3.2 验证

  类加载的第二阶段,确保上一步加载的Class文件字节流全部符合《Java虚拟机规范的》约束

  大致会完成四个方面的验证:

  1.文件格式验证:验证字节流是否符合Class文件规范,能否被当前版本虚拟机处理,如是否以魔数0xCAFEBABE开头,主次版本号是否受当前JVM支持等

   只有通过了文件格式验证,字节流才会存入方法区,后面三个验证都是基于方法区的存储结构的,不会再操作字节流

  2.元数据验证: 对字节码描述的信息进行语义分析 如:这个类是否有父类(除了java.lang.Object之外,所有的类都应该父类,没有显式继承父类的类,都默认继承java.lang.Object类),这个类是否继承了不允许被继承的类,非抽象类是否实现了父类中或接口中的所有抽象方法,类中的字段、方法是否与父类产生矛盾等

  3.字节码验证:这是整个验证阶段过程中最复杂的一个阶段,主要是通过数据流分析和控制流分析,确定程序语义是合法的、符合逻辑的。在第二阶段的基础上,此阶段对类的方法体进行校验分析,保证被校验类不会在运行时作出危害虚拟机的行为,如:保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,保证任何跳转指令都不会跳转到方法体以外的字节码指令上等

  4.符号引用验证:这个验证发生在虚拟机将符号引用化为直接引用的时候,这个转化发生在解析阶段。符号引用验证可以看作是对类自身以外(常量池中的各种符号引用)的各类信息进行匹配校验。如:符号引用种通过字符串描述的全限定名是否能找到对应的类,符号引用中的类、字段、方法、的可访问性是否可被当前类访问。

  符号引用验证主要是为了解析行为能正常进行,如果不能通过符号引用验证,虚拟机将抛出Java.lang.IncompatibleClassChangeError的子类异常。

  如果程序运行的全部代码都已经反复的使用和验证过,后期部署阶段可以考虑关闭类验证阶段,缩短类加载时间。

 3.3 准备

  准备阶段为类中定义的变量,即静态变量分配内存并设置变量的初始值,通常情况为该数据类型的“零值”。如果是final类静态变量,也可能在准备阶段直接赋值。注意此阶段内存分配仅包括类变量,而实例变量会在对象实例化时随着对象一起分配在堆中。

 3.4 解析

  解析阶段是虚拟机将常量池内的符号引用替换为直接引用的过程,此过程伴随着第二阶段验证中符号引用验证。

  解析过程主要针对类或接口、类方法、接口方法、方法类型、方法句柄、调用限定符引用,对应着常量池中8种常量类型。

    类或接口解析

      如果当前处于类D代码中,要把一个未解析符号引用N解析为一个类或接口的直接引用C,大致需要三步:

      1.如果C不是数组类型,那么将由D的类加载器根据N的全限定名去加载类C。在加载过程中可能触发其他相关类的加载,如父类或实现的接口,一旦失败, 解析过程就失败了

      2.如果C是一个数组类型,如Integer数组类型,则N的描述符会是“[Ljava/lang/Integer”的形式,则会加载java.lang.Interger,接着由JVM生成一个代表该数组维度和元素的数组对象。

      3.如果上两步通过,则C即成为一个实际有效的类或接口了,解析完成前,还需要验证D对C的访问权限,如果D不具备,抛出java.lang.IllegalAccessError异常。

    字段解析

      首先对字段所属的类C或接口的符号引用进行解析,一旦失败,则解析失败,如果解析成功,将会按照以下步骤解析类C的后续字段:

        类C自身范围查找→失败则从下往上递归在实现的接口中查找→失败则从下往上递归在继承的父类中查找,仍然失败则抛出java.lang.NoSuchFieldError异常。如果查找成功返回了引用,则进行权限验证,验证失败则抛出java.lang.IllegalAccessError异常。

      在实际情况中,虽然查找的范围是依次进行,但是如果一个字段在父类或实现的接口中多次出现,按照规则依然可以保证唯一性,但是多数编译器会拒绝编译代码。

    方法解析:

      方法解析同样需要先解析方法所属的类C和接口的符号引用,如果解析成功,则按照以下步骤对后续方法进行搜索:

        类C中查找→失败则在C的父类中递归查找→失败则在C实现的接口列表和他们的父接口中递归查找(此步若成功,说明C是一个抽象类,抛出java.lang.AbstractMethodError异常,查找结束)→ 否则,查找失败,抛出java.lang.NoSuchMethodError异常。

      如果查找成功返回了引用,则进行权限验证,验证失败则抛出java.lang.IllegalAccessError异常。    

    接口方法解析: 

      接口方法解析一样需要解析接口所属接口的符号引用,如果解析成功,则按照以下步骤对后续接口方法进行搜索:

         接口C中查找→失败则在C的父接口中查找→失败则查找失败,抛出java.lang.AbstractMethodError异常,查找结束

      在JDK9以前,所有接口方法都是public的,也没有模块化访问约束,所以不存在访问权限问题,不可能抛出java.lang.IllegalAccessError异常。

  3.5 初始化

    初始化是类加载的最后一步,在之前的几步里,只有“加载”阶段用户可以自定义类加载器参与其中,后面都是由JVM主导控制的,直到初始化开始,JVM才开始真正执行Java程序代码,将主导权移交给应用程序。

    大体来说,初始化就是执行类构造器的<clinit>()方法,此方法并不是coder直接编写的,而是由编译器自动收集类中所有类变量赋值动作和静态语句块(static块)合并产生,收集的顺序是其在源文件中出现的顺序。

    静态语句块只能访问到定义在它之前的变量,对于定义在其后的边量,只能进行赋值,但不能访问。

public class Test {
    static {
        i = 0;    //给变量赋值可以正常编译   
        System.out.println(i);    //这句话会提示非法向前引用
    }
    static int i = 1;
}

 

    JVM会保证在子类<clinit>()方法执行前,父类的<clinit>()方法先执行完毕,所以第一个被执行的<clinit>()方法是java.lang.Object类的<clinit>()方法,因此,父类中的静态代码块会优于子类的静态代码块执行。

    如过一个类没有静态代码块,也没有变量赋值操作,则编译器可以不生成这个类的<clinit>()方法

    接口中不能使用静态代码块,但可以由变量赋值,但执行接口的<clinit>()方法前不需要先执行父接口的<clinit>()方法,只有在父接口被使用时,父接口才会初始化。

    接口的实现类在初始化时也一样不会执行接口的<clinit>()方法

    JVM会对一个类的<clinit>()方法进行加锁同步,保证如果由多个线程初始化一个类,那么只有一个线程会执行<clinit>()方法,其他线程都需要阻塞等待,直到活动线程执行<clinit>()方法完毕。

posted @ 2020-05-06 16:55  dwwzone  阅读(2241)  评论(0编辑  收藏  举报