深入理解JVM-虚拟机类加载机制

你未必出类拔萃,但一定与众不同

虚拟机类加载机制

文章目录

  • 虚拟机类加载机制
    • 概述
    • 类加载的时机
      • 六种情况初始化
      • 被动引用例子1
      • 被动引用例子2
      • 被动引用例子3
    • 类加载过程
      • 加载
      • 验证
        • 文件格式校验
        • 元数组验证
        • 字节码验证
        • 符号引用验证
      • 准备
      • 解析
        • 类或接口的解析
        • 字段解析
        • 方法解析
        • 接口方法解析
      • 初始化

概述

Java虚拟机把描述类的数据从Class文件加载到内存,并对数据进行校验,转换解析和初始化,最终形成可以被虚拟机使用的Java类型。

类型的加载,连接和初始化都是在程序运行期间完成

类加载的时机

一个类型(每个Class文件都代表Java语言中的一个类或者接口)从被加载到虚拟机内存开始,到卸载出内存为止,它的整个生命周期将会经历加载,验证,准备,解析,初始化,使用和卸载七个阶段,其中验证,准备,解析三个部分统称为连接

七个阶段如下图
深入理解JVM-虚拟机类加载机制_第1张图片

[外链图片转存失败,源站可能有防盗链机制,建议将图片保存下来直接上传(img-cyWfPfqW-1623851784053)(C:\Users\宇文拓

  • 加载 验证 准备 初始化 卸载这五个顺序是确定的,类型的加载过程必须按照这个顺序开始,而解析则不一定,它在某些情况下可以在初始化阶段之后再开始,这是为了支持Java语言的运行时绑定特性
  • 关于什么情况下开始加载,《Java虚拟机》这里没有进行强制约束,而是交给虚拟机的具体实现自由把握

六种情况初始化

《Java虚拟机》中严格规定了有且只有六种情况必须立即对类进行初始化

  • 遇到new,getstatic,putstatic,invokestatic这四条字节码指令时,如果类型没有进行过初始化,则需要先触发其初始化阶段,能够生成这四条指令的典型Java代码场景

    • 使用new关键字实例化对象的时候
    • 读取或者设置一个类型的静态字段(被final修饰)的时候
    • 调用一个类的静态方法的时候
  • 使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则需要触发初始化

  • 当初始化类的时候,如果发现其父类还没有初始化,则需要先触发其父类的初始化

  • 当虚拟机启动的时候,用户需要指定一个执行的主类 虚拟机会初始化这个主类

  • 当使用JDK7引入的动态语言支持时,如果一个java.lang.invoke.MethodHandle实例最后的解析结果为REF_getStatic,REF_putStatic,

    REF_invokeStatic,REF_newInvokeSpecial四种类型的方法句柄,并且这个方法句柄对应的类没有进行过初始化,则需要触发其初始化

  • 当一个接口定义了默认方法(被default修饰的方法)如果有这个接口的实现类 发生了初始化,那该接口要在其之前被初始化。

对于以上六种的情况《Java虚拟机》使用了一个限定语,有且只有。这六种场景的行为成为对一个类型进行主动应用,除此之外,所有引用类型都不会触发初始化,称为被动引用。

被动引用例子1

public class Demo6161547 {

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

class SuperClass {
    static {
        System.out.println("SuperClass init");
    }
    public static int value = 123;
}
class SubClass extends SuperClass{
    static {
        System.out.println("SubClass init");
    }
}

输出结果

SuperClass init
123

上述代码只会输出SuperClass init 不会输出SubClass init 对于静态字段,只有直接定义这个字段的类才会被初始化

因此通过其子类的来引用父类中定义的静态字段,只会触发父类的初始化而不会触发子类的初始化,但是会导致子类被加载

被动引用例子2

public class Demo6161547 {

    public static void main(String[] args) {
        SuperClass[] sca = new SuperClass[10];
    }
}

复用上方代码,这里并没有输出SuperClass init 说明没有触发com.bluedot.test616.SuperClass的初始化阶段

但是却触发了另一个为[Lcom.bluedot.test616.SuperClass]的类的初始化,这不是一个合法的类型名称,是一个虚拟机自动生成的,直接继承与java.lang.Object的子类,创建动作由newarray触发,代表了一个元素类型为com.bluedot.test616.SuperClass的一维数组,数组中应该有的属性和方法都实现在这个类里。

被动引用例子3

public class Demo6161547 {

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

class ConstClass{
    static {
        System.out.println("ConstClass init");
    }
    public final static String HELLO = "HELLO";
}

以上代码运行也没有ConstClass init出现,这是因为虽然在Java源码里确实引用了常量HELLO,但是其实在编译期间已经通过常量的传播将常量HELLO存在了Demo6161547的常量池之中 也就是说Demo6161547对常量ConstClass.HELLO的引用 都被转换为Demo6161547对自身常量池的引用了

类加载过程

加载----验证----准备-----解析----初始化

加载

加载阶段是类加载过程中的一个阶段,在加载阶段虚拟机完成三件事情

  • 通过一个类的全限定名来获取定义此类的二进制字节流
  • 将这个字节流所代表的静态存储结构转化为方法区的运行时数据结构
  • 在内存中生成一个代表这个类的java.lang.Class对象,作为这个方法区这个类的各种数据的入口

对比与类加载过程的其他过程,非数组类型的加载阶段,是开发人员可控性最强的阶段,加载阶段即可通过引导类加载器来完成,也可以通过用户自定义的类加载器去完成,根据自己的想法来赋予应用程序获取运行代码的动态性

对于数组类而言,情况有些不同,数组类不通过类加载器创建,而是虚拟机直接在内存中动态构造而成

一个数组类创建过程需要遵循以下规则:

  • 如果数组的组件类型(Class.getComponentType())是引用类型,那就递归采用加载过程去加载这个组件类型,数组类将被标识在加载该组件类型的类加载器的类名称空间上
  • 如果数组的组件类型不是引用类型,虚拟机会将数组类标记为与引导类加载器相关
  • 数组类的可访问性与他的组件类型的可访问性意义,如果组件类型不是引用类型,它的数组类的可访问性将默认为public,可被所有的类和接口访问

验证

验证阶段主要完成四个阶段的校验当做

  • 文件格式校验
  • 元数组验证
  • 字节码验证
  • 符号引用验证
文件格式校验

这一阶段主要是验证字节流是否符合Class文件格式的规范,并且能被虚拟机处理

  • 是否以魔数(上文讲过这个)开头
  • 主次版本号是否在虚拟机接受范围内
  • 常量池的常量是否有不被支持的类型(检查常量tag的标志)

这阶段是基于二进制字节流进行

元数组验证

这阶段是对字节码描述的信息进行语义分析

  • 这个类是否有父类
  • 这个类是否继承了不被允许继承的类 例如final修饰的类
  • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
字节码验证

作为验证阶段最复杂的一个阶段,主要目的是通过数据流分析和控制流分析,确定程序语义是合法的,符合逻辑的,第二阶段对元数据信息中的数据类型校验完毕,这阶段就对类的方法体进行校验分析

  • 保证操作数栈的数据类型与指令代码序列配合工作
  • 保证任何指令都不会跳转到方法体之外
  • 保证方法体的类型转换是有效的

如果一个类型中有方法体的字节码没有通过字节码验证,那么肯定有问题,但是如果一个方法体通过字节码验证,就不能保证是一定安全。和布隆过滤器性质有一丝相同,布隆说不存在一定不存在,说存在的不一定存在

符号引用验证

​ 最后一个阶段的校验行为发生在虚拟机将符号引用转为直接引用,这个转换发生在连接的第三个阶段,解析阶段

符号引用验证 主要是对类自身以外的各类信息进行匹配性校验,也就是该类是否缺少或者被禁止访问它依赖的某些类,方法,字段等资源

通常负责校验以下内容

  • 符号引用中通过字符串描绘的全限定名是否能找到相应的类
  • 在指定类中是否存在符合方法的字段描述符以及简单名称锁描述的方法和字段
  • 符号引用中的类,字段,方法的可访问性

符号引用主要是保证解析行为能否正常执行,无法通过符号访问验证,就会抛出一个

IncompatibleClassChangeError的子类异常

准备

准备阶段主要是为类中定义的变量(static修饰的变量)分配内存并且设置变量初始值的阶段

基本数据类型的零值

数据类型 零值
byte 0
short 0
int 0
long 0L
char ‘\u000’
boolean false
float 0.0f
double 0.0d
reference null

在准备阶段对静态变量进行的赋值都是赋零值,真正赋值需要等初始化阶段

而final修饰的变量在这个阶段则是直接赋初值

解析

将符号引用替换为直接引用的过程

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

类或接口的解析

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

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

也就是假使一个类

public class Demo6161547 {

    public static void main(String[] args) {
       SuperClass superClass = new SubClass();
    }
}

class SuperClass {
    static {
        System.out.println("SuperClass init");
    }
    public static int value = 123;
}

当我在主函数中new 了一个superClass出来,main的类加载器就会去加载SuperClass这个类,如果这个类有父类或者实现了接口,那么就会去加载他的父类或者接口,没有出现任何问题的话,这个类在虚拟机就存在了,但是在解析完成前还要进行符号引用验证,确认 D 是否具备对 C 的访问权限。如果多次 new SubClass的话,解析不会重复进行,因为在运行时常量池中将这个符号的直接引用记录下来,并把常量标识为已解析状态。

字段解析

要解析一个未被解析过的字段符号引用,首先将会对字段表内 class_index 项中索引的 CONSTANT_Class_info 符号引用进行解析,也就是字段所属的类或接口的符号引用。如果在解析这个类或接口符号引用的过程中出现了任何异常,都会导致字段符号引用解析的失败。如果解析成功完成,那将这个字段所属的类或接口用 C 表示,虚拟机规范要求按照如下步骤对 C 进行后续字段的搜索。

  1. 如果 C 本身包含了简单名称和字段描述符都与目标匹配的字段,则返回这个字段的直接引用,查找结束;
  2. 否则,如果在 C 中实现了接口,将会按照继承关系从下往上递归搜索各个接口和他的父接口,如果接口中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束;
  3. 否则,如果 C 不是 java.lang.Object 的话,将会按照继承关系从下往上递归搜索其父类,如果父类中包含了简单名称和字段描述符都与目标相匹配的字段,则返回这个字段的直接引用,查找结束;
  4. 否则,查找失败,抛出 java.lang.NoSuchFieldError 异常。
public class FieldResolution {
	
	interface Interface0{
		int A = 0;
	}
	
	interface Interface1 extends Interface0{
		int A = 1;
	}
	
	interface Interface2{
		int A = 2;
	}
	
	static class Parent implements Interface1{
		public static int A = 3;
	}
	
	static class Sub extends Parent implements Interface2{
		public static int A = 4;
	}
	
	public static void main(String[] arg){
		System.out.println(Sub.A);
	}
}
方法解析

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

  • 类方法和接口方法符号引用的常量类型定义是分开的,如果在类方法表中发现 class_index 中索引的 C 是个接口,那就直接抛出 java.lang.IncompatibleClassChangeError 异常

  • 如果通过了第 1 步,在类 C 中查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的引用,查找结束

  • 否则,在类 C 实现的接口列表及他们的父接口之中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果有则返回这个方法的引用,查找结束

  • 否则,在类 C 实现的接口列表及他们的父接口中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,证明 C 是一个抽象类,这时查找结束,抛出 java.lang.AbstractMethodError 异常

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

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

接口方法解析

接口方法也需要先解析出接口方法表的 class_index 项中索引的方法所属的类或接口的符号引用,如果解析成功,依然用 C 表示这个接口,接下来虚拟机将会按照如下步骤进行后续的接口方法搜索。

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

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

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

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

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

初始化

类的初始化是类加载过程的最后一个步骤,直到初始化过程,虚拟机才会开始真正执行类中编写的Java程序代码

1.编译器收集的顺序是由语句在源文件中出现的顺序决定的,静态语句块中只能访问到定义在静态语句块之前的变量,而定义在它之后的变量,在前面的静态语句块可以赋值,但不能访问

public class Test {
    static {
        i = 0;                       //给变量赋值可以正常编译通过
        System.out.print(i);         //编译器会提示“非法向前引用”
        }
    static int i = 1;
}

2.初始化方法执行的顺序,虚拟机会保证在子类的初始化方法执行之前,父类的初始化方法已经执行完毕,因此在虚拟机中第一个被执行的类初始化方法一定是java.lang.Object。另外,也意味着父类中定义的静态语句块要优先于子类的变量赋值操作

public class Demo6161547 {

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

class SuperClass {
    static {
        System.out.println("SuperClass init");
    }
    public static int value = 123;
}
class SubClass extends SuperClass{
    static {
        System.out.println("SubClass init");
    }
}

3.clinit ()方法对于类或接口来说并不是必须的,如果一个类中没有静态语句块,也没有对变量的赋值操作,那么编译器可以不为这个类生成clinit()方法。

4.接口中不能使用静态语句块,但仍然有变量初始化的操作,因此接口与类一样都会生成clinit()方法,但与类不同的是,执行接口的初始化方法之前,不需要先执行父接口的初始化方法。只有当父接口中定义的变量使用时,才会执行父接口的初始化方法。另外,接口的实现类在初始化时也一样不会执行接口的clinit()方法。

5.虚拟机必须保证一个类的clinit()方法在多线程环境中被正确的加锁、同步,如果多个线程同时去初始化一个类,那么只会有一个线程去执行这个类的clinit()方法,其他线程都需要阻塞等待,直到活动线程执行类初始化方法完毕。

你可能感兴趣的:(笔记,jvm,java)