Skip to content

类加载器

类加载器负责把类的二进制字节流加载进 JVM,并决定这个类属于哪个命名空间。理解类加载器,是理解 Spring Boot 打包、插件化、热部署、依赖冲突和 ClassNotFoundException 的基础。

类与类加载器

判断类是否“相等”

任意一个类,都由加载它的类加载器和这个类本身一同确立其在 Java 虚拟机中的唯一性,每一个类加载器,都有一个独立的类名称空间。

因此,比较两个类是否“相等”,只有在这两个类是由同一个类加载器加载的前提下才有意义,否则,即使这两个类来源于同一个 Class 文件,被同一个虚拟机加载,只要加载它们的类加载器不同,那么这两个类就必定不相等。

这里的“相等”,包括代表类的 Class 对象的 equals() 方法、isInstance() 方法的返回结果,也包括使用 instanceof 关键字做对象所属关系判定等情况。

加载器种类

系统提供了 3 种类加载器:

  • 启动类加载器(Bootstrap ClassLoader): 负责将存放在 <JAVA_HOME>\lib 目录中的,并且能被虚拟机识别的(仅按照文件名识别,如 rt.jar,名字不符合的类库即使放在 lib 目录中也不会被加载)类库加载到虚拟机内存中。
  • 扩展类加载器(Extension ClassLoader): 负责加载 <JAVA_HOME>\lib\ext 目录中的所有类库,开发者可以直接使用扩展类加载器。
  • 应用程序类加载器(Application ClassLoader): 由于这个类加载器是 ClassLoader 中的 getSystemClassLoader() 方法的返回值,所以一般也称它为“系统类加载器”。它负责加载用户类路径(classpath)上所指定的类库,开发者可以直接使用这个类加载器,如果应用程序中没有自定义过自己的类加载器,一般情况下这个就是程序中默认的类加载器。

ClassLoader

当然,如果有必要,还可以加入自己定义的类加载器。

mermaid
flowchart TD
    A["Bootstrap ClassLoader<br/>JDK 核心类"] --> B["Extension / Platform ClassLoader<br/>扩展或平台类"]
    B --> C["Application ClassLoader<br/>classpath 应用类"]
    C --> D["Custom ClassLoader<br/>插件、热部署、容器隔离"]

双亲委派模型

什么是双亲委派模型

双亲委派模型是描述类加载器之间的层次关系。它要求除了顶层的启动类加载器外,其余的类加载器都应当有自己的父类加载器。(父子关系一般不会以继承的关系实现,而是以组合关系来复用父加载器的代码)

类加载器工作原理

工作过程

如果一个类加载器收到了类加载的请求,它首先不会自己去尝试加载这个类,而是把这个请求委派给父类加载器去完成,每一个层次的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中,只有当父加载器反馈自己无法完成这个加载请求(找不到所需的类)时,子加载器才会尝试自己去加载。

在 java.lang.ClassLoader 中的 loadClass 方法中实现该过程。

mermaid
flowchart TD
    A["收到类加载请求"] --> B{"父加载器是否存在"}
    B -->|是| C["委派给父加载器"]
    C --> D{"父加载器能否加载"}
    D -->|能| E["返回父加载器加载的 Class"]
    D -->|不能| F["当前加载器尝试加载"]
    B -->|否| F
    F --> G{"是否找到字节码"}
    G -->|是| H["定义 Class"]
    G -->|否| I["抛出 ClassNotFoundException"]

为什么使用双亲委派模型

像 java.lang.Object 这些存放在 rt.jar 中的类,无论使用哪个类加载器加载,最终都会委派给最顶端的启动类加载器加载,从而使得不同加载器加载的 Object 类都是同一个。

相反,如果没有使用双亲委派模型,由各个类加载器自行去加载的话,如果用户自己编写了一个称为 java.lang.Object 的类,并放在 classpath 下,那么系统将会出现多个不同的 Object 类,Java 类型体系中最基础的行为也就无法保证。

