likes
comments
collection
share

详解类加载机制

作者站长头像
站长
· 阅读数 73

类的加载过程总共分为五步:

详解类加载机制 以下面代码为例来分析:

public class ClassLoadDemo {
    static {
        System.out.println("Execute static code blocks!");
    }

    public static int VALUE = 11;
    
    public static final int FINALVALUE = 11;
    
    private String str = "Hello World";

    private int value;

    public int getValue() {
        return value;
    }

    public void setValue(int value) {
        this.value = value;
    }

    public static void main(String[] args) {
        new ClassLoadDemo();
    }
}

加载

加载的理解

所谓加载,简而言之就是将Java类的字节码文件加载到机器内存中,并在内存中构建出Java类的原型——类模板对象(类元信息)。 所谓类模板对象,其实就是Java类在JVM内存中的一个快照,JVM将从字节码文件中解析出的常量池、类字段、类方法等信息存储到类模板中,这样JVM在运行期便能通过每个类的class对象访问到类模板而获取Java类中的任意信息,能够对Java类的成员变量进行遍历,也能进行Java方法的调用。

反射的机制即基于这一基础。如果JVM没有将Java类的声明信息存储起来,则JVM在运行期也无法反射。 加载(Loading)是类加载(Class Loading)过程的其中一个阶段。

在加载阶段,JVM 需要完成 3 个步骤

  • 通过类的全限定名来获取这个类的二进制字节流

  • 将字节流转化为方法区的运行时数据结构(类元信息)。

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

类的Class实例,这个实例指向了方法区中的类模板对象

数组类的加载

创建数组类的情况稍微有些特殊,因为数组类本身并不是由类加载器负责创建,而是由JVM在运行时根据需要而直接创建的,但数组的元素类型仍然需要依靠类加载器去创建。创建数组类(下述简称A)的过程:

  1. 如果数组的元素类型是引用类型,那么就遵循定义的加载过程递归加载和创建数组A的元素类型;
  2. JVM使用指定的元素类型和数组维度来创建新的数组类。

如果数组的元素类型是引用类型,数组类的可访问性就由元素类型的可访问性决定。否则数组类的可访问性将被缺省定义为public

二进制流的获取方式

对于类的二进制数据流,虚拟机可以通过多种途径产生或获得。(只要所读取的字节码符合JVM规范即可)

  • 虚拟机可能通过文件系统读入一个class后缀的文件 (最常见)
  • 读入jar、zip等归档数据包,提取类文件
  • 事先存放在数据库中的类的二进制数据
  • 使用类似于HTTP之类的协议通过网络进行加载
  • 在运行时生成一段Class的二进制信息等

在获取到类的二进制信息后,Java虚拟机就会处理这些数据,并最终转为一个java.lang.Class的实例。

如果输入数据不是ClassFile的结构,则会抛出ClassFormatError。(比如如果不是cafebabe开头,就会抛出ClassFormatError)

类模型与Class实例的位置

类模型的位置

加载的类在JVM中创建相应的类结构,类结构会存储在方法区(JDK1.8之前:永久代;JDK1.8及之后:元空间)

Class实例的位置

类将.class文件加载至元空间后,会在堆中创建一个Java.lang.Class对象,用来封装类位于方法区内的数据结构,该Class对象是在加载类的过程中创建的,每个类都对应有一个Class类型的对象。(instanceKlass → mirror : Class的实例)

我们在运行时可以通过访问代表ClassLoadDemo类的Class对象来获取ClassLoadDemo的类数据结构 Class类的构造方法是私有的,只有JVM能够创建。

java.lang.Class实例是访问类型元数据的接口,也是实现反射的关键数据、入口。通过Class类提供的接口,可以获得目标类所关联的.class文件中具体的数据结构:方法、字段等信息

Verification(验证)

当类加载到系统后,就开始链接操作,验证是链接操作的第一步。

它的目的是保证加载的字节码是合法、合理并符合规范的。

