JVM篇(二):类加载器子系统

JVM篇(二):类加载器子系统

Lazzz Lv1

image

类加载器子系统的作用

  • 类加载器,负责从文件IO或者网络IO中加载Class文件由
  • 类加载器子系统加载了的Class信息存放在内存中的方法区中
  • 如果要执行我们的Class文件则是由执行引擎决定的

TIP

类加载器子系统只负责加载类,至于存储和执行由JVM中其他系统决定。所有的Class文件的头部一定有特殊的文件标识,即CAFA BABE(0xCAFABABE)。另外Class文件来源可以从本地系统直接加载、网络传输加载、压缩包加载(例如: jar、war、apk等)、运行时生成的类文件(例如动态代理)等等

类加载的过程

INFO

类加载过程包括了三个阶段: 加载,链接,初始化。Class文件进入类加载器子系统中第一步就会进行加载阶段,该阶段主要负责将类文件加载到内存中。

加载阶段(Loading)

在该阶段,类加载器通过类的全限定名获取定义此类的二进制字节流,然后将这个类的字节流代表的静态存储结构转换为方法区的运行时数据结构,最后在内存中生成一个代表这个类的 java.lang.Class 对象作为这个类的各种数据在方法区的访问入口。

简单概述

  1. 查找并加载类的二进制字节流
    • 类加载器通过类的全限定名查找并加载到内存中
  2. 解析类的二进制字节流
    • 将加载的二进制字节流解析并转化为运行时数据结构
  3. 创建 Class 对象
    • 在方法区中创建一个 java.lang.Class 对象

链接阶段(Linking)

💡提示

Class文件进过加载阶段之后已经在我们的内存中了。这时候进入我们类加载器子系统流程的第二部,链接阶段,该阶段包含了3个小阶段:验证(Verification)、准备(Preparation)、解析(Resolution)。

image

验证(Verification)

文件格式验证

🔭简要概括

文件格式验证是验证的第一步,发生在字节流刚被加载和存入方法区之间。这个阶段主要验证字节流是否符合Class文件格式的规范以及是否能被当前 JVM 版本正确处理。只有通过文件格式验证字节流才会被解析并存储到方法区中,后面的验证步骤都将基于方法区中的数据结构做验证,不再直接操作字节流。

  1. 魔数验证: 检查文件开头是否是以 0xCAFEBABE (Class文件的身份标识)开头。
  2. 版本号验证: 检查主次版本号是否在当前 JVM 的处理范围之内。保证 JVM能支持当前Class文件的版本
  3. 常量池验证: 检查常量池的常量中是否有不被支持的常量类型(通过常量tag标志),以及指向常量的各种索引值是否指向了不存在的常量或不符合类型的常量
  4. 文件结构验证: 检查Class文件本身以及各个部分(如访问标志、类索引、字段表、方法表等)**是否有被删除或附加其他信息,结构长度是否正确等。
元数据验证

🔭简要概括

通过文件格式验证后,字节流以及转化为方法区中的数据结构了。元数据验证则是对字节码描述的信息进行语义分析,以保证字节码的描述信息符合 Java 语言规范的要求。可以理解为对类元信息的**”语法”“基础语义”**检查

  1. 继承关系验证: 检查当前Class文件中的类是否有父类(除 java.lang.Object 以外的所有类都应有父类),以及是否继承了不允许被继承的类(即被 final修饰的最终类)
  2. 实现验证: 如果当前的类不是抽象类,检查它是否实现了其父类或接口之中所有要求实现的所有方法
  3. 一致性验证: 检查类中的所有字段、方法是否与父类产生矛盾,例如是否覆盖了父类的
字节码验证

🔭简要概括

