Java 类加载机制

什么是类

类是现实世界的实体在计算中的映射、它将数据以及对这些数据的操作封装在一起。

类加载的定义

类加载是 JVM 运行时的一个动作、支持将 class 动态加载到 JVM 中

类加载是一种懒加载模式、按需加载。

类加载到五个过程

  1. 加载
  2. 验证
  3. 准备
  4. 解释
  5. 初始化

提一点,不要认为类加载是严格地前一阶段结束后一阶段开始,加载阶段尚未完成时,连接阶段(验证、准备、解析)可能已经开始了,但它们的开始时间是保持着固定的先后顺序的

加载

输入是 class 二进制流、加载到输出就是方法区的 InstanceKlass 对象以及堆中的 InstanceMirroKlass 对象

验证

确保Class文件的字节流中包含的信息符合当前虚拟机的要求,并且不会危害虚拟机自身的安全

文件格式验证

字节流是否符合 Class 文件格式的规范

比如

  • 是否以魔数0xCAFEABE开头
  • 常量池中的常量中是否有不被支持的常量类型(检查常量tag标志)
  • Class文件中各个部分及文件本身是否有被删除的或附加的其他信息

这阶段的验证是基于二进制字节流进行的,只有通过了这个阶段的验证后,字节流才会进入内存的方法区中进行存储,所以后面的3个验证阶段全部是基于方法区的存储结构进行的,不会再直接操作字节流

元数据验证

第二阶段是对字节码描述的信息进行语义分析,以保证其描述的信息符合Java语言规范的要求

  • 这个类是否有父类(除了java.lang.Object以外,所有的类应当都有父类)
  • 这个类的父类是否继承了不允许被继承的类(被final修饰的类)
  • 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
  • 类中的字段、方法是否与父类产生矛盾(例如覆盖了父类的final字段,或者出现不符合规则的方法重载等)

字节码验证

第三阶段是整个验证过程中最复杂的一个阶段,主要目的是通过数据流和控制流分析,确定程序语义是合法的、符合逻辑的

这一阶段是将对类的方法体进行校验分析,保证被校验类的方法在运行时不会做出危害虚拟机安全的事件

  • 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作,比如不会出现这样的情况:在操作数栈放置了一个int类型的数据,使用时却按long类型来加载入本地变量表中
  • 保证跳转指令不会跳转到方法体以外的字节码指令上
  • 保证方法体中的类型转换是有效的,比如父类对象赋值给子类数据类型,甚至把对象赋值给与它毫无继承关系、完全不相干的一个数据类型,则是危险和不合法的

符号引用验证

最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作将在连接的第三阶段------------解析阶段中发生

符号引用验证可以看做是对类自身以外(常量池中的各种符号引用)的信息进行匹配性校验

  • 符号引用中通过字符串描述的全限定名是否能找到对应的类
  • 在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段
  • 符号引用中的类、字段、方法的访问性(private、protected、public、default)是否可被当前类访问

对于虚拟机的类加载机制来说,验证阶段时一个非常重要的阶段,但不是一定必要的。如果所运行代码都已经被反复使用和验证过,那么在实施阶段可以考虑使用 -Xverify:none 参数来关闭大部分的类验证措施,以缩短虚拟机类加载的时间

准备

准备阶段正式为静态变量(static修饰的)分配内存于堆区,并设置静态变量的零值(final修饰的除外)。这里不包括普通实例变量

在JDK7及之前,Hotspot使用的永久代来实现方法区时,静态变量(也成为类变量)是在方法区中分配的,而JDK8及以后,静态变量会随着Class对象一起移到Java堆中

static+final修饰的变量

static、final同时修饰的变量,不会赋零值,而会赋初值

在javac编译时会为变量生成ConstantValue属性,在准备阶段虚拟机会初始化为ConstantValue属性所指定的值

java 复制代码
public static int v1 = 123;
public static final int v2 = 123;

在准备阶段结束后,v1是0,v2是123

为啥一定要在准备阶段去赋值 ? 如果我有一个 static int i 1 = 1

那它准备阶段做的初始化赋值 i = 0 有啥意义、它最终初始化阶段的结果都是 i = 1 ?

那如果该 static 变量没有赋值初值呢?那么它就不会在初始化阶段中被赋值、那么它就不会存在于 InstanceMirrorKlass 中、后续我们使用该变量会直接报错

解释

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

