Java的类加载器

开头先分享一个自己写的小工具,使用tauri2开发的粘贴板工具:https://jingchuanyuexiang.com

一. 开篇

之前写了Jvm知识点Java类结构和类加载这两个用来记录Java的基础知识,最近回去翻看发现漏了Java类加载器相关的知识点,这里就补充一篇。如果有小伙伴看到这篇文章,建议先去看下Java类结构和类加载这个再回来看本篇。

二. 类加载器介绍

类加载器是一个负责加载类的对象,类加载器负责 将类的二进制表示(不一定非要来自文件系统)加载到 JVM 中,并最终定义为一个 java.lang.Class 对象。ClassLoader 是一个抽象类,给定类的二进制名称,类加载器应尝试定位或生成构成类定义的数据。典型的策略是将名称转换为文件名,然后从文件系统中读取该名称的"类文件"。每个非数组类、非基本类型的Java类都有一个引用指向加载它的 ClassLoader。不过,数组类不是通过 ClassLoader 创建的,而是 JVM 在需要的时候自动创建的,数组类通过getClassLoader()方法获取 ClassLoader 的时候和该数组的元素类型的 ClassLoader 是一致的。

JVM 中内置了三个重要的 ClassLoader:

  • BootstrapClassLoader(启动类加载器):最顶层的加载类,由 C++实现,在Java代码中并没有这个类,通常表示为 null,并且没有父级,主要用来加载 JDK 内部的核心类库( %JAVA_HOME%/lib目录下的 rt.jarresources.jarcharsets.jar等 jar 包和类)以及被 -Xbootclasspath参数指定的路径下的所有类。
  • ExtensionClassLoader(扩展类加载器):主要负责加载 $JAVA_HOME/jre/lib/ext/ 目录下的 jar 包和类以及被 java.ext.dirs 系统变量所指定的路径下的所有类,这里说的扩展类,实际并不是我们自己写的那些扩展类、也不是Spring等那些所谓的扩展类,而是JDK 官方提供、但不属于 Java 核心(rt.jar)的可选扩展库 ,比如:javax.crypto.*jdk.nio.zipfs.*。但是在Jdk9之后ExtensionClassLoader 被移除,jre/lib/ext 目录消失,被 PlatformClassLoader 替代,原因是Java 模块化(JPMS) ,并且ext 机制破坏模块边界
  • AppClassLoader(应用程序类加载器):面向我们用户的加载器,负责加载当前应用 classpath 下的所有 jar 包和类。

比如一个jdk21的Springboot3.3.x项目,使用内嵌 Tomcat java -jar启动 ,其中的类加载列举情况如下:

类的来源 示例类 负责加载的 ClassLoader
JDK 核心类 java.lang.String BootstrapClassLoader
JDK 扩展类 javax.crypto.Cipher PlatformClassLoader
Spring Boot / Spring org.springframework.boot.SpringApplication LaunchedClassLoader
业务代码 com.xxx.demo.DemoApplication LaunchedClassLoader
内嵌 Tomcat org.apache.catalina.startup.Tomcat LaunchedClassLoader
第三方依赖 com.fasterxml.jackson.ObjectMapper LaunchedClassLoader
SPI 实现 META-INF/services/* LaunchedClassLoader
数组类 String[] JVM 自动创建

上面这个表格基于 JDK21 + Spring Boot 使用内嵌 Tomcat,并通过 java -jar 方式启动

若在 IDE 或 classpath 模式启动,业务类与依赖类通常由 AppClassLoader 加载。在 IDE 中直接运行 Spring Boot 项目时,应用以 classpath 方式启动,JVM 使用 AppClassLoader 加载所有业务类与依赖类;只有在使用 java -jar 运行时,Spring Boot 才会创建 LaunchedClassLoader 来支持嵌套 jar 的类加载。

  • 对于一个由java -jar启动的Springboot项目,实际加载我们jar包中的class文件的,是:LaunchedClassLoader这个Springboot自定义的类加载器,自定义这个类加载器的主要原因是:解决JVM 原生类加载器无法从嵌套 jar 中加载类的问题 。其实很简单,我们的Springboot应用一般都是打成jar包的,你解压这个jar包就可以看到我们的依赖都在jar包的BOOT-INF/lib/*.jar中,jar是个压缩文件,上面说的Jvm内置的三个加载器都无法直接加载其中的jar,所以Springboot自定义了一个类加载器LaunchedClassLoader ,这个加载器并不属于 AppClassLoader,它是 Spring Boot 在运行期创建的自定义类加载器,二者同为 URLClassLoader 的子类,仅在委派模型中形成父子关系。这个LaunchedClassLoader你在源码中也是找不到的,他真正存在的位置是你jar包中的:app_xxx.jar\org\springframework\boot\loader\launch\LaunchedClassLoader.class,只有你打成jar包才能看到。LaunchedClassLoader 并没有改变 JVM 的双亲委派机制,它只是在遵守双亲委派的前提下,定制了 findClass / loadClass 的类查找来源,以支持 嵌套 jar 的加载。

  • 对于一个在IDE里面使用常用的debug模式启动一个springboot项目,那么你看到的Spring、Springboot、你的业务代码 这些都是由AppClassLoader 加载的,因为不需要通过jar包就能启动。

三. 双亲委派模型

双亲委派模型(Parent Delegation Model)是 JVM 类加载机制中的一种设计模式,用于保证 Java 类加载的 安全性、一致性和避免重复加载。它规定了类加载器之间的委派关系。双亲委派模型并不是一种强制性的约束,只是 JDK 官方推荐的一种方式。如果有必要我们也可以打破这种模式。

复制代码
                 ┌──────────────────┐
                 │  BootstrapLoader │
                 │  (引导类加载器)    │
                 └──────────────────┘
                           ▲
                           │
                 ┌──────────────────┐
                 │  PlatformLoader  │
                 │ (平台/扩展类加载器) │
                 └──────────────────┘
                           ▲
                           │
                 ┌──────────────────┐
                 │   AppClassLoader │
                 │  (应用类加载器)    │
                 └──────────────────┘
                           ▲
                           │
                    ┌─────────────┐
                    │  业务类/依赖  │
                    └─────────────┘

3.1 双亲委派模型的执行流程

java 复制代码
// 当前加载器的父级加载器
private final ClassLoader parent;

//...省略其他代码

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
{
    synchronized (getClassLoadingLock(name)) { // 对于同一个类名,获取一个锁,保证线程安全
        // First, check if the class has already been loaded
        // 首先,检查这个类是否已经被加载过
        Class<?> c = findLoadedClass(name);
        
        if (c == null) { // 如果尚未加载
            long t0 = System.nanoTime(); // 记录当前时间,用于性能统计
            try {
                if (parent != null) { 
                    // 如果存在父类加载器,先委派给父类加载器加载
                    c = parent.loadClass(name, false);
                } else {
                    // 如果父类加载器为空(通常是BootstrapClassLoader),尝试从BootstrapClassLoader加载
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // 如果父类加载器没有找到该类,会抛出 ClassNotFoundException
                // 这里捕获后什么都不做,交由当前类加载器自己去加载
            }

            if (c == null) {
                // 如果父加载器也没有找到类,那么调用当前加载器的 findClass 方法去加载
                long t1 = System.nanoTime(); // 再记录时间
                c = findClass(name); // 调用子类自定义的 findClass 方法去实际加载类

                // 记录类加载统计信息
                PerfCounter.getParentDelegationTime().addTime(t1 - t0); // 父委派耗时
                PerfCounter.getFindClassTime().addElapsedTimeFrom(t1); // 自己查找类耗时
                PerfCounter.getFindClasses().increment(); // 自己查找类的次数累加
            }
        }
        if (resolve) {
            // 如果调用者要求解析类,则解析之(解析包括链接阶段,准备和验证)
            resolveClass(c);
        }
        return c; // 返回加载的类对象
    }
}

上面这个是java.lang.ClassLoader双亲委派模型的实现源码的翻译注释版本。从代码可以看出实际是一个递归的流程,结合上面的源码,简单总结一下双亲委派模型的执行流程:

  • 在类加载的时候,系统会首先判断当前类是否被加载过。已经被加载的类会直接返回,否则才会尝试加载(每个父类加载器都会走一遍这个流程)
  • 类加载器在进行类加载的时候,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成(调用父加载器 loadClass()方法来加载类)。加载请求会沿着父加载器链逐级向上委派,最终由 BootstrapClassLoader 尝试加载。
  • 只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载(调用自己的 findClass() 方法来加载类)
  • 如果子类加载器也无法加载这个类,那么它会抛出一个 ClassNotFoundException 异常。

3.2 为什么要使用双亲委派模型

原因:JDK 默认采用双亲委派模型,是为了防止核心类被篡改、保证类加载结果的唯一性,并使类加载行为具有确定性和安全性

在双亲委派模型下,类加载请求会优先委派给父类加载器处理,只有在父类加载器无法加载目标类时,子类加载器才会尝试自行加载。

  • 首先,双亲委派模型能够防止 Java 核心类被篡改。例如 java.lang.String 等核心类只能由引导类加载器(BootstrapClassLoader)加载,应用程序即使在自身类路径中提供同名类,也无法覆盖核心类,从而保证了 JVM 的基础安全。
  • 其次,该模型保证了类定义的唯一性。在 JVM 中,一个类由"类的全限定名 + 定义它的类加载器"共同确定。通过双亲委派机制,父加载器已经加载过的类会被子加载器直接复用,避免了同名类被多次加载而导致的类型不一致问题。
  • 最后,双亲委派模型使类加载行为具有确定性和可预测性,降低了 JVM 和类加载器实现的复杂度。统一的加载顺序有助于不同 JVM 实现之间保持一致行为,也是 Java 作为通用运行时平台的重要设计基础。

双亲委派不是 JVM 的强制机制,而是 ClassLoader.loadClass 的默认实现策略,只有在明确理解其风险的前提下才应被打破。

3.3 打破双亲委派模型

在我们常用的环境里,最常见的并且需要了解的就是Tomcat打破了双亲委派模型,在标准的 JVM 双亲委派模型中,类加载请求会优先委派给父类加载器处理,只有在父类加载器无法加载时,子类加载器才会尝试自行加载。然而,Tomcat 并未完全遵循这一默认模型,而是对其进行了有目的的调整。

3.3.1 Tomcat 是如何打破双亲委派模型的

Tomcat 为每一个 Web 应用创建独立的类加载器(WebAppClassLoader)。该类加载器在加载类时,并非严格遵循"先父后子"的顺序,而是采用一种受控的 child-first 策略:

  • 对于 Web 应用自身的类和第三方依赖(WEB-INF/classes、WEB-INF/lib),优先由 WebAppClassLoader 自行加载;
  • 对于 Java 核心类(如 java.*)和容器自身的关键类,仍然遵循父类加载器优先加载。

这种机制并非彻底放弃双亲委派,而是对加载顺序进行局部反转,即在特定范围内由子加载器优先加载类。

复制代码
                ┌──────────────────────┐
                │   BootstrapLoader    │
                │   (JVM 引导类加载器)   │
                └──────────────────────┘
                            ▲
                            │
                ┌──────────────────────┐
                │  PlatformClassLoader │
                │   (JDK 平台类加载器)   │
                └──────────────────────┘
                            ▲
                            │
                ┌──────────────────────┐
                │    AppClassLoader    │
                │   (应用类加载器)       │
                └──────────────────────┘
                            ▲
                            │
          ┌──────────────────────────────────┐
          │     CommonClassLoader            │
          │  (Tomcat 公共类加载器)             │
          └──────────────────────────────────┘
                ▲                    ▲
                │                    │
  ┌──────────────────────┐   ┌──────────────────────┐
  │ CatalinaClassLoader  │   │  SharedClassLoader   │
  │ (Tomcat 容器类)       │   │ (Web 应用共享类)       │
  └──────────────────────┘   └──────────────────────┘
                                           ▲
                                           │
                           ┌────────────────────────┐
                           │   WebAppClassLoader    │
                           │ (每个 Web 应用一个)      │
                           └────────────────────────┘
  • CommonClassLoader作为 CatalinaClassLoaderSharedClassLoader 的父加载器。CommonClassLoader 能加载的类都可以被 CatalinaClassLoaderSharedClassLoader 使用。因此,CommonClassLoader 是为了实现公共类库(可以被所有 Web 应用和 Tomcat 内部组件使用的类库)的共享和隔离。
  • CatalinaClassLoaderSharedClassLoader 能加载的类则与对方相互隔离。CatalinaClassLoader 用于加载 Tomcat 自身的类,为了隔离 Tomcat 本身的类和 Web 应用的类。
    SharedClassLoader 作为 WebAppClassLoader 的父加载器,专门来加载 Web 应用之间共享的类比如 Spring、Mybatis。
  • 每个 Web 应用都会创建一个单独的 WebAppClassLoader,并在启动 Web 应用的线程里设置线程线程上下文类加载器WebAppClassLoader。各个 WebAppClassLoader 实例之间相互隔离,进而实现 Web 应用之间的类隔。
    Tomcat 的 WebAppClassLoader 对 Web 应用自身的类采用 child-first 加载策略,即优先从 WEB-INF/classesWEB-INF/lib 中加载;而对 Java 核心类及容器相关类仍然遵循父类加载器优先的原则,从而在保证安全性的同时实现 Web 应用之间的类隔离

3.3.2 Tomcat 为什么必须打破双亲委派模型

  1. 支持多 Web 应用的类隔离
    Tomcat 的核心职责是在同一个 JVM 中同时运行多个 Web 应用。
    如果严格遵循双亲委派模型:所有 Web 应用的依赖类都会被父类加载器加载不同应用之间将共享同一份类定义一旦依赖版本不同,就会发生冲突
    通过 child-first 策略:每个 Web 应用可以加载自己的依赖版本,Web 应用之间实现真正的类隔离
  2. 避免第三方依赖版本冲突
    不同 Web 应用可能依赖同一个库的不同版本,例如:应用 A 依赖 spring-web 5.x、应用 B 依赖 spring-web 6.x,如果由父加载器统一加载:只能加载一个版本,另一个应用必然出错。Tomcat 通过优先加载应用自身依赖,解决了这一问题。
  3. 支持 Web 应用的热部署与热重载
    Tomcat 支持对单个 Web 应用进行重新部署或热重载:卸载旧的 WebAppClassLoader,创建新的类加载器并重新加载应用类。如果所有类都由父加载器加载:类将无法被卸载,会造成内存泄漏。打破双亲委派,使 Web 应用类只由子加载器加载,从而可被整体回收。

3.3.3 线程上下文类加载器

线程上下文类加载器(Thread Context ClassLoader,简称 TCCL)是 JVM 为了解决父加载器无法访问子加载器所加载类的问题,而引入的一种运行时类加载补充机制。它的本质是:把"类由谁来加载"的决定权,从"类调用方"转移到"线程的执行上下文"上。

在 Java 中,每个线程都维护了一个上下文类加载器:

java 复制代码
ClassLoader cl = Thread.currentThread().getContextClassLoader();

那么为啥突然说道这个线程上下文类加载器呢?原因就是:在双亲委派模型中:父类加载器 无法看到 子类加载器 加载的类 ,这是 JVM 的安全设计。

举例说明就是我们所用到的SPI,例如:jdk中的java.sql.Driver,我们链接mysql一般用com.mysql.cj.jdbc.Driver,但是com.mysql.cj.jdbc.Driver是由WebAppClassLoader / AppClassLoader加载的,java.sql.Driver是由BootstrapClassLoader加载的,在双亲委派模型下:父加载器无法访问子加载器加载的类,BootstrapClassLoader 不可能直接加载应用类,所以,如果 SPI 这样写:

java 复制代码
Class.forName("com.mysql.cj.jdbc.Driver");

就必然会报错了。

所以引入了线程上下文类加载器

在 Tomcat / Spring Boot 中,请求线程是由容器创建,容器会在进入应用逻辑前:Thread.currentThread().setContextClassLoader(webAppClassLoader);

SPI 并非天生依赖线程上下文类加载器,但在双亲委派模型下,父类加载器无法访问子类加载器定义的类,而 SPI 的实现类通常位于应用类加载器中,因此 SPI 在实现层面必须借助线程上下文类加载器完成扩展类的加载。SPI + TCCL,本质上是在不破坏双亲委派模型前提下,解决"框架调用应用实现"的问题。

相关推荐
SmoothSailingT2 小时前
C#——LINQ方法
开发语言·c#·linq
yaoxin5211232 小时前
274. Java Stream API - 过滤操作(filter):筛选你想要的数据
java·windows
小白勇闯网安圈2 小时前
Java面向对象(上)
java
k***92162 小时前
Python 科学计算有哪些提高运算速度的技巧
开发语言·python
superman超哥2 小时前
仓颉条件变量深度解析与实践:解锁高效并发同步
开发语言·python·c#·仓颉
一点晖光2 小时前
maven推送项目到harhor私有仓库
java·maven
代码or搬砖2 小时前
MySQL窗口函数 OVER()讲解
java·mysql
道法自然|~3 小时前
【PHP】简单的脚本/扫描器拦截与重要文件保护
开发语言·爬虫·php
GoWjw3 小时前
在C&C++中结构体的惯用方法
c语言·开发语言·c++