字节码验证是整个验证过程中最复杂的一步。主要目的是通过数据流和控制流分析,确定程序语义是合法且符合逻辑的,确保被校验类的方法在运行时不会产生危害虚拟机安全到事件。在此阶段主要是对类的方法体进行校验。但是由于”停机”等理论限制,字节码验证无法做到100%准确,其目标是尽可能检查出所有可预知的明显问题。

  1. 类型一致性验证: 保证任意时刻操作数栈的数据类型与指令代码序列都能配合工作。例如: 不会出现在操作数栈放置了一个 int 类型数据,却按 long 类型来加载使用的错误。
  2. 控制流验证: 保证跳转指令不会跳转到方法体以外的字节码指令上,确保程序计数器始终指向方法体内的有效指令
  3. 类型转换有效性验证: 保证方法体中的数据类型转换是有效的。例如: 可以把一个子类对象赋值给父类数据类型(向上转型,安全),但是把父类对象赋值给子类数据类型(向下转型,不安全) 则需要在运行时检查,而把对象赋值给与他毫无继承关系的类型则是危险不合法的
符号引用校验

🔭简要概括

最后一个阶段的校验发生在虚拟机将符号引用转化为直接引用的时候,这个转化动作在链接的第三个阶段——解析阶段中进行。符号引用验证可以看作是对类自身外(常量池的各种符号引用)的信息进行匹配性校验。符号引用验证的目录是确保后续的解析动作能正常执行,如无法通过则会抛出 java.lang.IncompatibleClassChangeError异常的子类,如 IllegalAccessErrorNoSuchFieldErrorNoSuchMethodError

  1. 存在性验证: 符号引用中通过字符串描述的全限定名是否能找到对应的类
  2. **成员验证: **在指定类中是否存在符合方法的字段描述符以及简单名称所描述的方法和字段
  3. 访问权限验证: 符号引用中的类、字段、方法的访问性(private、protected、public、default) 是否可被当前类访问
补充

验证阶段是链接阶段的第一步,对于 JVM 的安全至关重要。然而,从性能角度看,它也是一个非常重要但非必须的阶段。如果所运行的全部代码都已经被反复使用和验证过(例如在开发环境),可以考虑使用 -Xverify:none 参数来关闭大部分的类验证措施,以缩短虚拟机类加载时间。不过在生产环境中,为了安全起见,通常不建议关闭验证

准备(Preparation)

🔭简要概括

准备阶段是链接的第二步,简单来说,准备阶段是赋默认值。而后续的初始化(Initialization)阶段才是执行类构造器**()方法,进行“赋初始值”。准备阶段主要任务是为类中定义的静态变量**(即被 static 修饰的变量) 分配内存,并设置这些变量的初始默认值。

  1. 分配内存与设置默认值: 在此阶段的内存分配仅针对类变量(静态变量),不包括实例变量。实例变量将会在对象实例化时随着对象一起分配在 Java 堆中。这里设置的初始值是数据类型的**”零值”**,而非程序代码中显示赋予的初始值。
1
2
3
4
5
/**
* 在准备阶段后,value的值为0,而非123。
* 因为0为int变量的"零值"
*/
public static int value = 123;
  1. final static 变量特殊处理: 如果静态变量同时被 final 修饰,即常量,那么在编译时值就已经被确定。在准备阶段,JVM 会直接将这些常量赋予程序代码中指定的值,而不会赋默认值
1
2
3
4
5
6
/**
* 在准备阶段后,VALUE的值为123,而非"零值"。
* 因为常量的值在编译的时候就确定了
* 在准备阶段则直接赋予代码中的指定值
*/
public static final int VALUE = 123;

解析(Resolution)

🔭简要概括

解析是链接阶段最后一步。其核心任务是将常量池内的符号引用(Symbolic References)替换为直接引用(Direct References) 。解析阶段的目标就是将在编译期无法确定的依赖关系(如父类方法、其他类中的字段等)确定下来,将抽象的符号引用转化为具体的内存地址相关的直接引用,为后续程序访问做好准备。

符号引用

