10 类加载机制——双亲委派模型与打破它的场景

摘要:

Java 程序运行时,.class 文件是如何从磁盘(或网络、数据库)进入 JVM 内存并变成可用类型的?这个过程由类加载子系统完成,分为加载、验证、准备、解析、初始化五个阶段。整个过程的组织方式——“哪个类加载器负责加载哪个类”——由双亲委派模型(Parent Delegation Model) 规范。双亲委派保证了 Java 核心 API 的唯一性和安全性:java.lang.Object 永远只能由 Bootstrap ClassLoader 加载,不能被自定义类冒充。然而,现实工程中存在多个场景必须打破双亲委派:SPI(Service Provider Interface)的服务发现、Tomcat 的多 Web 应用隔离、OSGi 的模块化、以及 JDK 9 引入的模块系统(JPMS)。理解为什么要打破、如何打破、打破之后的影响,是理解 Java 复杂框架(Spring、Tomcat、Dubbo)类加载行为的基础,也是解决 ClassNotFoundExceptionNoClassDefFoundError、类型不一致(同名类的不同 Class 对象)等诡异问题的关键。


第 1 章 类加载的五个阶段

1.1 从 .class 文件到可用类型

01 JVM 全局架构——从 .java 到机器码的完整旅程 中我们简要介绍了类加载的五个阶段。本章将每个阶段深入展开,理解其工作内容和关键细节。

五个阶段的顺序

加载(Loading)→ 验证(Verification)→ 准备(Preparation)→ 解析(Resolution)→ 初始化(Initialization)

其中,验证、准备、解析三个阶段统称为连接(Linking)。加载和连接阶段在时间上可以交叉进行(加载开始后,字节流的部分内容可以边解析边验证),但五个阶段的开始顺序是固定的。

1.2 加载(Loading)

加载阶段做三件事

  1. 通过类的全限定名(如 com.example.HelloWorld)找到对应的二进制字节流(.class 文件、JAR 包内的条目、网络字节流、动态代理生成的字节码等)
  2. 将字节流所代表的静态存储结构转化为方法区(元空间)的运行时数据结构
  3. 中创建一个代表这个类的 java.lang.Class 对象(作为方法区中类型数据的访问入口)

注意:JVM 规范对”通过什么方式获取字节流”没有规定——这是类加载器的自由度所在。标准实现从文件系统读取,但也可以从数据库、加密文件、远程服务器获取,甚至在运行时动态生成(Spring AOP、CGLib 动态代理、java.lang.reflect.Proxy)。

1.3 验证(Verification)

验证阶段是连接的第一步,确保 .class 文件的字节流符合 JVM 规范,且不会危害 JVM 的安全

验证分为四个子阶段:

文件格式验证:检查魔数(0xCAFEBABE)、版本号、常量池中的常量类型是否合法等。这个阶段在字节流级别进行,还没有进入元空间。

元数据验证:对类的元数据做语义分析,例如:

  • 是否有父类(除 java.lang.Object 外所有类都应有父类)
  • 是否继承了 final 修饰的类(final 类不可继承)
  • 非抽象类是否实现了接口中所有的抽象方法

字节码验证:这是最复杂的一步,通过数据流分析确保字节码指令序列不会危害 JVM:

  • 操作数栈类型与字节码指令要求的类型兼容(如 iadd 要求操作数栈顶是两个 int
  • 跳转指令的目标地址合法(在方法体内,且指向有效的指令起始位置)
  • 局部变量在使用前已赋值

JDK 6+ 引入 StackMapTable 属性,将控制流的类型状态记录在 .class 文件中,字节码验证时直接读取而无需从头推导,将验证速度从 O(n²) 降低到 O(n)。

符号引用验证:将常量池中的符号引用(字符串形式的类名、方法名)与实际存在的类型做对照验证:

  • 符号引用中描述的类是否存在、是否可访问
  • 字段、方法在目标类中是否存在、是否有访问权限

设计哲学:验证是可选优化

对于被反复验证过的可信代码,可以通过 -Xverify:none(JDK 13+ 弃用)或 -noverify 禁用验证,加快类加载速度(有一定启动加速效果)。但对于来自不受信任来源的字节码(如用户上传的插件),验证是安全的最后一道防线,不能省略。

1.4 准备(Preparation)

准备阶段为类的静态变量(类变量) 分配内存,并设置初始零值

public class Example {
    public static int value = 100;    // 准备阶段:value = 0(零值),不是 100!
    public static final int CONST = 200;  // 编译期常量:准备阶段直接设为 200
}
  • static int value = 100:准备阶段值为 0100 要等到初始化阶段执行 <clinit> 时才赋上
  • static final int CONST = 200编译期常量(字面量常量,值在编译时确定),在准备阶段就直接设为 200,不需要等到初始化

注意:准备阶段只分配静态变量(类级别,存储在方法区),实例变量的分配发生在对象创建new 指令执行时,在堆中分配)。

