再战JVM (1) 类加载过程

一. 类加载过程

类加载过程可以分为三个阶段

  1. 加载(Loading)
  2. 连接(Linking)
    a. 验证(Verify)
    b. 准备
    c. 解析
  3. 初始化(Initialization)

下面这图是类的生命周期图,这里只看初始化以及之前的过程
再战JVM (1) 类加载过程

二. 加载

1. 加载过程

  1. 通过一个类的全限定名获取定义此类的二进制字节流
  2. 通过类加载器 将这个字节流所代表的静态存储结构转化为方法区(1.8后为元空间)的运行时数据结构
  3. 在内存中生成代表一个类的java.lang.class对象,作为方法区里面这个类的数据访问入口

2. 加载class文件的方式

  • 从本地系统中直接加载
  • 通过网络获取,典型场景:Web Applet
  • 从zip压缩包中读取,成为日后jar、war格式的基础
  • 运行时计算生成,使用最多的是:动态代理技术
  • 由其他文件生成,典型场景:JSP应用从专有数据库中提取.class文件
  • 从加密文件中获取,典型场景:防Class文件被反编译的保护措施

三. 连接

1. 验证

验证是为了确保Class文件的字节流中包含的信息的要求。只有通过这个阶段的验证,虚拟机才会让字节流进入到方法区中进行存储,后面的验证都是直接操作在方法区之上,而不是直接操作字节流

  • 元数据验证 (对字节码描述的信息进行语义分析,确保符合java语言规范)

    • 这个类是否有父类,java中除了Object,其它的类必须存在父类,默认为Object
    • 这个类是否extends了不被允许的类,如被final修饰的类
    • 如果这个类不是抽象类,是否实现了其父类或接口之中需要实现的所有方法
    • 类中的字段、方法是否与父类产生矛盾,如覆盖了父类的final字段和方法,覆盖不符合规则等等
  • 字节码验证 (通过数据流和控制流分析,确定语义是合法的,符合逻辑的)

    • 保证任意时刻操作数栈的数据类型与指令码序列都能够配合工作,不会出现类似于在操作栈放置了 一个int类型的数据,使用时却按long类型来载入本地变量表中
    • 保证跳转指令不会跳到方法体以外的字节码指令上
    • 保证类型转换是有效的,例如把父类型赋值给子类型是安全的,子类型赋值给父类型就是不安全危险的
  • 符号引用验证 (对除了自身类,以外的各类信息进行匹配性校验,确保解析行为可以正常执行)

    • 符号引用中通过字符串的描述的全限定名是否能找到对应的类
    • 在指定类中是否符合方法的字段描述及简单名称所描述的方法和字段
    • 在符号引用中的类,字段,方法的可访问性是否可被当前类访问
  • 2. 准备

    准备阶段是正式为类实例变量分配内存并且设置类变量初始值的阶段,这些变量所使用的内存都将在方法区中进行分配。这里的类变量指的是被static修饰的变量,不包括实例变量,实例变量将会在对象实例化的时候随着对象一起分配在java堆中。另外这里的初始值并不是代码中 = 右边的初始值而是变量数据类型对应的零值(int为0,String为null等)

    如下面的例子:这里在准备阶段过后的初始值为0,而不是7

    public static int a=7 

    3. 解析

    解析阶段是将常量池中的符号引用替换为直接引用的过程,解析动作主要针对类或接口、字段、类方法、接口方法、方法类型、方法句柄和调用点限定符7类符号引用进行,分别对应于常量池的CONSTANT_Class_info、CONSTANT_Fieldref_info、CONSTANT_Methodref_info、CONSTANT_InterfaceMethodref_info、CONSTANT_MethodType_info、CONSTANT_MethodHandle_info和CONSTANT_InvokeDynamic_info 7种常量类型

    符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要可以在使用时能无任何歧义的定位到目标时即可
    直接引用是指向目标的指针、相对偏移量或者是一个可以间接可以定位到目标的句柄

    1. 类或接口的解析

    假设当前代码所处的类为D,如果要把一个从未解析过的符号引用N解析为名叫C的类或接口的直接引用,那虚拟机完成整个解析的过程需要以下3个步骤:

    • 如果C不是一个数组类型,那虚拟机将会把代表N的全限定名传递给D的类加载器去加载这个类C。在加载过程中,由于元数据验证、字节码验证的需要,又可能触发其他相关类的加载动作,例如加载这个类的父类或实现的接口。一旦这个加载过程出现了任何异常,解析过程就宣告失败。
    • 如果C是一个数组类型,并且数组的元素类型为对象,也就是N的描述符会是类似“[Ljava/lang/Integer”的形式,那将会按照第1点的规则加载数组元素类型。如果N的描述符如前面所假设的形式,需要加载的元素类型就是“java.lang.Integer”,接着由虚拟机生成一个代表此数组维度和元素的数组对象。
    • 如果上面的步骤没有出现任何异常,那么C在虚拟机中实际上已经成为一个有效的类或接口了,但在解析完成之前还要进行符号引用验证,确认D是否具备对C的访问权限。如果发现不具备访问权限,将抛出java.lang.IllegalAccessError异常。

    加载成功后:

    • 类C 加载成功后,会创建一个InstanceKlass对象,InstanceKlass存放在元空间
    • 类C 实例化出来的对象则对应InstanceOopDesc,InstanceOopDesc存放在堆

    类的解析是将一个类的符号引用改为指向InstanceKlass对象的直接指针(每个InstanceKlass对象表示一个具体的Java类,这个Java类不包括Java数组)。指向这个对象的开头, 当创建对象的时候,这个指针会赋值给对象头中_kclass指针。 这样就定位到了该类的数据。访问类的元数据信息,是通过描述该类的类的对象实现的,当然 每个类只对应一个InstanceKlass对象。这就是类本身如何被描述的内存形态。

    因为对象内部的数据在内存中的连续堆放的,当你访问一个类的某字段,是需要通过元数据InstanceKlass对象 记录的这个字段与对象头的偏移量来获取。 当然调用对象的方法是定位到虚方法表 而不是定位到对象的内存区域
    创建对象其实就是仅仅向一块内存区域写入与类元数据对应的各种字段的值,当然对象类型的值是一个引用,访问一个对象的字段的值, 是通过定位这个字段在这个对象起始地址的相对偏移量。确定相对偏移量就是在字段解析阶段完成的。

    2. 字段的解析

    字段的解析是确定一个对象的字段的访问地址,是计算相对对象起始地址的偏移量

    要解析一个未被解析过的字段符号引用,首先会解析所属的类或接口的符号引用。将这个字段所属的类或接口用C表示,如果这个类解析完成,虚拟机规范要求按照如下步骤对C进行后续字段的搜索

    • 如果C本身就包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
    • 否则,如果在C中实现了接口,将会按照继承关系从下往上递归搜索各个接口和他的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束。
    • 否则,如果C不是java.lang.Object的话,将会按照继承关系从下往上递归搜索其父类,如果在父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段直接引用,查找失败。
    • 否则,查找失败,抛出java.lang.NoSuchFieldError异常。

    出错情况:

    • 如果查找过程成功返回了引用,将会对这个字段进行权限验证,如果发现不具备对字段的访问权限,将抛出java.lang.IllegalAccessError异常
    • 如果 C 的父类和所实现的接口中都有这个字段
      • 如果C 也有这个字段,虚拟机会确定C的这个字段就是访问字段
      • 如果C没有这个字段,虚拟机无法确定访问的字段到底是接口的,还是父类的,javac编译器将提示 The field xx is ambiguous
    3. 方法解析
    1. 类方法解析

    类方法解析的第一个步骤与字段解析一样,也需要解析所属的类或接口的符号引用,我们依然用C表示这个类,如果解析成功:

    • 类方法和接口方法符号引用的常量类型定义是分开的,如果在类方法表中发现class_index中索引的C是个接口,那就直接抛出java.lang.IncompatibleClassChangeError异常。
    • 如果通过了第1步,在类C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束
    • 否则,在类C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束
    • 否则,在类C实现的接口列表及他们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,说明类C是一个抽象,这时查找结束,抛出java.lang.AbstractMethodError异常
    • 否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError

    最后,如果查找过程成功返回了直接引用,将会对这个方法进行权限验证,如果发现不具备对此方法的访问权限,将抛出java.lang.IllegalAccessError异常

    2. 接口方法解析

    接口方法解析的第一个步骤与字段解析一样,也需要解析所属的类或接口的符号引用,如果解析成功,依然用C表示这个接口,接下来虚拟机将会按照如下步骤进行后续的接口方法搜索:

    • 与类方法解析不同,如果在接口方法表中发现class_index中的索引C是个类而不是接口,那就直接抛出java.lang.IncompatibleClassChangeError异常

    • 否则,在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束

    • 否则,在接口C的父接口中递归查找,直到java.lang.Object(查找范围会包括Object类)为止,看是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的直接引用,查找结束

    • 否则,宣告方法查找失败,抛出java.lang.NoSuchMethodError异常

    由于接口中的所有方法默认都是public,所以不存在访问权限的问题,因此接口方法的符号解析应当不会抛出java.lang.IllegalAccessError异常

    每一个类加载后,会对应一个虚方法表

    当第一次调用方法时,也就是执行invokevirtual指令,指令参数为 该方法的符号引用(包含了参数个数和类型信息,返回值类型,这样就区分了方法重载是不同的方法),也就是对应找到常量表中的methodref类型的项。(class文件中不同类型的项都有标记来标识,从而能够描述并得到这个的项的内部结构 而取到对应的值)。

    在虚方法表,根据方法描述符找到对应指向匹配方法的下标,该下标指向methodblock*指针,也就是对应的方法内存地址入口,然后把虚方法表的下标和参数个数 写回到该类型为methodref的常量池项 比如是第二项#2。来取代之前的符号引用。也就是说符号引用变成了虚方法表的下标。这个下标就是一种直接引用的体现

    类的直接引用–> ClassClass–> methodtable - 下标 -> methodblock结构体(ClassClass)
    第二次调用方法,这时候invokevirtual指令会变成invokevirtual_quick, 该指令的参数为虚方法表的下标(vtable index)和 方法的参数个数。 所以调用方法并不是直接调用方法块,而是先找到虚方法表,再去根据下标调用对应的方法块

    四. 初始化

    初始化阶段就是执行类构造器法<clinit>()的过程
    <clinit>()方法是由编译器自动收集类中的所有类变量(静态变量)的赋值动作和静态语句块(static{})中的语句合并产生的。编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块只能访问到定义在静态语句块之前的变量,定义在它之后的变量,静态语句块能进行赋值操作,但是不能进行访问

    • <clinit>()方法与类的构造函数不同,它不需要显式的调用父类构造器,虚拟机会保证在<clinit>()方法执行之前,父类的<clinit>()方法已经执行完毕。因此在虚拟机中第一个被执行的<clinit>()方法肯定是java.lang.Object的<clinit>()方法
    • <clinit>()方法对于类或接口来说并不是必需的,如果一个类中没有静态语句块,也没有静态变量的赋值操作,那么编译器可以不为这个类生成<clinit>()方法
    • 虚拟机会保证一个类的<clinit>()方法在多线程环境中被正确的加锁、同步,如果多个线程去初始化一个类,那么只会有一个线程去执行这个类的<clinit>()方法,其他线程都需要阻塞等待,直到活动线程的<clinit>()方法执行完毕。同一个虚拟机上类的<clinit>()方法只会执行一次

    什么时候类会被初始化:

    • 实例化一个类,new一个类的实例对象
    • 访问类的静态变量
    • 调用类的静态方法
    • 通过反射调用类
    • 实例化类的子类
    • 被标位启动类的类(即main方法执行的类)
    版权声明:玥玥 发表于 2021-04-07 6:18:00。
    转载请注明:再战JVM (1) 类加载过程 | 女黑客导航