验证的步骤比较复杂,实际要验证的项目也很繁多,大体上Java虚拟机需要做以下检查: 验证的内容则涵盖了类数据信息的格式验证、语义检查、字节码验证,以及符号引用验证等。

  • 其中格式验证会和加载阶段一起执行。验证通过之后,类加载器才会成功将类的二进制数据信息加载到方法区中。
  • 格式验证之外的验证操作将会在方法区中进行。

链接阶段的验证虽然拖慢了加载速度,但是它避免了在字节码运行时还需要进行各种检查。

具体说明

格式验证:是否以魔数0xCAFEBABE开头,主版本和副版本号是否在当前Java虚拟机的支持范围内,数据中每一个项是否都拥有正确的长度等。

Java虚拟机会进行字节码的语义检查,但凡在语义上不符合规范的,虚拟机也不会给予验证通过。比如:

  • 是否所有的类都有父类的存在(在Java里,除了Object外,其他类都应该有父类)
  • 是否一些被定义为final的方法或者类被重写或继承了
  • 非抽象类是否实现了所有抽象方法或者接口方法
  • 是否存在不兼容的方法(比如方法的签名除了返回值不同,其他都一样,这种方法会让虚拟机无从下手调度;abstract情况下的方法,就不能是final的了)

Java虚拟机还会进行字节码验证,字节码验证也是验证过程中最为复杂的一个过程。它试图通过对字节码流的分析,判断字节码是否可以被正确地执行。比如:

  • 在字节码的执行过程中,是否会跳转到一条不存在的指令
  • 函数的调用是否传递了正确类型的参数
  • 变量的赋值是不是给了正确的数据类型等

栈映射帧(StackMapTable)就是在这个阶段,用于检测在特定的字节码处,其局部变量表和操作数栈是否有着正确的数据类型。但遗憾的是,100%准确地判断一段字节码是否可以被安全执行是无法实现的,因此,该过程只是尽可能地检查出可以预知的明显的问题。如果在这个阶段无法通过检查,虚拟机也不会正确装载这个类。但是,如果通过了这个阶段的检查,也不能说明这个类是完全没有问题的

在前面3次检查中,已经排除了文件格式错误、语义错误以及字节码的不正确性。但是依然不能确保类是没有问题的。

校验器还将进行符号引用的验证。 Class文件在其常量池会通过字符串记录自己将要使用的其他类或者方法。因此,在验证阶段,虚拟机就会检查这些类或者方法确实是存在的,并且当前类有权限访问这些数据,如果一个需要使用类无法在系统中找到,则会抛出NoClassDefFoundError,如果一个方法无法被找到,则会抛出NoSuchMethodError。

此阶段在解析环节才会执行。

准备

准备是连接阶段的第二步,目的是为静态变量(被 static 修饰的变量)分配内存,初始化默认值。

Java 中所有基本数据类型的默认值,如下表所示:

详解类加载机制

注:JVM 中 boolean 类型映射为 int 类型,false 用 0 表示,true 用 1 表示,由于 int 默认值为 0,因此 boolean 的默认值为 false。

以 ClassLoadDemo 为例,VALUE 将会被初始化为 0。

// 即使代码中 VALUE 赋值为 11,不过在准备阶段,VALUE 的值为 0
public static int VALUE = 11;
// 而该行代码则会在准备阶段赋值11
public static final int FINALVALUE = 11;
  • final在编译的时候就会分配空间,准备阶段会显式赋值。
  • 注意这里不会为实例变量分配初始化,类变量会分配在方法区中,而实例变量是会随着对象一起分配到Java堆中。
  • 在这个阶段并不会像初始化阶段中那样会有初始化或者代码被执行。

Resolution(解析)

在准备阶段完成后,就进入了解析阶段。

解析阶段(Resolution),简言之,将类、接口、字段和方法的符号引用转为直接引用。 该阶段会把一些静态方法(符号引用,比如main()方法)替换为指向数据所存内存的指针或句柄等(直接引用),这是所谓的静态链接过程(类加载期间完成),动态链接是在程序运行期间完成的将符号引用替换为直接引用

名词解释

