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)类加载行为的基础,也是解决 ClassNotFoundException、NoClassDefFoundError、类型不一致(同名类的不同 Class 对象)等诡异问题的关键。
第 1 章 类加载的五个阶段
1.1 从 .class 文件到可用类型
在 01 JVM 全局架构——从 .java 到机器码的完整旅程 中我们简要介绍了类加载的五个阶段。本章将每个阶段深入展开,理解其工作内容和关键细节。
五个阶段的顺序:
加载(Loading)→ 验证(Verification)→ 准备(Preparation)→ 解析(Resolution)→ 初始化(Initialization)
其中,验证、准备、解析三个阶段统称为连接(Linking)。加载和连接阶段在时间上可以交叉进行(加载开始后,字节流的部分内容可以边解析边验证),但五个阶段的开始顺序是固定的。
1.2 加载(Loading)
加载阶段做三件事:
- 通过类的全限定名(如
com.example.HelloWorld)找到对应的二进制字节流(.class文件、JAR 包内的条目、网络字节流、动态代理生成的字节码等) - 将字节流所代表的静态存储结构转化为方法区(元空间)的运行时数据结构
- 在堆中创建一个代表这个类的
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:准备阶段值为0,100要等到初始化阶段执行<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 规范明确规定):
new指令创建类的实例- 读/写类的静态字段(非编译期常量)
- 调用类的静态方法
- 对类使用反射(
Class.forName("...")) - 初始化子类时,其父类尚未初始化
- 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-Package 和 Export-Package)。
OSGi 的类加载顺序完全不是树状的双亲委派,而是一个网状结构:
- 检查是否是
java.*包(委派给 Bootstrap) - 检查是否在 OSGi Framework 的委派列表中(
org.osgi.*等) - 检查是否在本 Bundle 的
Import-Package声明的包中(委派给提供该包的 Bundle 的类加载器) - 检查本 Bundle 自身的类路径(
Bundle-ClassPath)
这是一个完全打破双亲委派树状结构的网状委派模型。OSGi 的强大之处在于可以在运行时动态安装/卸载 Bundle(热部署),因为每个 Bundle 有独立的类加载器,卸载 Bundle 时只需丢弃其类加载器即可触发类的卸载(GC 回收)。
4.4 场景四:热部署与动态类替换
热部署(在不重启 JVM 的情况下替换已加载的类的实现)需要打破双亲委派:因为同一个类加载器加载过的类会被缓存(findLoadedClass 的结果),不可能再次加载同名类的新版本。
热部署的实现原理:
- 丢弃旧类加载器:让旧的类加载器不再被引用,等待 GC 回收(此时旧加载器加载的所有类也会被卸载)
- 创建新类加载器:用新的类加载器加载新版本的类
- 更新引用:让应用的其他部分使用新类加载器加载的新类
Spring Boot DevTools 的热重启就是这个原理(实际上是重启应用上下文,重新创建类加载器)。真正的字节码热替换(不重启任何东西)需要 Java Instrumentation API + JVM TI,更为复杂。
第 5 章 JDK 9 模块系统(JPMS)——类加载的重大变革
5.1 模块化之前的问题
JDK 9 之前,整个 JDK 是一个单一的 rt.jar(几十 MB),应用即使只用了 String 和 ArrayList,也必须加载整个 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.base、java.sql、java.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 卸载:
- 该类的所有实例都已经被 GC 回收(堆中没有任何该类的对象)
- 该类的
Class对象不再被任何引用持有(无法通过反射等方式访问该类) - 加载该类的 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 的所有类都无法被卸载!常见的类加载器泄漏模式:
- 由 Web 应用类加载器加载的类注册到了全局单例(如
java.sql.DriverManager) - Web 应用的
ThreadLocal变量在线程池线程中未被清除(参见 15 ThreadLocal 的实现原理与内存泄漏——线程封闭的正确姿势) - log4j/logback 的 MDC 未清除
第 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 可执行的指令。
参考文献
- Tim Lindholm et al., “The Java Virtual Machine Specification, Java SE 21 Edition”, Chapter 5: Loading, Linking, and Initializing
- 周志明, 《深入理解 Java 虚拟机(第三版)》, 第 7 章:虚拟机类加载机制
- Joshua Bloch, “Effective Java”, 3rd Edition, Item 79(关于线程上下文类加载器的评论)
- OSGi Alliance, “OSGi Core Specification R7”, osgi.org
- JEP 261: Module System (JDK 9), openjdk.org
- Mark Reinhold, “Project Jigsaw: Late for the train”, 2017, InfoQ
思考题
- 双亲委派模型要求子类加载器先委托父类加载器加载。但 JDBC 的
DriverManager(由 Bootstrap ClassLoader 加载)需要加载第三方驱动(由 Application ClassLoader 加载)——这打破了双亲委派。Thread.currentThread().getContextClassLoader()是如何解决这个矛盾的?SPI 机制的本质是什么?- Tomcat 为每个 Web 应用创建独立的
WebappClassLoader,加载同一个 Servlet API 的不同版本。这意味着同一个com.example.UserService类在两个不同的 WebappClassLoader 中是两个不同的类——instanceof会返回false。这种’类隔离’是如何实现的?当两个 Web 应用需要共享一个单例对象时会遇到什么问题?- OSGi、Java 9 Module System 和 Spring Boot 的 Fat JAR 都涉及自定义类加载器。在热部署(Hot Swap)场景中,旧的类加载器需要被 GC 回收以释放 Metaspace。但如果旧类加载器加载的类的静态变量持有对其他对象的引用,这个类加载器能被回收吗?Metaspace 泄漏最常见的原因是什么?