1.5 解析(Resolution)

解析阶段将常量池中的符号引用(Symbolic Reference) 替换为直接引用(Direct Reference)

  • 符号引用:一组描述目标的符号(字符串),如 "java/lang/String""#5 Methodref java/io/PrintStream.println:(Ljava/lang/String;)V"
  • 直接引用:指向目标的指针、偏移量或句柄,可以直接定位到内存中的目标

解析的目标包括:类/接口、字段、方法(类方法和接口方法)。

解析可以延迟到使用时(懒解析):JVM 规范允许在第一次使用某个符号引用时才解析它,而不是在类加载完成时就全部解析。HotSpot 就是这样实现的——常量池中的引用一开始是符号引用,第一次被访问时才解析为直接引用。

1.6 初始化(Initialization)——执行用户代码

初始化阶段才是真正执行 Java 代码的开始。JVM 执行类的 <clinit>() 方法(由 javac 合并所有静态变量赋值语句和静态初始化块生成):

public class Example {
    static int a = 10;           // ① 静态变量赋值
    static {
        System.out.println("静态块 1:a = " + a);  // ② 静态块
        b = 20;                  // ③ 可以赋值(虽然声明在后面)
    }
    static int b;                // 声明在静态块后,但在准备阶段已分配
    static {
        System.out.println("静态块 2:b = " + b);  // ④ 静态块
    }
}
// <clinit> 按源码顺序执行:① → ② → ③ → ④
// 输出:静态块 1:a = 10
//       静态块 2:b = 20

<clinit> 的特性

  • 由 JVM 保证多线程安全:多个线程同时触发同一个类的初始化,只有一个线程执行 <clinit>,其他线程阻塞等待
  • 如果父类的 <clinit> 未执行,JVM 先执行父类的 <clinit>
  • 接口不需要先执行父接口的 <clinit>(接口的 <clinit> 只在接口中的常量被使用时才触发)
  • 如果类没有静态变量赋值和静态块,javac 不会生成 <clinit> 方法

触发初始化的六种时机(主动引用,JVM 规范明确规定):

  1. new 指令创建类的实例
  2. 读/写类的静态字段(非编译期常量)
  3. 调用类的静态方法
  4. 对类使用反射(Class.forName("...")
  5. 初始化子类时,其父类尚未初始化
  6. JVM 启动时指定的主类(含 main 方法的类)

第 2 章 类加载器——谁来做加载工作

2.1 类加载器的本质

类加载器(ClassLoader) 是实现”通过类的全限定名获取其字节码二进制流”这一动作的组件。

JVM 规范规定了类加载器的行为,但不规定有哪些类加载器——每个 JVM 实现可以有自己的类加载器体系。HotSpot JVM 提供三层内置类加载器:

Bootstrap ClassLoader(启动类加载器)

  • C++ 实现,是 JVM 的一部分,不是 Java 类(String.class.getClassLoader() 返回 null,因为它没有 Java 层面的对象)
  • 加载 Java 的核心类库:JAVA_HOME/lib/ 下的 rt.jar(JDK 8)或 JDK 9+ 的 java.base 模块
  • 只加载被 JVM 信任的类(路径白名单,不能随意加进来)

Extension/Platform ClassLoader(扩展/平台类加载器)

  • JDK 8:sun.misc.Launcher$ExtClassLoader,加载 JAVA_HOME/lib/ext/ 下的扩展类库
  • JDK 9+:改名为 Platform ClassLoader,加载 Java SE 平台的非核心模块
  • 父加载器:Bootstrap ClassLoader

Application ClassLoader(应用程序类加载器)

  • sun.misc.Launcher$AppClassLoader(JDK 8)
  • 加载用户的 classpath(-cp / -classpath)上的类——也就是你写的应用代码
  • 父加载器:Extension/Platform ClassLoader
  • 通常是 Java 程序的默认类加载器ClassLoader.getSystemClassLoader() 返回它)