解释动作主要针对类或接口

  • CONSTANT_Class_info
  • CONSTANT_Class_info
  • CONSTANT_Class_info
  • CONSTANT_InterfaceMethodref_info
  • CONSTANT_MethodType_info
  • CONSTANT_MethodHandle_info
  • CONSTANT_InvokeDynamic_info

解析动作具体来看

  • 类或接口的解析。假设有一个类D,要把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,分C是或不是数组类型来处理

    • 如果C不是一个数组类型,那么虚拟机会把代表N的全限定名传递给D的类加载器,去加载这个类C(第1点)
    • 如果C是一个数组类型,并且数组的元素类型为对象,也就是N的描述符会是类似"【Ljava/lang/Integer"的形式,那么将会按照(第1点)的规则加载数组元素类型,接着虚拟机会生成一个代表此数组维度和元素的数组对象
  • 字段解析。需要对字段表内的class_index项中索引的CONSTANT_Class_info符号引用(字段所属的类或接口的符号引用)进行解析

  • 类方法解析。需要对方法表的class_index项中索引的CONSTANT_Methodref_info符号引用(方法所属的类或接口的符号引用)进行解析

  • 接口方法解析。需要对接口方法表的class_index项中索引的CONSTANT_InterfaceMethodref_info符号引用(方法所属的类或接口的符号引用)进行解析

符号引用转化为直接引用

符号引用(Symbolic References):符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可

运行之后、用 HSDB 分析

Constant Type = JVM_CONSTANT_Class 时,Constant Value = public class com.peter.jvm.example.SymbolicReferencesToDirectReferencesTest @0x00000007c0060828

初始化

初始化阶段是执行类构造器clinit方法的过程

clinit方法是由编译器自动收集类中所有静态变量的赋值动作和静态代码块中的语句合并产生的,编辑器收集的顺序是由语句在源文件中出现的顺序所决定的

clinit方法是什么

clinit方法是类构造器方法,编译器会收集所有静态变量的赋值动作、所有静态代码块,合并产生一个方法,即clinit,该方法在类加载中"初始化"阶段被调用

clinit方法的功能及特点

  • clinit方法会执行静态代码块,给静态变量赋值
  • clinit方法执行顺序由源文件中语句出现顺序决定
  • 静态代码块中只能访问到定义在静态代码块之前的变量
  • 静态代码块中可以给定义在它之后的变量赋值,但不能访问
java 复制代码
public class Test {
  static {
    i = 0; // 给静态变量赋值可以正常编译通过
    System.out.print(i); // 定义在静态代码块之后的变量无法访问,会报编译错误
  }
  static int i = 1;
}

clinit 方法的必要性

clinit方法对于类或接口来说并不是必需的,如果一个类中没有静态代码块,也没有静态变量的赋值,编译器可以不为这个类生成clinit方法

在接口中执行 clinit 的逻辑

  • 接口中不能使用静态代码块,但仍然有变量(默认是static、final的)初始化赋值的操作,因此接口与类一样都会生成clinit方法
  • 接口与类不同的是,执行接口的clinit方法不需要先执行父接口的方法
  • 只有当父接口中定义的变量使用时,父接口的clinit方法才会被执行
  • 接口的实现类在执行clinit方法前也不需要先执行接口的clinit方法

多线程下执行clinit方法的逻辑

  • 虚拟机会保证一个类的clinit方法再多线程环境中被正确的加锁、同步
  • 如果多线程同时去初始化一个类,那么只会有一个线程去执行这个类的clinit方法,其他线程都需要阻塞等待,直到活动线程执行clinit方法完毕
  • 如果一个类的clinit方法中有耗时很长的操作,就可能造成多个线程阻塞

这里亲身经历遇到过一个大坑、后面写一个文章记录下

类加载的时机

  • 当虚拟机启动时,用户需要指定一个要执行的主类(包含main()方法的那个类),虚拟机会先初始化这个主类
  • 遇到 new、getstatic、putstatic 或 invokestatic 这4条字节码指令时,如果类没有进行过初始化,则需要先触发其初始化
  • 当初始化一个类的时候,如果发现其父类还没有进行过初始化,则需要先触发其父类的初始化
  • 使用 java.lang.reflect 包的方法对类进行反射调用的时候,如果类没有进行过初始化,则需要先触发其初始化·
  • 档使用 JDK1.7 的动态语言支持时,如果一个 java.lang.invoke.MethodHandle 实例最后的解析结果 REF_getstatic、REF_putStatic、REF_invokeStatic 的方法句柄,并且这个方法句柄所对应的类没有进行过初始化,则需要先触发其初始化