符号引用:

  • 使用符号来描述引用的目标,符号可以是任何形式的字面量,只需要能够准确的定位到目标就行;

  • 与虚拟机的内存布局无关,由于一份Class文件能够加载到不同的虚拟机中,但是虚拟机的实现不同其内存布局也不同,符号引用存在于Class文件中,而直接引用是一个内存地址。因此对于符号引用来说,只需保证能够确保加载的目标即可。

比如:一个字段,在常量池中是用的CONSTANT_Fieldref_info表示的,至于要在虚拟机中怎么分配内存,这是虚拟机的事情,但是对于不同虚拟机来说,这个CONSTANT_Fieldref_info属性都是一样的。

符号引用就是一个字段/类/方法的属性表,是存在于Class文件中的,对于不同虚拟机来说符号引用是一样的,确定不变的。

直接引用:

  • 能够直接定位到目标的指针,或者间接定位的句柄,这个是和虚拟机内存布局相关的,不同的虚拟机内存空间不同,自然而然指针,偏移量也就不一样。
  • 直接引用就是将Class文件中的符号引用(也就是字段/类/方法的属性表)转换为真实的内存地址(访问读取修改就是基于真实的内存地址来操作的,为了之后的操作)。由于是内存地址,不同虚拟机的内存布局实现可能不同,对于不同的虚拟机来说直接引用是不一样的,不确定的。

静态链接:

编译时即可确认要转换成哪个直接引用。

编译的时候由于能够确定变量的静态类型,所以编译时可知,也就是为什么叫做静态链接的原因。

动态链接: 编译时不能确认转换成哪个引用要等到运行时才可以确认调用的是哪个方法。

初始化

前面讲到过在类的准备阶段时会对静态final的常量进行初始化并赋值,而对只有static修饰没有被final修饰的变量则是赋默认值。

那么初始化阶段也就是对静态类型上面说的赋默认值的静态变量进行赋值操作,同时该阶段也会执行静态语句块中的内容。将这两个步骤合到一块就是静态变量赋值操作和静态语句块执行操作,编译器整合这两个操作生成了一个方法叫做cinit。而执行和赋值的操作是根据用户写的java文件的顺序决定的。

在初始化阶段也需要确保父类完成类加载,因此cinit方法执行前父类的cinit方法肯定会执行完毕,和类的构造函数init方法还不太一样,cinit不需要显示调用父类的构造器。

注意:

1.cinit方法不一定会生成,如果没有静态代码块或者静态变量,那么编译器是不会生成这个方法的

2.JAVA虚拟机会保证父类的cinit方法先执行,不需要像init方法一样显示的调用父类构造器来保证父类init方法执行完成。

接口

接口中没有静态代码块,字段默认是static和final修饰的。

注意:

1.接口的cinit方法执行前不一定需要父接口的cinit方法也执行完。当使用到了父接口中的变量父接口才会初始化。 2.接口实现类初始化前不会执行接口的cinit方法。 3.cinit方法是加锁同步的,多线程初始化同一个类时会发生阻塞只有当cinit方法执行完才可以释放锁。

总结:

初始化也就是执行编译器自动添加的cinit方法,按java文件中的顺序为静态变量赋值和静态语句块执行的操作。

使用

这里类加载完成之后就可以进行使用了,上面说到的都是静态变量,代码块的初始化赋值执行操作,那么类的成员变量,类的构造方法呢?这些叫做init方法执行构造方法,完成类成员变量的初始化(也就是实例变量),当然这些都是在创建完对象后进行的操作,而且init执行前需要显示的去执行父类的init方法。

卸载

类型卸载的条件比较严苛:

1.该类的所有对象都已被清除

2.该类的java.lang.Class对象没有被任何对象或变量引用。即没有在任何地方通过反射访问该类。

只要一个类被虚拟机加载进方法区,那么在堆中就会有一个代表该类的对象:java.lang.Class。这个对象在类被加载进方法区的时候创建,在方法区中该类被删除时清除。

3.加载该类的ClassLoader已经被回收

转载自:https://juejin.cn/post/7220365570457010233
评论
请登录