符号引用是指一组用来描述所引用目标的符号。与 JVM 实现的内存布局无关,引用的目标不一定已经加载到内存中。在Class文件中,它以 CONSTANT_Class_infoCONSTANT_Fieldref_infoCONSTANT_Methodref_info等类型的常量形式存在。例如,一个方法调用,在Class文件中是通过指向常量池中一个包含类名、方法名、描述符的符号引用来表示的。

直接引用

直接引用可以是直接指向目标的指针、相对偏移量或者是一个能间接定位到目标的句柄。直接引用和 JVM 实现的内存布局直接相关,如果有了直接引用,那引用的目标必定已经在内存中存在。

解析动作主要针对一下七类符号引用进行:

  1. 类和接口
  2. 字段
  3. 类方法
  4. 接口方法
  5. 方法类型
  6. 方法句柄
  7. 调用点限定符

初始化(Initialization)

在JVM的类加载过程中,**初始化(Initialization)**是链接阶段之后的最终步骤,也是类加载过程中真正开始执行用户编写的Java程序代码的阶段。它负责为类的静态变量赋予程序设定的初始值,并执行静态初始化代码块。

初始化阶段的核心任务

初始化的核心是执行类的 <clinit>方法。这个方法并非由程序员直接编写,而是由 Java 编译器自动生成的。编译器会收集类中所有的静态变量的赋值动作和**静态代码块(static {})**中的语句,将他们合并生成一个唯一的 <clinit>方法。

因此,初始化阶段具体做的事情就是JVM调用 <clinit>方法:

  1. 为静态变量赋”真值”: 在之前链接阶段的**准备(Preparation) **步骤中,静态变量已被分配在内存并赋予了各个数据类型的默认零值。在初始化阶段才会执行静态变量的赋值语句,将其定义为代码中的初始值
1
2
3
4
5
6
/**
* 在初始化阶段后,value的值为123
* 因为初始化阶段会执行静态变量的赋值语句
* 所以会替换掉准备阶段赋予的默认零值
*/
public static int value = 123;
  1. 执行静态代码块: 按照代码中出现的顺序,依次执行所有静态代码块中的逻辑

触发初始化的条件(主动使用)

一个类或接口不会在加载后立即初始化。JVM 规范严格规定”主动使用“一个类的六种情况,只有在这几种情况下才会立即触发类的初始化过程:

  1. 创建类的实例: 例如使用 new 关键字
  2. 访问或赋值类的静态变量: 有一个例外,如果该静态变量是编译时常量(即被 static final 修饰,且值在编译时就能确定),那么对此常量的引用不会处罚初始化,因为它在编译阶段就被内联到使用处了。
  3. 调用类的静态方法
  4. 使用反射: 例如 Class.forName("ClassName")
  5. 初始化子类: 当初始化一个类时,如果其父类还未被初始化,则会先触发其父类的初始化
  6. 被指定为JVM启动时的主类: 即包含 main(String[])方法的类

初始化具体执行过程与顺序

当初始化被触发后,JVM 会确保该过程以线程安全的方式执行(通过加锁),并遵循一个清晰的顺序规则:

  1. 父类优先: 如果当前类有父类未被初始化,则 JVM 会首先去初始化父类。这个规则会递归应用,因此 JVM 最先初始化的总是 java.lang.Object
  2. 按代码顺序: 在同一个 <clinit>方法内部,静态变量赋值和静态代码块会严格按照它们在源文件中出现的顺序先后执行
  3. 合并执行: 编译器生成的 <clinit>方法会合并所有静态初始化操作。这意味着,即使静态变量声明和静态代码块在代码中交错出现,她们在 <clinit>方法中的执行顺序依然与源码中顺序完全一致
1
2
3
4
5
6
7
8
/**
* 在下面代码中静态变量和静态代码块会合并成一个 <clinit> 方法
* 并且合并之后的顺序不会变,从下面的代码来看,合并之后静态变量依然在静态代码块前
*/
public static value = 99;
static {
System.out.println("休息☕️");
}

类加载器的分类

