JVM 类加载过程

本文最后更新于 2025年8月3日 下午

Java类的生命周期

Java类型的生命周期主要分为7个阶段 :

加载→ 验证→ 准备→ 解析→ 初始化→ 使用→ 卸载

触发加载的条件

  1. 在遇到new ,getstatic ,putstatic或invokestatic 这四条字节码指令时,如果对应类型没有被初始化,则需要先触发其初始化阶段,这几条指令通常会在以下情况被调用
    1. 使用new关键字实例化对象的时候
    2. 读取或设置一个类型的静态字段的时候
    3. 调用一个类型的静态方法的时候
  2. 使用java.lang.reflect包的方法对类型进行反射调用的时候,如果类型没有进行过初始化,则触发初始化
  3. 当初始化类的时候,如果发现其父类还没进行过初始化,则需要先触发其父类的初始化
  4. 当虚拟机启动时,用户需要指定又给要执行的主类,虚拟机会先初始化这个主类
  5. 当需要使用反射调用方法时,对应的类没有进行过初始化,则触发初始化
  6. 当一个接口中定义默认方法时,如果有这个接口的实现类发生了初始化,那接口要在其之前被初始化

加载

加载阶段的目标 :

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

对于数组类

数组类本身不通过类加载器创建,它是由java虚拟机直接在内存中动态构造出来的。但数组类与类加载器仍然有很密切的关系,因为数组类的元素类型,最终还是要靠类加载器来完成加载。

数组类的创建过程

  1. 如果数组的组件类型是引用类型,那就递归采用本节中定义的加载过程区加载这个组件类型,数组C将被标识在加载该组件类型的类加载器的类命名空间上。
  2. 如果数组的组件类型不是引用类型,java虚拟机将会把数组C标记为与引导类加载器关联
  3. 数组类的可访问性与它的组件类型的可访问性一致,如果组件类型不是引用类型,它的数组类的可访问性将默认为public

验证

验证用于确保Class文件的字节流中包含信息复合规范的全部约束要求

文件格式验证

验证字节流是否符合Class文件格式的规范,包括

  1. 是否以魔数0xCAFEBABE开头
  2. 主,次版本号是否在当前Java虚拟机接受范围之内
  3. 常量池的常量中是否有不被支持的常量类型
  4. CONSTANT_Utf8_info型的常量中是否有不符合UTF-8编码的数据
  5. Class文件中各个部分及文件本身是否有被删除的或附加的其他信息

元数据验证

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

  1. 这个类是否有父类
  2. 这个类的父类是否继承了不允许被继承的类
  3. 如果这个类不是抽象类,是否实现了其父类或接口之中要求实现的所有方法
  4. 类中的字段,方法是否与父类产生矛盾

字节码验证

第三阶段 通过数据流分析和控制流分析,确定程序语义是合法的,符合逻辑的。

  1. 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作。
  2. 保证任何跳转指令都不会跳转到方法体以外的字节码上
  3. 保证方法体中的类型转换总是有效的

符合引用验证

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

准备

准备阶段是正式为类中定义的变量分配内存并设置类变量初始值的阶段。

通过这个步骤就将定义的类变量值设置为0,只有当声明的变量中存在final关键字时,会直接将对应的值设置为ConstantValue 属性。并在准备阶段将属性直接赋值给对应的变量

解析

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

符号引用 :符号引用以一组符号来描述所引用的目标,符号可以是任何形式的字面量,只要使用时能无歧义地定位到目标即可。符号引用与虚拟机实现的内存布局无关,引用的目标并不一定是已经加载到虚拟机内存当中的内容。

直接引用 :直接引用是可以直接指向目标的指针,相对偏移量或者是一个能间接定位到目标的句柄。直接引用是和虚拟机实现的内存布局直接相关的。

动态解析

在处理多次解析请求时,如果是除invokedynamic指令外的解析过程,虚拟机实现可以对第一次解析的结果进行缓存,无论是否真正执行了多次解析动作,java虚拟机都需要保证的是在同一个实体中。

而对于invokedynamic指令,则会在程序实际运行到这条指令时,解析动作才能进行,而其他的触发解析的指令都是静态的,可以在加载阶段完成

类或接口解析

假设当前代码所处的类为D,如果把一个从未解析过的符号引用N解析为一个类或接口C的直接引用,则需要

  1. 当C不是一个数组类型时,虚拟机会将N的全限定名传递给D的类加载器去加载类C。在加载过程中,又会触发其他相关加载动作。
  2. 当C是一个数组类型,并且数组的元素类型为对象,那会按照1中的规则加载数组元素类型,接着由虚拟机生成一个代表该数组维度和元素的数组对象
  3. 如果上述两步执行完毕,那么C在虚拟机中已经是一个有效的类或者接口了,再之后需要进行符号引用验证。

字段解析

要解析一个未被解析过的字段符号引用,需要先解析字段所属的类或接口的符号引用。

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

方法解析

方法解析也需要优先解析方法所属类或接口的符号引用

  1. 判断 所需要解析的信息的类型与表中的类型不一致的话,抛出异常
  2. 查询C中简单名称与描述符都与目标相匹配的方法,如果存在返回该方法的直接引用,查找结束
  3. 否则,在类C的父类中递归查找是否有简单名称和描述符都与目标相匹配的方法,如果存在返回该方法直接引用,查找结束
  4. 否则,在C实现的接口列表及父接口之中查找是否有简单名称和描述符都与目标相匹配的方法,如果存在匹配的方法,说明类C是一个抽象类。抛出异常
  5. 否则,查找失败