最后那一种忽略、没遇到过

注意:newarray,anewarray不会触发类加载

关于数组类

对于数组类而言,数组类本身不通过类加载器创建,它是由Java虚拟机直接创建的。但数组类与类加载器仍然有很密切的关系,因为数组类的元素类型(ElementType,指的是数组去掉所有维度的类型)最终是要靠类加载器去创建

创建过程遵循以下规则

  • 如果数组的组件类型(Component Type,指的是数组去掉一个维度的类型)是引用类型,那就递归采用 自定义类加载器 => 应用程序类加载器 => 拓展类加载器 => 启动类加载器 的加载过程
  • 如果数组的组件类型是基本类型(比如 int[]数组),就标记为与引导类加载器(我理解为启动类加载器)关联

数组本质也是一个类,注意不要与它的元素类搞混淆,比如B[] b = new B[100],B[]是数组类,B是元素类,而new一个数组对象时,是不会加载对应的元素类

java 复制代码
public class ArraysTest {

    public static void main(String[] args) {
        A a = new A();
        B[] b = new B[100];
    }
    private static class A {
        static {
            System.out.println("A");
        }
    }
    private static class B {
        static {
            System.out.println("B");
        }
    }
}

输出结果为 A

B[] b = new B[100]; 只是初始化了一个数组对象,为该数组对象分配了内存,但没有为100个数组元素分配内存。假设B占1个字节,B[] b = new B[100]; 这句话会分配100个字节的内存,这样理解是不正确的。很显然new B[100]之后每个元素都是null,null是没有内存地址的,只有在数组元素初始化时才会去分配内存

这里最大的质疑问题是:加载一个数组,需要知道分配多少内存,分配内存就需要类加载,可为什么不需要加载元素类呢

在学完对象内存布局后,上面的质疑就可以清晰地回答了

数组也是一个类,叫数组类,它与普通类有区别,类加载过程不太一样

A[] a = new A[3]; 这句话,是对A[] 这个数组类加载了,没有对A这个元素类加载

加载A[] 这个数组类,是需要知道要分配多少内存的,这个肯定

就需要看一看数组对象 内部结构了,如下

  • 对象头(包括MarkWord、类型指针、数组长度)
  • 实例数据
  • 对齐填充

关键在于实例数据,比如A[] a = new A[3]; 其中3个元素是分配在实例数据中的,而关键又在于分配的是什么

如果一个数组 int[] a = new int[3]; 这种情况下,实例数据占12个字节,因为int占4个,有3个int

如果一个数组 A[] a = new A[3]; 这种情况下,若开启指针压缩,实例数据占 12 个字节,若不开启指针压缩,实例数据占 24 个字节

这里有个概念,1个引用类型,开启指针压缩是4个字节,不开启是8个字节

然后可以看出来,对一个数组类分配内存的时候,其实是不需要知道元素类占多少内存的,数组对象中实例数据存储的是引用,也就是一个指向堆区对象的指针引用,它是4个字节或8个字节(是否开启指针压缩),只存储引用,不需要关心元素类占多少内存,也就不需要元素类的类加载了

关于常量池

  1. 静态常量池
  2. 运行时常量池
  3. 字符串常量池
相关推荐
24k小善40 分钟前
Flink TaskManager详解
java·大数据·flink·云计算
想不明白的过度思考者1 小时前
Java从入门到“放弃”(精通)之旅——JavaSE终篇(异常)
java·开发语言
.生产的驴1 小时前
SpringBoot 封装统一API返回格式对象 标准化开发 请求封装 统一格式处理
java·数据库·spring boot·后端·spring·eclipse·maven
猿周LV1 小时前
JMeter 安装及使用 [软件测试工具]
java·测试工具·jmeter·单元测试·压力测试
晨集1 小时前
Uni-App 多端电子合同开源项目介绍
java·spring boot·uni-app·电子合同
时间之城1 小时前
笔记:记一次使用EasyExcel重写convertToExcelData方法无法读取@ExcelDictFormat注解的问题(已解决)
java·spring boot·笔记·spring·excel
椰羊~王小美1 小时前
LeetCode -- Flora -- edit 2025-04-25
java·开发语言
凯酱2 小时前
MyBatis-Plus分页插件的使用
java·tomcat·mybatis
程序员总部2 小时前
如何在IDEA中高效使用Test注解进行单元测试?
java·单元测试·intellij-idea
oioihoii2 小时前
C++23中if consteval / if not consteval (P1938R3) 详解
java·数据库·c++23