2.2 类的唯一性由类加载器决定

在 JVM 中,判断两个类是否”相同”,不只看类的全限定名,还要看加载它的类加载器是否相同

// 同一个 .class 文件,被两个不同的类加载器加载,得到的是两个不同的类!
ClassLoader cl1 = new URLClassLoader(new URL[]{classPath});
ClassLoader cl2 = new URLClassLoader(new URL[]{classPath});
 
Class<?> class1 = cl1.loadClass("com.example.Foo");
Class<?> class2 = cl2.loadClass("com.example.Foo");
 
System.out.println(class1 == class2);           // false!两个不同的 Class 对象
System.out.println(class1.equals(class2));       // false!
System.out.println(class1.getName().equals(class2.getName())); // true:名字相同
 
// 类型转换会失败!
Object obj1 = class1.newInstance();
class2.cast(obj1);  // ClassCastException!虽然名字相同,但是不同的"类型"

这个特性是理解 Tomcat 的 Web 应用隔离OSGi 模块化的基础:不同的 Web 应用使用不同的 ClassLoader,即使加载了相同名字的类(比如都依赖 log4j),它们也是相互隔离的不同类型。


第 3 章 双亲委派模型——设计理念与实现

3.1 双亲委派的工作流程

双亲委派模型(Parent Delegation Model) 规定了类加载器的工作方式:任何一个类加载器收到类加载请求时,首先将请求委派给父加载器,父加载器再委派给它的父加载器,一直到最顶层的 Bootstrap ClassLoader;只有当父加载器无法加载(找不到对应的类)时,子加载器才自己尝试加载

Bootstrap ClassLoader(顶层,C++ 实现,负责加载 java.*)
    ↑ 委派(向上)
Extension/Platform ClassLoader
    ↑ 委派(向上)
Application ClassLoader
    ↑ 委派(向上)
自定义 ClassLoader A
    ↑ 委派(向上)
自定义 ClassLoader B(收到加载请求)

流程:B 收到加载 com.example.Foo 的请求 → 委派给 A → A 委派给 Application → Application 委派给 Extension → Extension 委派给 Bootstrap → Bootstrap 找不到 → Extension 找不到 → Application 找不到 → A 找不到 → B 自己加载(在 classpath 或自定义路径找到)。

3.2 双亲委派的源码实现

双亲委派的核心逻辑在 java.lang.ClassLoader.loadClass() 方法中(JDK 8):