接口方法解析

也需要查询所属的类或接口已经被解析

  1. 如果查询到索引是个类而不是接口,则直接抛出异常
  2. 否则,在接口C中查找是否有简单名称和描述符都与目标相匹配的方法,如果有,则返回该方法的直接引用,查找结束
  3. 否则,在接口C的父接口中递归查找,直到Object类为止
  4. 如果C的不同父接口中存在多个简单名称和描述符都与目标类相匹配的方法,则会从中返回其中一个并结束查找
  5. 否则,查找失败

初始化

初始化阶段,Java虚拟机才真正开始执行类中编写的Java程序代码,将主导权移交给应用程序

初始化阶段 ,会根据编程人员通过程序编码制定的主管计划去初始化类变量和其他资源。

初始化过程就是执行类构造器<clinit>方法的过程。它由javac编译器自动生成

<clinit>() 方法是由编译器自动收集类中的所有类变量的赋值动作和静态语句块中的语句合并产生的

静态语句块中只能访问到定义在静态语句块之前的变量,定义在它之后的变量,在前面的静态语句块可以赋值,但不能访问

<clinit> 不需要显式的执行父类的构造器,由于父类构造器先执行,则父类中定义的静态代码优先于子类的赋值操作

<clinit>() 方法对于类或者接口来说不是必须的

多个线程同时去初始化一个类,那么就只会有其中一个线程去执行这个类的<clinit>方法

类加载器

JVM在设计时有意把类加载阶段中的“通过一个类的全限定名来获取描述该类的二进制字节流”放在外部实现,这一部分的代码实现被称为 类加载器

类加载器只用于实现类的加载动作,它和类本身一起共同确立类在java虚拟机中的唯一性。是否由同一个类加载器加载 是两个类是否相等的 判定标准之一。

双亲委派机制

启动类加载器

启动类加载器负责加载存放在\lib目录,或者被-Xbootclasspath参数所指定的路径中存储的,而且是java虚拟机能够识别的类库加载到虚拟机的内存中,启动类加载器无法被java程序直接引用,用户如果需要将加载请求委派给引导类加载器处理,直接使用null代替

扩展类加载器

扩展类加载器负责加载\lib\ext中 或者被java.ext.dirs 指定路径中的所有类库

应用程序类加载器

应用程序类加载器,负责加载用户类路径上的所有类库。

双亲委派

如果一个类加载器受到了类加载的请求,会先将这个请求委派给父类加载器去完成,最终都会将请求传递到最顶层的启动类加载器中,当父加载里中没有查询到该类,再由子类尝试完成加载。

双亲委派的作用

  • 安全隔离 :防止恶意代码通过自定义java.lang.String等核心类来破坏系统
  • 命名空间管理 :同一个类加载器加载的类属于同一个命名空间
  • 版本控制 :不同类加载器可以加载同名但不同版本的类,实现隔离
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
protected synchronized Class<?> loadClass(String name , boolean resolve) throws 
ClassNotFoundException
{
Class c = findLoadedClass(name);
if(c==null){
try {
if(parent != null){
c = parent.loadClass(name,false);
}else {
c = findBootstrapClassOrNull(name);
}
}catch(ClassNotFoundException e){
// 如果父类抛出异常 则说明父类无法完成加载请求

}
if (c == null){
// 父类无法完成加载时,由本类尝试加载
c = findClass(name);
}
}
if(resolve){
resolveClass(c);
}
}

破坏双亲委派

  1. SPI 双亲委派机制只能解决各个类加载器协作时的一致性问题。对于一些用于对资源进行查询和管理的业务,它本身就需要完成管理类的加载过程,通过线程上下文加载器,去加载所需的SPI服务代码, 实际上是通过父类加载器请求子类加载器完成类加载的行为。
  2. OSGi 有些模块需要对自身所依赖的类进行更何时的定制化加载管理,每一个程序模块都有一个自己的类加载器,当需要更换一个Bundle时,就把Bundle连同类加载器一起换掉以实现代码的热替换。这样对于一个模块来说,它本身掌握了该子系统所需的类加载逻辑,可以实现更好的定制化加载,其加载逻辑 :
    1. 将以java.* 开头的类委派给父类加载器加载
    2. 否则,将委派列表名单内的类,委派给父类加载器加载
    3. 否则,将Import列表中的类,委派给Export 这个类的Bundle的类加载器加载
    4. 否则,查找当前Bundle的ClassPath,使用自己的类加载器加载
    5. 否则,查找类是否在自己的Fragment Bundle中,如果在,则委派给Fragment Bundle的类加载器加载
    6. 否则,查找Dynamic Import 列表的Bundle,委派给对应Bundle的类加载器加载
    7. 否则,类查找失败
  3. 我们可以理解到双亲委派机制本身的作用是用于控制类来源的一致性,保证基础类是通过有确定来源的位置被加载并使用的,但是为了满足实际场景中的不同需求,同一个类的不同版本被加载到同一个项目中,或者热部署的切换需求,需要有更加灵活的类加载过程,才能保证系统的定制化实现。

JVM 类加载过程
http://gadoid.io/2025/08/03/JVM-类加载过程/
作者
Codfish
发布于
2025年8月3日
许可协议