mindmap
  id) 类加载器 (
    BootStrap Class Loader
      加载核心 Java 类库
      JDK8由 C++ 实现, JDK8 之后 由 Java 实现
    Extension Class Loader
      加载 **JRE** 扩展目录中的类
    Application Class Loader
      加载用户类路径上的类
    User Defined Class Loader
      实现自定义类加载逻辑
    ....

提醒

在 JDK8 及之前的版本 类加载器位于 sun.misc.Launcher 类路径,而在 JDK8 之后的版本的类路径则变更为 jdk.internal.loader.ClassLoaders 且启动类加载器由原本的C++编写改变为Java编写

启动类加载器

  • 启动类加载器为JVM中最底层的类加载器,在JDK8之前由 C/C++ 实现(之后的版本由 JAVA 实现)。嵌套在JVM内部,不继承 java.lang.ClassLoader ,主要负责加载 Java 的核心库
  • 最开始由于是 C/C++ 实现,在代码中获取启动类加载器的引用会返回 null,于是为了保持统一,即便后续版本中的启动类加载器由 Java 实现,获取其引用也为 null

扩展类加载器

  • 扩展类加载器为java.lang.ClassLoader 的派生类,它负责加载 Java 的扩展库
  • 从系统属性 java.ext.dirs 指定的目录或 JDK 安装目录的 lib/ext 子目录下加载类。任何jar包在该目录下,扩展类加载器都会自动加载。

应用程序类加载器

  • 应用程序类加载器也被称为系统类加载器,与扩展类同为 java.lang.ClassLoader 的派生类。它也是我们在日常开发中最常接触到的类加载器。
  • 它负责加载环境变量 classpath 或系统属性 java.class.path 指定路径下的所有类库。日常开发中编写的应用程序以及多数第三方依赖默认都由应用程序类加载器加载

用户自定义类加载器

  • 所有 java.lang.ClassLoader 派生的类加载器,在规范层面都可以划分为自定义类加载器
  • 在必要时,开发者可以继承 ClassLoader ,并重写findClass 方法来实现特殊的类加载需求
  • 常见应用场景:
    • 隔离加载类: 实现类库隔离、组件化
    • 扩展类加载源: 从网络、数据库、加密文件等非标准来源加载字节码
    • 修改加载方式: 实现热部署、字节码加解密等

双亲委派机制

🚀️ 概述

双亲委派机制是 Java 类加载器工作的核心模型,它通过一套层次化的委托规则确保Java程序的稳定性、安全性和类的唯一性。

flowchart BT
  A@{ shape: stadium, label: "UserDefinedClassLoader" }
  --> 
  B@{ shape: stadium, label: "ApplicationClassLoader" }
  --> 
  C@{ shape: stadium, label: "Extension ClassLoader" }
  -->
  D@{ shape: stadium, label: "BootStrapClassLoader" }

什么是双亲委派机制?(概念)

双亲委派机制(Parent Delegation Model) 是一种类加载策略。核心思想是当一个类加载器收到加载某个类的请求时,该类加载器不会立即尝试自己加载,而是先将这个请求委托给它的父类(上层)加载器去处理。这个过程会逐级向上进行,直到最顶层的启动类加载器。只有当所有父类加载器都无法在自己的搜索范围内完成加载时(即抛出 ClassNotFoundException),子加载器才会尝试自己加载。这种机制不是通过继承实现,而是通过组合关系,每个类加载器内部都持有一个指向父类加载器的引用(parent字段)

工作流程

双亲委派机制的具体逻辑体现在 ClassLoader 类的 loadClass(String name, boolean resolve) 方法中。其工作流程可以分为以下几步:

  1. 检查是否已加载: 首先,类加载器会调用 findLoadedClass(String name) 方法来检查这个类是否已经被自己加载过,如果自己已经加载过则直接返回缓存的Class对象,避免重复加载。
  2. 委托父加载器: 如果该类未被加载且当前加载的父加载器(parent)不为null,则调用 parent.loadClass(name, false) ,将请求委托给父加载器。这个过程会一直往上传递至到启动类加载器。
  3. 父加载器为空的处理: 如果当前加载器的父加载器为 null,通常意味已经到了扩展类加载器委托给启动类加载器的环节,而启动类加载器在 Java 层表示为 null,则会调用 findBootstrapClassOrNull(name) 方法,尝试让启动类加载器加载
  4. 自行加载: 如果所有的父加载器都无法完成加载(在各自的搜索路径中找不到该类),请求会逐级返回,最终由最初发起请求的类加载器调用自己的 findClass(name) 方法来尝试加载
  5. 解析类: 最后,如果需要( resolve 参数为 true ),则调用 resolveClass(c) 方法对类进行链接阶段的解析

优势与目的

三大核心优势:

  1. 确保类的唯一性与避免重复加载: 对于一个类,只要父加载器成功加载,子加载器就不会再加载第二次。这保证了 JVM 中,一个类由其全限定名加载它的类加载器共同确立了其唯一身份。避免了内存中出现多份相同的字节码,也防止了因类版本冲突导致的混乱。
  2. 保护核心API的安全: 核心 Java 类库由启动类加载器加载。双亲委派机制保证了即使用户在 ClassPath 下自定义了一个与核心库同名的类(例如 java.lang.String),加载该类的请求最终也会被委托给启动类加载器。由于启动类加载器已经加载了真正的核心类,所以不会再加载用户自定义的同名类,从而有效的防止核心API被恶意篡改
  3. 维持程序的稳定性与类型一致性: 它建立了一种带有优先级的层次关系,使得基础类总是被高层级的加载器加载,为程序的稳定运行提供了基础框架。

打破双亲委派机制

为何打破?

尽管双亲委派机制是默认且优秀的设计,但是在一些特定的场景下,严格的层级委托会带来问题。主要场景包括:

  1. SPI(服务提供者接口)机制: 以JDBC为例。java.sql.DriverManager (核心API,由启动类加载器加载)需要加载各个数据库厂商提供的 Driver 实现类(位于 ClassPath 下)。根据双亲委派,BootstrapClassLoader 无法“向下”加载 ApplicationClassLoader 路径中的类。解决方案是使用线程上下文类加载器(ThreadContextClassLoader)。DriverManager 在加载驱动时,会将当前线程的上下文加载器设置为应用类加载器,从而实现“逆向”委托,由应用类加载器去加载 SPI 实现类
  2. 实现模块化与热部署: 如 OSGI 框架。在OSGI中,每个模块 (Bundle) 都有自己独立的类加载器。当需要一个类时,OSGI 的类加载器会优先在本模块内查找并加载,如果找不到,再按照特定的规则(而非简单的父委托)去其他模块或父加载器中查找。这实现了真正的模块隔离和动态更新。
  3. Web 容器中的应用隔离:Tomcat。一个 Tomcat容器中可能运行多个Web应用,每个应用可能依赖不同版本的同一库(如 Spring)。如果严格使用双亲委派,子加载器会委托给共享的父加载器,导致库版本冲突。因此,Tomcat 为每个 Web应用创建了独立的 WebappClassLoader,它会优先加载 /WEB-INF/lib 和 /WEB-INF/classes 目录下的自有类,只有找不到时才会委托给父加载器(共享的 CommonClassLoader),从而实现了应用间的类隔离

如何打破?

通常是通过自定义类加载器并重写 loadClass() 方法来实现。在重写的方法中,可以先不委托给父加载器,而是优先自己尝试加载,或在特定条件下绕过父加载器

  • 标题: JVM篇(二):类加载器子系统
  • 作者: Lazzz
  • 创建于 : 2025-12-09 19:39:33
  • 更新于 : 2025-12-12 17:36:50
  • 链接: https://blog.bukkitmc.cn/posts/4f6671b9/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
评论