// java.lang.ClassLoader.loadClass() 的关键逻辑(简化)
protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    synchronized (getClassLoadingLock(name)) {
        // 1. 首先检查该类是否已经被加载过(缓存)
        Class<?> c = findLoadedClass(name);
        
        if (c == null) {
            try {
                // 2. 父加载器不为 null,委派给父加载器
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    // 3. 父加载器为 null,说明父加载器是 Bootstrap ClassLoader
                    //    委派给 Bootstrap 加载
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // 父加载器抛出 ClassNotFoundException,表示找不到,不是错误
                // 继续往下,让自己尝试
            }
            
            if (c == null) {
                // 4. 父加载器们都找不到,自己尝试加载
                c = findClass(name);  // 子类应该 override 这个方法,而不是 loadClass
            }
        }
        
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

关键设计:子类加载器应该覆盖(override)findClass(),而不是 loadClass()loadClass() 中内置了双亲委派逻辑,如果覆盖 loadClass(),双亲委派就被破坏了。

3.3 双亲委派保护了什么

防止核心类被篡改:如果用户自定义了一个 java.lang.Object 类,在双亲委派下,加载请求会一直委派到 Bootstrap ClassLoader,Bootstrap 从 rt.jar 中找到官方的 java.lang.Object 并加载,用户的自定义版本永远不会被加载。Java 核心 API 的”纯洁性”得到了保障。

保证同一个类在 JVM 中的唯一性:任何类最终都由能加载它的最顶层加载器加载,不会出现同一个 java.lang.String 被不同加载器分别加载出两个不同 Class 对象的情况(在标准类库范围内)。


第 4 章 打破双亲委派的场景

双亲委派是一种”建议”而非”强制”——只要覆盖 loadClass() 方法,任何加载器都可以打破它。现实中有几个经典场景,必须打破双亲委派才能正常工作。

4.1 场景一:SPI 机制——父加载器调用子加载器加载

问题背景:Java 的 SPI(Service Provider Interface,服务提供者接口)机制允许第三方提供接口实现,典型例子是 JDBC。

java.sql.Driver 接口定义在 rt.jar 中,由 Bootstrap ClassLoader 加载。但 JDBC 驱动的实现(如 com.mysql.cj.jdbc.Driver)在用户的 classpath 上,需要由 Application ClassLoader 加载。

问题来了:DriverManager(在 rt.jar 中,由 Bootstrap ClassLoader 加载)需要通过 ServiceLoader 发现并加载 classpath 上的 JDBC 驱动实现。但 Bootstrap ClassLoader 只能加载 rt.jar 中的类,无法找到 MySQL Driver!

这就是”父类加载器需要加载由子类加载器负责的类”的矛盾——正面违反了双亲委派的方向。

解决方案:线程上下文类加载器(Thread Context ClassLoader)

JDK 1.2 引入了一个 workaround:Thread 类中有一个 contextClassLoader 字段,允许线程携带一个类加载器(通常是 Application ClassLoader)。当父类加载器(Bootstrap)需要加载子类加载器才能找到的类时,可以通过 Thread.currentThread().getContextClassLoader() 获取应用类加载器,委托给它加载:

// JDK 中 ServiceLoader 的简化逻辑
public static <S> ServiceLoader<S> load(Class<S> service) {
    // 不用当前类的加载器(Bootstrap),改用线程上下文类加载器
    ClassLoader cl = Thread.currentThread().getContextClassLoader();
    return new ServiceLoader<>(service, cl);
}

这是一种”逆向委派”——由父加载器主动调用子加载器,双亲委派的单向性被打破。

设计哲学:线程上下文类加载器是一个"补丁"

线程上下文类加载器的设计被 Java 核心工程师 Joshua Bloch 描述为”一种破坏双亲委派优雅性的糟糕技巧”( hack),但它是在不修改 ClassLoader 继承体系的前提下解决 SPI 问题的唯一实用方案。这是典型的”工程中不得不做的妥协”。

4.2 场景二:Tomcat——Web 应用的类隔离

问题背景:Tomcat 是一个多 Web 应用容器,可以同时运行多个 Web 应用(war 包)。每个 Web 应用可能:

  • 依赖同一个类库的不同版本(A 应用用 log4j 1.2,B 应用用 log4j 2.x
  • 有同名的类(A 和 B 都有 com.example.util.StringUtils,但实现不同)

这两个需求要求不同 Web 应用的类在 JVM 中必须是隔离的——标准双亲委派下,同名类只能加载一次,无法满足隔离需求。

Tomcat 的类加载器体系

Bootstrap ClassLoader(加载 JDK 核心类)
    ↑
Extension ClassLoader
    ↑
Application ClassLoader
    ↑
Tomcat Common ClassLoader(加载 Tomcat 自身类库,如 catalina.jar)
    ↑                    ↑
Tomcat Catalina CL   Tomcat Shared CL(多 Web 应用共享的类库)
                         ↑              ↑
             WebApp ClassLoader A    WebApp ClassLoader B

WebApp ClassLoader 的特殊规则(打破双亲委派):

对于加载请求,WebApp ClassLoader 不是先委派给父加载器,而是先尝试自己加载(在自己的 /WEB-INF/classes//WEB-INF/lib/ 中查找):

  • 找到了:使用自己加载的版本(Web 应用私有类)
  • 找不到:再委派给父加载器(Common ClassLoader)
  • 父也找不到:才向上到 Bootstrap

例外:java.* 开头的核心类仍然委派给 Bootstrap(安全边界),Tomcat 自身类库(org.apache.catalina.* 等)委派给 Common ClassLoader。

这样,A 应用的 /WEB-INF/lib/log4j-1.2.jar 和 B 应用的 /WEB-INF/lib/log4j-2.x.jar 分别由各自的 WebApp ClassLoader 加载,相互隔离,互不干扰。

4.3 场景三:OSGi——模块化的网状委派

OSGi(Open Service Gateway Initiative) 是 Java 世界最完整的模块化规范(Eclipse IDE 的基础),每个模块(Bundle)有独立的类加载器,Bundle 之间的类共享通过显式声明的依赖关系控制(Import-PackageExport-Package)。

OSGi 的类加载顺序完全不是树状的双亲委派,而是一个网状结构

  1. 检查是否是 java.* 包(委派给 Bootstrap)
  2. 检查是否在 OSGi Framework 的委派列表中(org.osgi.* 等)
  3. 检查是否在本 Bundle 的 Import-Package 声明的包中(委派给提供该包的 Bundle 的类加载器)
  4. 检查本 Bundle 自身的类路径(Bundle-ClassPath

这是一个完全打破双亲委派树状结构的网状委派模型。OSGi 的强大之处在于可以在运行时动态安装/卸载 Bundle(热部署),因为每个 Bundle 有独立的类加载器,卸载 Bundle 时只需丢弃其类加载器即可触发类的卸载(GC 回收)。

4.4 场景四:热部署与动态类替换

热部署(在不重启 JVM 的情况下替换已加载的类的实现)需要打破双亲委派:因为同一个类加载器加载过的类会被缓存(findLoadedClass 的结果),不可能再次加载同名类的新版本。

热部署的实现原理:

  1. 丢弃旧类加载器:让旧的类加载器不再被引用,等待 GC 回收(此时旧加载器加载的所有类也会被卸载)
  2. 创建新类加载器:用新的类加载器加载新版本的类
  3. 更新引用:让应用的其他部分使用新类加载器加载的新类

Spring Boot DevTools 的热重启就是这个原理(实际上是重启应用上下文,重新创建类加载器)。真正的字节码热替换(不重启任何东西)需要 Java Instrumentation API + JVM TI,更为复杂。


第 5 章 JDK 9 模块系统(JPMS)——类加载的重大变革

5.1 模块化之前的问题

JDK 9 之前,整个 JDK 是一个单一的 rt.jar(几十 MB),应用即使只用了 StringArrayList,也必须加载整个 JDK 核心库。这带来了两个问题:

  • 启动缓慢:Bootstrap ClassLoader 需要扫描整个 rt.jar
  • 封装性差:JDK 内部实现类(如 sun.misc.Unsafe)对用户代码可见,内部 API 被大量滥用,JDK 团队无法随意修改内部实现

5.2 模块系统的核心概念

JDK 9 引入 JPMS(Java Platform Module System,Project Jigsaw),将 JDK 拆分为 80+ 个模块(java.basejava.sqljava.xml 等),每个模块精确声明:

  • exports:对外公开哪些包
  • requires:依赖哪些其他模块
// module-info.java(模块描述文件)
module com.example.myapp {
    requires java.sql;              // 依赖 java.sql 模块
    requires java.logging;          // 依赖 java.logging 模块
    exports com.example.api;        // 对外公开 com.example.api 包
    // com.example.internal 包不导出,完全封装
}

5.3 JPMS 对类加载器体系的影响

Extension ClassLoader → Platform ClassLoader

  • JDK 9 中 Extension ClassLoader 改名为 Platform ClassLoader(jdk.internal.loader.ClassLoaders$PlatformClassLoader
  • 不再从 ext/ 目录加载,改为负责加载 Java SE 平台的非 java.base 模块

模块化后的加载器分工

  • Bootstrap ClassLoader:负责 java.base 等核心模块(java.*, javax.*, jdk.internal.*
  • Platform ClassLoader:负责其他 Java SE 平台模块(java.sql, java.xml 等)
  • Application ClassLoader:负责应用模块(--module-path 上的模块)

模块系统强化了封装性:JDK 内部包(sun.misc.Unsafe, sun.nio.* 等)默认不再对用户代码可见,强行访问会报 InaccessibleObjectException。需要在启动参数中显式允许:--add-opens java.base/sun.misc=ALL-UNNAMED(不推荐,是临时兼容方案)。


第 6 章 类卸载——类的生命周期终点

6.1 类什么时候会被卸载

类加载进 JVM 后,并不是永久存在的。当满足以下所有条件时,类可以被 GC 卸载:

  1. 该类的所有实例都已经被 GC 回收(堆中没有任何该类的对象)
  2. 该类的 Class 对象不再被任何引用持有(无法通过反射等方式访问该类)
  3. 加载该类的 ClassLoader 对象已经被 GC 回收(这是最关键的条件)

第三个条件解释了为什么 Bootstrap/Extension/Application 三层内置加载器加载的类几乎永远不会被卸载——这三个加载器对象始终被 JVM 持有强引用,永远不会被 GC 回收,因此它们加载的类也不会被卸载。

自定义加载器加载的类可以被卸载:当 Tomcat 热部署一个 Web 应用时,旧的 WebApp ClassLoader 失去所有引用后被 GC 回收,它加载的所有类随之卸载,元空间空间得以释放。这也是大量热部署/热重载场景中元空间增长的原因——如果旧的 ClassLoader 没有被正确释放(存在循环引用或 ClassLoader 泄漏),类就无法卸载,元空间持续增长直至 OOM。

6.2 类加载器泄漏

类加载器泄漏是 Java 应用中最隐蔽的内存问题之一:

// 典型的类加载器泄漏:
// 应用类(由 WebApp ClassLoader 加载)注册到了全局单例(由 App ClassLoader 加载)中
// 导致 WebApp ClassLoader 被全局单例间接引用,无法被 GC
ThreadLocal<MyClass> tl = new ThreadLocal<>();  // MyClass 由 WebApp CL 加载
// 如果线程池的线程(由 App CL 管理)持有这个 ThreadLocal 值,
// ThreadLocalMap → MyClass 实例 → MyClass.class → WebApp ClassLoader
// → 整个 WebApp 的所有类都无法被卸载!

常见的类加载器泄漏模式:


第 7 章 总结

类加载机制是 JVM 实现 Java”一次编写、到处运行”的关键子系统之一:

五个阶段:加载(获取字节流)→ 验证(安全检查)→ 准备(分配内存,零值初始化静态变量)→ 解析(符号引用 → 直接引用)→ 初始化(执行 <clinit>,运行用户代码)。

双亲委派模型:加载请求先向上委派,保证 Java 核心类的唯一性和安全性。实现在 ClassLoader.loadClass() 中,子类应 override findClass() 而非 loadClass()

打破双亲委派的四种场景

  • SPI(JDBC):线程上下文类加载器,父加载器主动调用子加载器(逆向委派)
  • Tomcat:每个 Web 应用独立的 ClassLoader,自己先找,实现应用隔离
  • OSGi:网状委派,Bundle 间通过 Import-Package 声明精确共享类
  • 热部署:丢弃旧 ClassLoader,创建新 ClassLoader 加载新版本类

JPMS(JDK 9+):模块系统重构了类加载器体系,从根本上解决了 JDK 内部 API 的封装性问题,但也带来了兼容性挑战(--add-opens 的泛滥是过渡期的代价)。

类卸载:只有自定义 ClassLoader 加载的类才可能被卸载,条件是 ClassLoader 对象被 GC 回收。ClassLoader 泄漏会导致元空间持续增长,是热部署场景最常见的内存问题。

下一篇 11 字节码指令集与执行引擎 将进入执行引擎领域,剖析 JVM 基于栈的执行模型、Class 文件的字节码指令集,以及解释器如何将字节码翻译为 CPU 可执行的指令。


参考文献

  1. Tim Lindholm et al., “The Java Virtual Machine Specification, Java SE 21 Edition”, Chapter 5: Loading, Linking, and Initializing
  2. 周志明, 《深入理解 Java 虚拟机(第三版)》, 第 7 章:虚拟机类加载机制
  3. Joshua Bloch, “Effective Java”, 3rd Edition, Item 79(关于线程上下文类加载器的评论)
  4. OSGi Alliance, “OSGi Core Specification R7”, osgi.org
  5. JEP 261: Module System (JDK 9), openjdk.org
  6. Mark Reinhold, “Project Jigsaw: Late for the train”, 2017, InfoQ

思考题

  1. 双亲委派模型要求子类加载器先委托父类加载器加载。但 JDBC 的 DriverManager(由 Bootstrap ClassLoader 加载)需要加载第三方驱动(由 Application ClassLoader 加载)——这打破了双亲委派。Thread.currentThread().getContextClassLoader() 是如何解决这个矛盾的?SPI 机制的本质是什么?
  2. Tomcat 为每个 Web 应用创建独立的 WebappClassLoader,加载同一个 Servlet API 的不同版本。这意味着同一个 com.example.UserService 类在两个不同的 WebappClassLoader 中是两个不同的类——instanceof 会返回 false。这种’类隔离’是如何实现的?当两个 Web 应用需要共享一个单例对象时会遇到什么问题?
  3. OSGi、Java 9 Module System 和 Spring Boot 的 Fat JAR 都涉及自定义类加载器。在热部署(Hot Swap)场景中,旧的类加载器需要被 GC 回收以释放 Metaspace。但如果旧类加载器加载的类的静态变量持有对其他对象的引用,这个类加载器能被回收吗?Metaspace 泄漏最常见的原因是什么?