Java —— 类加载机制

830 阅读10分钟

概述

代码编译是将本地机器码变为字节码,这一过程是存储格式发展的一小步,却是编程语言发展的一大步。

虚拟机加载完Class文件,最终形成可以被虚拟机直接使用的Java类型。这一个过程也就是类的加载机制。



这里写图片描述

Java编译器可以将Java代码(我们平时码的源代码)编译为存储字节码的Class文件(二进制字节码)。(虚拟机并不关心Class的来源是何种语言)

本篇文章讲述的是虚拟机将Class文件加载(通过字节流方式)到内存,并对数据进行校验、解析和初始化转为虚拟机可以直接使用的Java类型的过程。

类的生命周期

类从被加载到虚拟机内存中开始,到卸载出内存为止,它的生命周期包括

加载、验证、准备、解析、初始化、使用、卸载这7个阶段。

其中验证、准备、解析3个部分统称连接。

Java虚拟机中类加载的全过程,也就是(加载「类加载的一个阶段」、验证、准备、解析、初始化)这5个阶段所执行的具体动作。

这里写图片描述


加载

“加载”是“类加载”过程的一个阶段。在这个阶段中,虚拟机需要完成以下3件事情 :

在了解加载之前可以先看看以下两篇文章。
java方法区
对象的访问定位

  • 1. 通过一个类的全限定名来获取此类的二进制字节流
    jvm并没有指明需要从Class文件中获取,也可以通过ZIP包、网络、动态代理方式或者是其它文件数据库等资源类型中获取二进制字节流。

  • 2. 将字节流所代表的静态存储结构转化为方法区的运行时数据结构
    虚拟机外部的二进制字节流就按照虚拟机的格式,存储在方法区之中,方法区中的数据存储格式由虚拟机实现自行定义。
    方法区中 静态常量池转为运行时常量池

  • 3. 在内存中生成代表这个类的java.lang.Class对象,作为方法区这个类的各种数据的访问入口
    然后在内存中实例化一个java.lang.Class类对象(虽然是对象,但是存放在方法区中),这个对象将作为程序访问方法区中的这些数据类型的外部接口。
    作为 堆中对象实例访问数据的接口(引用)


验证

加载阶段与连接阶段的部分内容是交叉进行的(如文件格式验证),加载阶段尚未完成,连接阶段可能已经开始。但是两个阶段的开始时间仍然保持着先后顺序。

验证是连接阶段的第一步,这一阶段的目的是为了确保Class文件中的字节流包含的信息符合当前虚拟机的要求,并且不会危害虚拟机的自身安全。(因为加载的Class文件不一定是用Java源码编译而来的。)

从整体来看,验证阶段大致会完成以下4个检验动作 :

  • 1. 文件格式验证(二进制字节流验证)
    针对字节流是否符合Class文件格式规范的验证,包括 :
    ①是否以魔数开头。
    ②主次版本号是否在当前虚拟机处理范围。
    ③检查常量池中的常量类型,索引值和编码数据格式等等……
    该阶段是保证输入的二进制字节流能够正确的解析并存储到方法区中, 只有通过了文件格式的验证,字节流才会进入方法区。
    并且后面的3个验证阶段全部是基于方法区的存储结构进行的。

  • 2. 元数据验证(方法区存储结构)
    元数据是描述数据的数据,直白来说就是描述代码间的关系。检验包括 :
    ①这个类是否有父类。(除了java.lang.Object之外)
    ②这个类的父类是否继承了不允许的类。(final修饰的类)
    ③如果这个类不是抽象类,是否实现了父类或接口中要求实现的所有方法。
    ④类中的字段,方法是否与父类产生矛盾等等…

  • 3. 字节码验证
    这个阶段是整个验证阶段中最复杂的,字节码验证将对类的方法体进行校验分析。
    ①保证操作数栈的数据类型与指令代码序列正确配合。(例如操作数栈处理int类型后,不会按照long类型加入局部变量表)
    ②保证跳转指令不会跳转到方法体外的字节码指令上。
    ③保证方法体中的类型转换是有效的。(向上转型)等等……

  • 4. 符号引用验证
    这个阶段发生在虚拟机将符号引用转化为直接引用时候(解析阶段),确保解析能够正常的执行。
    符号引用验证是 常量池中各种符号引用信息的匹配性检验,通常校验一下内容 :
    ①符号引用中通过字符串描述的全限定名是否能找到对应的类。
    ②在指定类中是否存在符合方法和字段描述所对应的方法和字段。
    ③符号引用中的类、字段、方法的访问性(private、protected、public、default)等等……

对于虚拟机的类加载机制来说,验证机制是一个非常重要的,但不一定必要(因为对程序运行期没有影响)的阶段。如果所运行的全部代码都已经被反复使用和验证过。那么也可以关闭此阶段。


准备

  • 准备阶段是正式为类变量(static修饰的成员变量)分配内存并设置初始值的阶段。
    这些变量使用的内存都将在方法区中进行分配。

提前了解类构造器和和实例构造器 : 对象的访问定位

public static int value = 123;