双亲委派怎么被打破

先说准确结论

所谓“打破双亲委派”,通常不是删除父加载器,也不是让所有类都由子加载器加载,而是针对某些类改变标准 loadClass 的顺序或可见性规则。

标准双亲委派顺序:

text
findLoadedClass
    ↓ 未加载
parent.loadClass
    ↓ 父加载器找不到
findClass

常见打破方式:

text
findLoadedClass
    ↓ 未加载
findClass             先尝试当前加载器
    ↓ 当前加载器找不到
parent.loadClass      再委派父加载器

或者不直接改变 loadClass,而是通过线程上下文类加载器,让父层定义的框架代码反过来使用子层加载器发现实现类。

因此,“打破”可以分为两类:

类型做法代表场景
子优先加载重写 loadClass,特定业务类先 findClassTomcat WebApp、插件隔离、热部署
反向委派父层代码主动使用线程上下文类加载器 TCCLJDBC、JNDI、SPI、框架扩展发现

标准 loadClass 源码做了什么

下面是 JDK 8 ClassLoader.loadClass 的核心结构,省略了非核心细节:

java
protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException {

    synchronized (getClassLoadingLock(name)) {
        Class<?> clazz = findLoadedClass(name);

        if (clazz == null) {
            try {
                if (parent != null) {
                    clazz = parent.loadClass(name, false);
                } else {
                    clazz = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException ignored) {
                // 父加载器找不到,当前加载器再尝试。
            }

            if (clazz == null) {
                clazz = findClass(name);
            }
        }

        if (resolve) {
            resolveClass(clazz);
        }

        return clazz;
    }
}

关键点:

  1. findLoadedClass 防止同一个加载器重复定义同名类。
  2. 标准实现先调用 parent.loadClass
  3. 只有父加载器抛出 ClassNotFoundException,当前加载器才调用 findClass
  4. loadClass 决定委派策略;findClass 通常只负责“当前加载器去哪里找字节码”。
  5. 自定义加载器只重写 findClass,但不重写 loadClass,通常没有打破双亲委派。
mermaid
flowchart TD
    A["调用loadClass"] --> B["findLoadedClass检查是否已加载"]
    B --> C{"是否已经加载"}
    C -- "是" --> D["直接返回Class"]
    C -- "否" --> E["父加载器loadClass"]
    E --> F{"父加载器是否找到"}
    F -- "找到" --> D
    F -- "找不到" --> G["当前加载器findClass"]
    G --> H{"是否找到字节码"}
    H -- "是" --> I["defineClass并返回"]
    H -- "否" --> J["抛ClassNotFoundException"]

方式一:重写 loadClass 实现子优先

下面是教学版子优先类加载器。它用于说明委派顺序,不建议把代码原样用于生产插件系统。

java
import java.net.URL;
import java.net.URLClassLoader;

public class ChildFirstClassLoader extends URLClassLoader {

    public ChildFirstClassLoader(URL[] urls, ClassLoader parent) {
        super(urls, parent);
    }

    @Override
    protected Class<?> loadClass(String name, boolean resolve)
            throws ClassNotFoundException {

        synchronized (getClassLoadingLock(name)) {
            Class<?> loaded = findLoadedClass(name);
            if (loaded != null) {
                return loaded;
            }

            // JVM核心类和父子之间共享的API必须继续父优先。
            if (mustDelegateToParent(name)) {
                return super.loadClass(name, resolve);
            }

            Class<?> clazz;
            try {
                // 顺序被改变:先从当前加载器自己的URL中找。
                clazz = findClass(name);
            } catch (ClassNotFoundException notFoundByChild) {
                // 当前加载器找不到,再交给父加载器。
                clazz = super.loadClass(name, false);
            }

            if (resolve) {
                resolveClass(clazz);
            }
            return clazz;
        }
    }

    private boolean mustDelegateToParent(String name) {
        return name.startsWith("java.")
                || name.startsWith("javax.")
                || name.startsWith("jdk.")
                || name.startsWith("sun.")
                || name.startsWith("com.example.plugin.api.");
    }
}

这段代码真正“打破”的位置是:

java
clazz = findClass(name);

它被放到了父加载器调用之前。但仍然保留了父优先白名单,因为如果 API 接口也由插件加载器重新加载,会出现“类名完全相同,却不能强制转换”的问题。

为什么核心类仍然不能随意子优先

假设插件包里放了一份 java.lang.String,并不意味着就能替换 JVM 的 String:

  • java.* 核心命名空间有 JVM 和安全限制;
  • Bootstrap ClassLoader 已经加载核心类型;
  • JVM 内部、标准库和对象布局都依赖同一套核心类定义;
  • 破坏核心类型唯一性会让 Java 类型系统失去基础。

所以生产级 Child First 一定是“选择性子优先”,不是所有包无条件子优先。

自己定义一个 java.lang.String 会怎么样

例如创建源文件:

java
package java.lang;

public class String {
    public String() {
        System.out.println("custom String");
    }
}

不能只回答“会被双亲委派交给 Bootstrap,所以自定义 String 无效”。完整结果要按阶段和 JDK 版本区分。

情况一:JDK 9+ 通常在编译阶段就被模块系统拒绝

JDK 9 引入模块系统后,java.lang 属于 java.base 模块。普通应用试图向这个包中定义类,javac 通常会报类似错误:

text
package exists in another module: java.base

也就是说,普通项目甚至无法正常生成这个 java.lang.String.class。这是模块边界和拆分包限制,不需要等到双亲委派运行阶段。

情况二:旧版JDK即使编译出Class,正常应用仍使用JDK String

在某些旧 JDK 8 工具链或特殊编译方式下,可能得到同名 class 文件。普通应用通过 Application ClassLoader 加载 java.lang.String 时,标准 loadClass 先委派父加载器:

text
ApplicationClassLoader请求java.lang.String

委派Platform/Extension层

最终委派Bootstrap ClassLoader

Bootstrap已经加载JDK自带String

直接返回JDK String.class

应用加载器不会再findClass自定义版本

可以验证:

java
public class StringLoaderDemo {
    public static void main(String[] args) {
        ClassLoader loader = java.lang.String.class.getClassLoader();
        System.out.println(loader);
        System.out.println(java.lang.String.class
                .getProtectionDomain()
                .getCodeSource());
    }
}

String.class.getClassLoader() 通常输出:

text
null

这里的 null 表示由 JVM 内部的 Bootstrap ClassLoader 加载,不是“String 没有类加载器”。

情况三:Child First强行定义也会被禁止

有人可能想到:既然父优先拿到 JDK String,那就写 Child First,直接读取自定义字节码并调用:

java
defineClass("java.lang.String", bytes, 0, bytes.length);

普通自定义 ClassLoader 通常会在定义 java.* 类时被 JVM/ClassLoader 安全检查拒绝,出现:

text
java.lang.SecurityException: Prohibited package name: java.lang

这是第二道保护。双亲委派保证标准加载顺序和核心类唯一性,禁止普通加载器定义 java.* 又进一步保护核心命名空间。

情况四:为什么不能假设能用它替换JDK String

String 在 JVM 中不是普通业务类:

  • 类加载、常量池、字符串常量和字节码描述都依赖它;
  • JVM 和 JDK 大量本地/内部代码假设它具有标准字段和行为;
  • 字符串字面量在应用主逻辑执行前就已经大量存在;
  • Bootstrap ClassLoader 极早加载核心类;
  • JDK 9+ 的 java.base 模块进一步封装核心包;
  • 普通自定义加载器禁止定义 java.*

因此,在现代普通 Java 应用中,通过 classpath 放一个 java.lang.String 不能替换 JDK String。

历史上的旧 JDK 曾存在修改启动类路径、修改运行时镜像或使用底层 Instrumentation 等特殊方式,但那不属于普通业务类加载,也不应作为“可以覆盖 String”的项目方案。强行替换核心类可能导致 JVM 无法启动、链接错误或不可预测行为。

如果只是在自己的包里定义 String

下面是允许的,因为全限定名不是 java.lang.String

java
package com.example.model;

public class String {
}

但在同一个源码中写:

java
package com.example.model;

public class Demo {
    private String value;
}

这里的 String 会优先解析为同包的:

text
com.example.model.String

而不是:

text
java.lang.String

若确实要使用 JDK String,需要写全限定名:

java
private java.lang.String value;

这种命名虽然能编译,但会让阅读、导入、泛型和反射代码非常混乱,生产项目不应把业务类命名为 StringObjectSystem 等核心类型名称。

这个问题与类唯一性的关系

普通业务包中的同名类,只要全限定名不同,就是完全不同的类:

text
java.lang.String
com.example.model.String

如果全限定名也相同,但定义加载器不同,JVM 类型身份理论上仍由:

text
全限定类名 + 定义类加载器

共同确定。不过 java.* 核心命名空间有额外禁止规则,普通自定义加载器不能拿这个理论去定义第二份 java.lang.String

面试标准回答

JDK 9+ 普通项目声明 package java.lang,通常编译阶段就会因为 java.lang 属于 java.base 模块而报 package exists in another module: java.base。在旧 JDK 或特殊方式下即使生成了自定义 java.lang.String.class,标准双亲委派也会先由 Bootstrap ClassLoader 返回 JDK 自带 String,应用加载器不会再加载自定义版本。如果重写 Child First 并直接 defineClass,普通自定义加载器又会因为禁止定义 java.* 包而抛 SecurityException: Prohibited package name: java.lang。所以普通应用不能通过 classpath 自定义 String 替换 JDK String。

为什么要打破:插件依赖隔离

假设主应用使用:

text
guava-20.jar

插件 A 使用:

text
guava-31.jar

如果完全父优先,插件 A 可能拿到主应用的 Guava 20,调用新版本方法时出现:

text
NoSuchMethodError

子优先可以让插件自己的实现依赖由插件加载器加载,同时将共享 API 委派父加载器:

mermaid
flowchart TD
    A["ApplicationClassLoader"] --> B["加载共享Plugin接口"]
    A --> C["PluginClassLoader A"]
    A --> D["PluginClassLoader B"]
    C --> E["插件A实现与Guava 31"]
    D --> F["插件B实现与其他依赖版本"]
    E --> G["实现对象可转换为父加载器的Plugin接口"]
    F --> G

共享接口必须由共同父加载器加载。如果插件包再加载一份同名接口:

text
主应用的 com.example.Plugin
    加载器 = AppClassLoader

插件中的 com.example.Plugin
    加载器 = PluginClassLoader

JVM 认为它们是两个不同类型,可能抛出:

text
ClassCastException: com.example.Plugin cannot be cast to com.example.Plugin

异常文本看起来类名相同,真正差异是定义它们的类加载器不同。

Tomcat怎么打破双亲委派

Tomcat 要同时解决:

  1. 不同 Web 应用可以使用不同版本的业务依赖;
  2. Java 核心类和 Servlet API 不能被每个 Web 应用随意覆盖;
  3. Tomcat 自身实现类不应暴露给 Web 应用替换;
  4. Web 应用应该能优先加载 WEB-INF/classesWEB-INF/lib 中的多数类。

可以简化理解为:WebAppClassLoader 对 Web 应用私有类采用 WebApp First/Child First,但对 Java、Servlet 等受保护 API 仍然 Parent First。

mermaid
flowchart TD
    A["Bootstrap"] --> B["System"]
    B --> C["Common ClassLoader"]
    C --> D["WebAppClassLoader 应用A"]
    C --> E["WebAppClassLoader 应用B"]
    D --> F["A的WEB-INF/classes与lib"]
    E --> G["B的WEB-INF/classes与lib"]

因此应用 A 和 B 可以使用不同版本的 Spring、日志库或业务依赖,但共享 Tomcat/JDK 级 API。实际规则受 Tomcat 版本和配置影响,不能简单回答“Tomcat 完全不使用双亲委派”。

方式二:线程上下文类加载器 TCCL

为什么需要反向委派

Bootstrap/Platform 层加载的标准 API 只看得到自己的父级和自身,正常情况下看不到 Application ClassLoader 中第三方实现类。

例如:

text
java.sql.Driver 接口
    由平台/启动层加载

MySQL Driver 实现
    在应用classpath,由ApplicationClassLoader加载

按严格父到子可见性,父层 API 无法直接发现子层实现。线程上下文类加载器允许框架代码获取当前线程指定的加载器:

java
ClassLoader contextLoader =
        Thread.currentThread().getContextClassLoader();

再用它加载实现或资源:

java
ServiceLoader<MyService> loader =
        ServiceLoader.load(MyService.class, contextLoader);

这形成了“父加载器定义接口,子加载器提供实现,父层代码借助 TCCL 加载子层实现”的反向路径。

mermaid
flowchart TD
    A["父层加载器加载SPI接口"] --> B["框架调用ServiceLoader"]
    B --> C["读取当前线程TCCL"]
    C --> D["TCCL查找META-INF/services"]
    D --> E["加载应用或插件中的实现类"]
    E --> F["实现类转换为父层共享接口"]

TCCL使用Demo

java
ClassLoader old = Thread.currentThread().getContextClassLoader();
ClassLoader pluginLoader = createPluginClassLoader();

try {
    Thread.currentThread().setContextClassLoader(pluginLoader);

    ServiceLoader<Plugin> plugins =
            ServiceLoader.load(Plugin.class, pluginLoader);

    for (Plugin plugin : plugins) {
        plugin.execute();
    }
} finally {
    Thread.currentThread().setContextClassLoader(old);
}

必须在 finally 中恢复。线程池线程会复用,如果不恢复 TCCL:

  • 后续任务可能错误加载上一个应用/插件的类;
  • 插件 ClassLoader 被线程长期引用,无法卸载;
  • Metaspace 和插件对象可能泄漏;
  • 热部署后旧版本仍然存活。

JDBC与SPI为什么常被说成打破双亲委派

JDBC 的 API 位于 JDK 平台层,驱动实现位于应用依赖。JDBC 4 之后驱动可以通过 META-INF/services/java.sql.Driver 被发现。其本质不是让 Bootstrap 直接看见所有子类,而是标准库/SPI 机制在合适位置借助调用方或线程上下文加载器发现应用层实现。

面试时不要只说:

JDBC 打破了双亲委派。

更准确的回答:

JDBC/SPI 需要父层定义的接口发现子层 classpath 中的实现,因此借助线程上下文类加载器或调用方加载器建立反向可见路径。这不是全局取消双亲委派,而是对服务发现加载器来源的特殊选择。

OSGi为什么比简单Child First更复杂

OSGi 不是一棵单纯父子树,而是 Bundle 按导入/导出包建立模块依赖图。一个类可能先从本 Bundle 查找,再按 Import-Package 委托指定 Bundle,最后按动态导入规则处理。

它打破的是“所有加载器都沿固定父链向上”的单一路径,换来模块版本并存、动态安装卸载能力,也增加了依赖解析、类可见性和诊断复杂度。

打破后会出现哪些问题

问题根因典型现象
类型不兼容同名类由不同加载器定义同名类之间 ClassCastException
LinkageError同一加载约束遇到不兼容定义loader constraint violation
NoSuchMethodError实际加载到错误依赖版本编译有方法,运行没有
Metaspace泄漏旧ClassLoader仍被线程/静态字段引用热部署多次后Metaspace上涨
SPI找不到实现TCCL错误或资源不可见ServiceLoader结果为空
安全边界破坏子优先覆盖不应覆盖的API容器/框架运行异常

怎么排查到底由谁加载

代码中打印:

java
Class<?> type = target.getClass();

System.out.println(type.getName());
System.out.println(type.getClassLoader());
System.out.println(type.getProtectionDomain()
        .getCodeSource()
        .getLocation());

System.out.println(Thread.currentThread()
        .getContextClassLoader());

JVM 参数:

JDK 8:

bash
-verbose:class

JDK 9+:

bash
-Xlog:class+load=info
-Xlog:class+unload=info

运行中可用:

bash
jcmd <pid> VM.classloader_stats
jcmd <pid> GC.class_histogram

Arthas:

bash
sc -d com.example.Plugin
classloader -t
classloader -l

排查依赖来源还要结合 Maven/Gradle 依赖树、Fat Jar 内容、Tomcat WEB-INF/lib 和容器共享 lib。

面试标准回答

双亲委派怎么被打破

双亲委派的标准逻辑在 ClassLoader.loadClass:先 findLoadedClass,再委派父加载器,父找不到才调用当前加载器的 findClass。打破通常有两种:第一,重写 loadClass,对业务或插件类先 findClass、失败再委派父加载器,Tomcat WebApp 和插件隔离属于选择性子优先;核心类与共享API仍保持父优先。第二,通过线程上下文类加载器,让父层定义的SPI/JDBC接口反向发现应用层实现。打破后要处理同名类不兼容、依赖版本冲突、ClassLoader/Metaspace泄漏和安全边界。

重写findClass算不算打破

通常不算。标准 loadClass 本来就在父加载器找不到后调用 findClass。只有改变了 loadClass 的父子查找顺序、绕过标准父链,或通过TCCL等机制建立反向加载路径,才通常称为打破或调整双亲委派。

为什么Tomcat不能完全父优先

如果完全父优先,多个Web应用会被迫使用容器或共同父加载器中的同一依赖版本,无法隔离各自 WEB-INF/lib。Tomcat对Web应用私有类采用选择性WebApp优先,对Java核心、Servlet API和容器关键类仍父优先,从而兼顾隔离和核心类型安全。

为什么打破后同名类不能强转

JVM 判断类型身份使用“类全限定名 + 定义它的类加载器”。两个加载器分别定义的 com.example.User 即使字节码完全相同,也是两个不同类型。因此共享接口必须由共同父加载器加载,插件只加载实现和私有依赖。

类加载器常见问题

问题原因排查方式
ClassNotFoundException运行时 classpath 找不到类检查依赖包、启动命令、打包结果
NoClassDefFoundError编译有类,运行缺失或初始化失败看完整异常链和依赖版本
ClassCastException 明明类名相同同名类由不同加载器加载打印 obj.getClass().getClassLoader()
依赖版本冲突多个 jar 含同名类用 Maven dependency tree 或查看 fat jar
热部署异常旧类加载器未释放检查线程、静态变量、缓存引用

代码 Demo:打印类加载器

下面程序可以直观看到不同类由哪个类加载器加载。

java
public class ClassLoaderDemo {
    public static void main(String[] args) {
        System.out.println(String.class.getClassLoader());
        System.out.println(ClassLoaderDemo.class.getClassLoader());
        System.out.println(ClassLoaderDemo.class.getClassLoader().getParent());
        System.out.println(ClassLoaderDemo.class.getClassLoader().getParent().getParent());
    }
}

运行:

bash
javac ClassLoaderDemo.java
java ClassLoaderDemo

String 由启动类加载器加载,打印结果通常是 null;业务类通常由应用类加载器加载。