类加载的过程

image-20210730191532573

类加载的过程主要分为三个阶段 加载,链接,初始化。 而链接阶段又可以细分为验证,准备,解析三个子阶段。

接下来,我们详细分析下类加载的过程。

加载过程

加载过程需要完成以下三个事情:

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

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

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

《Java虚拟机规范 》对这三点的要求并不是特别的具体。因此,留给虚拟机实现于Java的应用的灵活度都是很大的。

在第一步通过一个类的全限定名获取字节流的时候,并没有规范一定是从字节码文件获取,更没有规定是从本地文件中获取。因此,虚拟机的实现者就可以在加载阶段就构建出一个相当开放的舞台。

  • 从ZIP压缩文件中读取,最终成为日后JAR包,WAR包的基础

  • 从网络中获取,这种情况最典型的就是Web Applet。

  • 运行时生成,从而为后来的动态代理技术奠定了理论基础。

  • 从其他文件中生成,典型的应用就是Web中的JSP技术。由JSP文件编译生成字节码文件。

  • 从数据库获取,例如中间件服务器,可以选择把程序安装到数据库中完成程序代码在集群中的分发。

    ……

加载结束之后,外部的二进制字节流就会以JVM所设定的格式存在于方法区中了。
之后会在堆中实例一个java.lang.class类型的对象,
这个对象作为程序访问方法区中的类型数据的入口。

链接过程

加载示例

  1. 验证(Verify)

    1. 目的:

    在于确保Class文件的字节流中包含信息符合当前JVM规范要求,保证被加载类的正确性,不会危害虚拟机自身安全。

    2. 主要包括四种验证
    • 文件格式验证

      • 字节码是否以十六进制的CAFEBABE开头
      • 主,次版本号是否在当前虚拟机可接受的范围之内。
      • 常量池的常量中是否有不被支持的类型
      • Class文件中是否有被添加的其他恶意信息。

      文件格式验证不止以上,上面所列举的只是从HotSpot虚拟机源码中摘抄的一部分。只有通过这个阶段的验证之后,这一段字节流才会进入虚拟机内存中进行存储,
      之后的过程都是基于方法区中的存储结构进行的。不会直接读取字节流了。

    • 源数据验证

      用于保证字节码中的代码符合《Java语言规范》

      • 此类的父类是否是不可继承的类(Final修饰的)
      • 如果此类不是抽象类,它是否实现了全部需要实现的方法。
      • 类中的字段,方法是否和父类冲突。
      • ……
    • 字节码验证

      此过程保证代码是符合逻辑的,对代码的流程进行判断,保证不会出现危害虚拟机安全的情况。

      • 保证任意时刻操作数栈中的类型和指令代码序列可以正常工作,比如执行到iadd字节码指令,但是操作数栈顶有一位是Long类型的。
      • 保证代码中的类型转换是有效的。

      如果一个类型中的方法体没有通过次阶段,那它一定是有问题的。但是,不可以认为只要通过此阶段验证,一定没有问题。通过程序去校验程序的逻辑是无法做到绝对准确的。

    • 符号引用验证

      此阶段验证符号引用是否合法,主要用于解析阶段的前置任务。

      主要用于判断 该类中是否存在缺少后者被禁止访问它依赖的某些外部类,字段,方法等资源。

  2. 准备(Prepare)

    • 为类变量(static)分配内存并且设置初始值。

    • 这里不包含用final修饰的static,因为final在编译的时候就会分配了,准备阶段会显式初始化;

    • 不会为实例变量分配初始化,类变量会分配在方法去中,而实例变量是会随着对象一起分配到java堆中。

  3. 解析(Resolve)

    • 将常量池内的符号引用转换为直接引用的过程。

    • 事实上,解析操作往往会伴随着JVM在执行完初始化之后再执行

    • 符号引用就是一组符号来描述所引用的目标。符号应用的字面量形式明确定义在《java虚拟机规范》的class文件格式中。直接引用就是直接指向目标的指针、相对偏移量或一个间接定位到目标的句柄

    • 解析动作主要针对类或接口、字段、类方法、接口方法、方法类型等。对应常量池中的CONSTANT_Class_info/CONSTANT_Fieldref_infoCONSTANT_Methodref_info等。

初始化过程

  • 初始化阶段就是执行类构造器方法clInit()的过程。 clInit是ClassInit缩写。此方法并不是程序员定义的构造方法。

  • 是javac编译器自动收集类中的所有类变量(Static)的赋值动作和静态代码块中的语句合并而来。

  • 构造器方法中指令按语句在源文件中出现的顺序执行

  • 若该类具有父类,jvm会保证子类的clinit()执行前,父类的clinit()已经执行完毕

    比如如下代码

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    /**
    * @作者: 写Bug的小杜 【email@shaoxiongdu.cn】
    * @时间: 2021/07/30
    * @描述:
    */
    class A{
    public static int a = 10;
    static {
    a = 20;
    }
    }
    class B extends A{
    public static int b = a;
    }
    public class CInitTestMain {

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

    通过执行,发现B类中b的值为20 由于是父类的CInit方法先执行,也就是说父类的静态代码块中的内容优于子类的赋值操作先执行。

  • 虚拟机必须保证一个类的clinit()方法在多线程下被同步加锁。

    验证

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    /**
    * @作者: 写Bug的小杜 【email@shaoxiongdu.cn】
    * @时间: 2021/07/30
    * @描述: 测试一个类的CInit方法是否被加锁
    */
    class TestClass {
    static{
    // 如果不加这个判断 编译器会报死循环的错误
    if(true){
    System.out.println(Thread.currentThread().getName() + "线程正在执行CInit方法");
    while (true){
    }
    }
    }
    }
    public class DeadLoopClass{
    public static void main(String[] args) {
    Runnable runnable = new Runnable() {
    @Override
    public void run() {
    System.out.println(Thread.currentThread().getName() + "启动");
    TestClass testClass = new TestClass(); //触发加载TestClass类
    System.out.println(Thread.currentThread().getName() + "结束");
    }
    };
    new Thread(runnable).start();
    new Thread(runnable).start();
    }
    }

    执行结果如下: 当一条线程死循环在CInit处,别的线程也会阻塞。

    image-20210730195642762