//类变量value在准备阶段过后的初始值是 0 。存储在方法区中。
//存放在类构造器<clinit>方法之中,初始化阶段才会赋值。

我们在Java — 常量池探索一文中说过,final修饰的成员变量是常量。那么如果上面的变量value加了final修饰后 :

public final static int value = 123;

//value常量,存储在常量池中。

那么这时候的value就称为常量,并存储在方法区的类常量池中,并且在准备阶段value就会被赋值,即value = 123。


解析

解析阶段是虚拟机将 常量池内的符号引用替换为 直接引用的过程。

  • 符号引用 : 任何形式的字面量(Class文件中)来描述所引用的目标。

  • 直接引用 : 直接引用已经存在于内存中,并且可以是直接执行目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。

解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用限定符7类符号引用运行。


初始化

类的初始化阶段是类加载过程的最后一步,到了初始化阶段,才真正开始执行类中定义的Java程序代码(字节码)。

在准备阶段,变量已经分配内存并设置初始值(置零),而在初始化阶段,则会去赋予变量代码中对应的值。

也可以说是执行类构造器<Clinit>()方法的过程,如果一个类中没有静态语句块,也没有对类变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法。

Java虚拟机规范中并没有进行强制性约束类加载阶段,这点可以交给虚拟机的具体实现来自由把握。

但是对于初始化阶段,虚拟机规范则是严格规定了有且只有5种情况必须立即对类进行初始化。(而加载、验证、准备自然需要再次开始之前开始) : (以下5中情况如果类没有初始化,则需要先触发其初始化。)

  • 1. 遇到new 、get static(读取类变量)、 put static(设置类变量)、 invoke static(调用类变量)这4条字节码指令。

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

  • 3. 初始化一个类,发现其父类没有初始化,则需要先触发父类的初始化

  • 4. 当虚拟机启动时,用户需要指定一个要执行的主类。(包含main()方法的类)

  • 5. 使用动态语言支持的类(包含main()方法的类)

以上这5种情况如果类没有初始化过,则会主动触发类的初始化阶段。

除此之外,所有引用类的方式都不会触发初始化,称为被动引用。下面举3个例子分析被动引用 : (我们还用对象的访问定位中的例子)

public class Parent {

    int m = 1;
    static int s2 = 2;

    Parent() {
        System.out.println("Parent 构造方法" + "-->m=" + m);
    }

    {
        System.out.println("Parent 普通方法块" + "-->m=" + m);
    }

    static {
        System.out.println("Parent 静态方法块" + "-->s2=" + s2);
    }
}
public class Child extends Parent {

    static int s = 2;// clinit

    static Bean beans = new Bean("hello_static");// clinit

    static {
        System.out.println("Child 静态方法块" + "-->s=" + s);// clinit
    }

    int m = 1;// init

    Bean bean = new Bean("hello_normal");// init

    Child() {
        System.out.println("Child 构造方法" + "-->m=" + m);// init
    }

    {
        System.out.println("Child 普通方法块" + "-->m=" + m);// init
    }

}
public class Test3 {

    public static void main(String[] args) {

        System.out.println("parent s2=" + Child.s2);//s2为父类类变量
    }

}



//Parent 静态方法块-->s2=2
//parent s2=2

上面代码中只会输出父类Parent静态方法块的语句,并不会输出子类Child静态方法块语句。

对于静态字段,只有直接定义这个字段的类才会初始化。

但是根据初始化阶段的第三点:初始化一个类,发现其父类没有初始化,则需要先触发父类的初始化。

public class Test3 {

    public static void main(String[] args) {

        System.out.println("parent s=" + Child.s);//s为 Child的类变量
    }

}

//Parent 静态方法块-->s2=2
//Child 静态方法块-->s=2
//parent s=2



我们上面说过,在准备阶段会初始化类变量,也就是赋零值。但是我们也讲到一个特例,那就是常量。(final 修饰的成员变量)。

如果在我们的Child类中,将类变量s 增加final修饰符,那么理论上来说 :
常量在编译阶段会存入调用类的常量池中,本质上没有直接引用到定义常量的类。因此不会触发定义常量类的初始化。

//Child

    final static int s = 2;// clinit
public class Test3 {

    public static void main(String[] args) {

        System.out.println("parent s=" + Child.s);
    }

}

//parent s=2



类在初始化时,要求其父类全部都已经初始化过了。

但是一个接口在初始化时,并不要求其父接口全部都完成了初始化,只有在真正使用到父类接口的时候(比如引用接口中定义的常量)才会初始化。

卸载

除非使用自定义类加载器,或者应用关闭,否则类是不会卸载的。也就是说通常情况下类加载过程(上述5个阶段)就只会走一遍。(所以也验证了类的静态代码块只会执行一次的原因。)

类的卸载要同时满足以下3种情况,可以稍微了解一下 :

  • 该类的所有实例都已经被回收,java堆中不存在该类的任何实例。
  • 加载该类的ClassLoader被回收(只能是自定义类加载器,因为启动类加载器永不会被回收)。
  • 该类对应的java.lang.Class对象已经没有任何地方被引用,并且无法通过反射访问该类方法。