1. 项目背景
业务场景:某支付系统在引入一个新的风控 SDK(.jar 包)后,应用启动即报 ClassCastException: com.alibaba.fastjson.JSONObject cannot be cast to com.alibaba.fastjson.JSONObject。运维和开发足足排查了 2 小时——同一个类名,两个包,抛出的异常信息竟然说"不能把 A 转成 A"。更诡异的是,SDK 的 jar 里明确包含了 fastjson,而主应用也依赖了同一个版本的同名 jar,怎么会冲突?
痛点:
- 类命名空间隔离的盲区:大多数开发以为 "classpath 上只有一个 jar 就不会冲突"。实际上,同一个类被不同的 ClassLoader 加载后,JVM 会认为它们是两个不同的类型。
instanceof判断、ClassCastException转换、静态变量——全部"分裂"成两套。 - 类加载顺序不可控:Maven/Gradle 依赖树中有多个同名类时,谁先被加载取决于 classpath 顺序或模块路径优先级,而这个顺序在不同环境(本地 IDE / CI / 容器)可能不同。
- 框架"打破"双亲委派的隐蔽性:Java 的 SPI(Service Provider Interface)、OSGi 模块化、Tomcat WebApp ClassLoader、Spring Boot DevTools——这些框架为实现热加载或隔离,故意打破了双亲委派模型。如果不理解"为什么打破"和"怎么打破",线上问题将完全无从下手。
本章从 ClassLoader 的父委托机制出发,深入双亲委派模型的设计原理、打破场景与风险,最后通过一个"同名类冲突"实战让读者亲手制造并解决这个经典问题。
2. 项目设计
(小胖的电脑屏幕上赫然显示着那个诡异的 ClassCastException。)
小胖:大师,这错误信息是耍我吗?!com.alibaba.fastjson.JSONObject cannot be cast to com.alibaba.fastjson.JSONObject——这不就是自己不能转自己吗?又不是用 A 转 B,凭啥报错?
大师(扫了一眼堆栈):这不是耍你。类在 JVM 中的"身份"由两个维度决定——全限定类名 + 加载它的 ClassLoader。同一个 JSONObject.class 文件,被你应用的主 ClassLoader(Application ClassLoader)加载了一个,又被风控 SDK 的自定义 ClassLoader 加载了另一个——JVM 眼里这就是两个完全不同的类型,跟你写的 String 和 Integer 一样互不兼容。
技术映射:ClassLoader + 类名 ↔ "上海的王伟"和"北京的王伟"——同名同姓,但身份证号码不同,在户籍系统里是完全不同的两个人。
小白:等等,我有个更基础的问题——类加载到底分几个步骤?我总听人说"加载""链接""初始化",它们是串行的还是并发的?
大师(拿起白板笔):好,这正是理解一切的起点。类的生命周期分为 7 个阶段,其中"加载""链接""初始化"是核心三部曲:
加载 (Loading)
↓
链接 (Linking)
├── 验证 (Verification):检查 class 文件格式合法性
├── 准备 (Preparation):为静态变量分配内存并赋默认值(不是代码里的初始值)
└── 解析 (Resolution):将符号引用替换为直接引用
↓
初始化 (Initialization):执行静态变量赋值和静态代码块
↓
使用 (Using)
↓
卸载 (Unloading):类被 GC 回收(条件苛刻:ClassLoader 实例被回收 + 类的所有实例被回收)
关键陷阱在"准备"阶段:public static int value = 123; 在准备阶段 value 的值是 0(默认值),直到初始化阶段才被赋值为 123。
小胖:不对啊!那 static final 的常量呢?比如 public static final int MAX = 100;——如果"准备"阶段给默认值 0,那后面才赋值 100,中间不会有不一致吗?
大师(赞许地点头):你这个追问很到位。static final 修饰的字段如果是编译时已知的常量表达式(如字面量、常量运算),编译器会把它写入常量池的 ConstantValue 属性,在准备阶段直接赋值为 100,不需要等初始化。这就是为什么"常量不依赖类初始化"——访问 MAX 不会触发类的初始化。
技术映射:static final 编译时常量 ↔ 食材包装袋上印好的保质期(生产时就确定了,不用等拆袋);普通 static ↔ 需要开袋后现场称重的食材(必须等初始化阶段才确定值)。
小白:那"双亲委派"到底是什么意思?为什么叫"双亲"而不叫"单亲"?
大师:翻译问题,"双亲"其实是"parents"的直译——每个 ClassLoader 可以有多个"父"加载器吗?不,其实只有一个。但因为存在一个"父委托链"(Bootstrap → Platform → Application → Custom),所以中文习惯叫"双亲委派"。
工作流程如下:
1. 类加载请求到达一个 ClassLoader
2. 它先检查自己是否已经加载过该类 → 有则返回
3. 没加载过 → 委托给父 ClassLoader
4. 父 ClassLoader 重复步骤 2-3
5. 若所有父加载器都找不到 → 自己尝试加载(findClass)
Bootstrap ClassLoader(启动类加载器)是这条链的顶端——它由 C++ 实现(src/hotspot/share/classfile/classLoader.cpp),负责加载 <JAVA_HOME>/lib 下的核心类(java.lang.*、java.util.*)。它没有 Java 层的父加载器,getParent() 返回 null。
小胖:那 Platform ClassLoader 和 Application ClassLoader 有什么区别?
大师:
- Platform ClassLoader (JDK 9+) / Extension ClassLoader (JDK ≤8):JDK 8 时叫"扩展类加载器",加载
<JAVA_HOME>/lib/ext/下的 jar。JDK 9 引入模块系统后改名为"平台类加载器",负责加载 Java SE 平台模块(java.sql、java.xml等),不再加载lib/ext。 - Application ClassLoader(系统类加载器):加载 classpath 上的类和
--module-path上的非平台模块。这是ClassLoader.getSystemClassLoader()返回的实例,也是你应用代码的直接"父加载器"。
技术映射:Bootstrap ↔ 国家级图书馆(核心基础藏书),Platform ↔ 省级图书馆(扩展标准库),Application ↔ 你书架上的自购书(业务代码和第三方依赖)。
小白:既然"双亲委派"这么好(保障了核心类库的安全——你不能用一个自定义 java.lang.String 覆盖 JDK 的 String),那为什么 Tomcat、SPI、OSGi 要打破它?
大师:问到了整个模型的核心张力——
- SPI 打破委派:JDBC 驱动加载就是经典案例。
java.sql.DriverManager由 Bootstrap 加载,但它需要加载你 classpath 上的 MySQL 驱动(com.mysql.cj.jdbc.Driver)。Bootstrap 加载器"向下看"不到 Application ClassLoader 的类。解决方案:Thread.currentThread().getContextClassLoader()——也就是"线程上下文类加载器",让上层加载器可以反向找下层加载器加载类。这也是"打破"的最常见模式。
// java.sql.DriverManager 内部简化逻辑
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class);
// ServiceLoader 使用线程上下文类加载器来"向下"查找实现
-
Tomcat WebApp ClassLoader:每个 webapp 需要隔离——WebApp A 的 Spring 5.x 不能与 WebApp B 的 Spring 6.x 冲突。Tomcat 为每个 WebApp 分配独立的 WebAppClassLoader,加载顺序是先自己后父——打破了"先父后子"的默认规则。
-
OSGi:更激进的委派模型——每个 Bundle 有自己的 ClassLoader,依赖关系用
Import-Package/Export-Package声明,构成一个有向图而非链式委托。
3. 项目实战
3.1 环境准备
| 组件 | 版本 | 用途 |
|---|---|---|
| JDK | OpenJDK 21 | 运行和编译 |
| 源码 | java.lang.ClassLoader |
阅读 loadClass 源码 |
| 构建工具 | 无(纯 JDK 命令) | 演示无需 Maven/Gradle 的类加载冲突 |
3.2 分步实现
步骤一:制造"同名不同 ClassLoader"的冲突
目标:用一个自定义 ClassLoader 加载一个类,演示它与系统类加载器加载的同名类不兼容。
// ConflictDemo.java —— 制造 ClassCastException
import java.io.*;
import java.lang.reflect.*;
public class ConflictDemo {
// 自定义 ClassLoader:从指定目录加载 .class 文件
static class MyClassLoader extends ClassLoader {
private String classDir;
public MyClassLoader(String classDir) {
this.classDir = classDir;
}
@Override
protected Class<?> findClass(String name) throws ClassNotFoundException {
try {
String path = classDir + "/" + name.replace('.', '/') + ".class";
byte[] bytes = Files.readAllBytes(new File(path).toPath());
return defineClass(name, bytes, 0, bytes.length);
} catch (IOException e) {
throw new ClassNotFoundException(name, e);
}
}
}
// 一个简单的测试类(编译后会放到两个目录各一份)
public static class TestBean {
public String hello() {
return "hello from " + this.getClass().getClassLoader();
}
}
public static void main(String[] args) throws Exception {
// 1. 用系统类加载器加载 TestBean(来自 classpath)
Class<?> cls1 = Class.forName("ConflictDemo$TestBean");
Object obj1 = cls1.getDeclaredConstructor().newInstance();
System.out.println("cls1 loaded by: " + cls1.getClassLoader());
System.out.println("obj1.hello(): " + cls1.getMethod("hello").invoke(obj1));
// 2. 用自定义 ClassLoader 加载"另一个副本"的 TestBean
// 先把 TestBean.class 复制到另一个目录
File altDir = new File("./alt_classes/ConflictDemo");
altDir.mkdirs();
Files.copy(
new File("./ConflictDemo$TestBean.class").toPath(),
new File("./alt_classes/ConflictDemo/TestBean.class").toPath(),
java.nio.file.StandardCopyOption.REPLACE_EXISTING
);
MyClassLoader myLoader = new MyClassLoader("./alt_classes");
Class<?> cls2 = myLoader.loadClass("ConflictDemo$TestBean");
Object obj2 = cls2.getDeclaredConstructor().newInstance();
System.out.println("cls2 loaded by: " + cls2.getClassLoader());
System.out.println("obj2.hello(): " + cls2.getMethod("hello").invoke(obj2));
// 3. 验证两个类是否相同
System.out.println("cls1 == cls2: " + (cls1 == cls2)); // false
System.out.println("cls1.equals(cls2): " + cls1.equals(cls2)); // false
// 4. 尝试强转 → ClassCastException!
try {
ConflictDemo.TestBean casted = (ConflictDemo.TestBean) obj2; // 编译通过,运行爆炸
} catch (ClassCastException e) {
System.out.println("ClassCastException: " + e.getMessage());
// com.example.ConflictDemo$TestBean cannot be cast to com.example.ConflictDemo$TestBean
}
}
}
运行结果:
cls1 loaded by: jdk.internal.loader.ClassLoaders$AppClassLoader@...
obj1.hello(): hello from jdk.internal.loader.ClassLoaders$AppClassLoader@...
cls2 loaded by: ConflictDemo$MyClassLoader@...
obj2.hello(): hello from ConflictDemo$MyClassLoader@...
cls1 == cls2: false
cls1.equals(cls2): false
ClassCastException: class ConflictDemo$TestBean cannot be cast to class ConflictDemo$TestBean
验证核心结论:同一份字节码,被不同的 ClassLoader 加载后,JVM 视其为两个完全不同的类型。
步骤二:模拟双亲委派模型
目标:手写一个 ClassLoader,通过覆盖 loadClass 方法展示"先委托、后自身"的默认行为。
// DelegationDemo.java
public class DelegationDemo {
static class LoggingClassLoader extends ClassLoader {
private String name;
public LoggingClassLoader(String name, ClassLoader parent) {
super(parent);
this.name = name;
}
@Override
public Class<?> loadClass(String className) throws ClassNotFoundException {
System.out.println("[" + name + "] 收到加载请求: " + className);
// 先检查是否已经加载
Class<?> loaded = findLoadedClass(className);
if (loaded != null) {
System.out.println("[" + name + "] 已缓存,直接返回: " + className);
return loaded;
}
// 双亲委派:先让父加载器尝试
try {
Class<?> parentClass = getParent().loadClass(className);
System.out.println("[" + name + "] 父加载器成功加载: " + className);
return parentClass;
} catch (ClassNotFoundException ignored) {
System.out.println("[" + name + "] 父加载器未找到: " + className);
}
// 父加载器找不到,自己来
System.out.println("[" + name + "] 自己加载: " + className);
return findClass(className);
}
}
public static void main(String[] args) throws Exception {
// 创建加载器链:parentLoader → childLoader
LoggingClassLoader parentLoader = new LoggingClassLoader(
"Parent", ClassLoader.getSystemClassLoader()
);
LoggingClassLoader childLoader = new LoggingClassLoader(
"Child", parentLoader
);
// 请求加载 java.lang.String —— 会被最顶层的 Bootstrap 处理
childLoader.loadClass("java.lang.String");
System.out.println("---");
// 请求加载系统中不存在的类
try {
childLoader.loadClass("com.missing.Class");
} catch (ClassNotFoundException e) {
System.out.println("最终未找到: " + e.getMessage());
}
}
}
步骤三:展示 SPI 打破双亲委派
目标:用 JDBC 驱动加载演示 Thread.getContextClassLoader() 如何"向上找下"。
// SPIBreakDemo.java
import java.sql.*;
import java.util.*;
public class SPIBreakDemo {
public static void main(String[] args) {
// DriverManager 由 Bootstrap ClassLoader 加载
System.out.println("DriverManager's ClassLoader: "
+ DriverManager.class.getClassLoader()); // null (= Bootstrap)
// 打印当前线程上下文类加载器(默认为 Application ClassLoader)
ClassLoader ctxLoader = Thread.currentThread().getContextClassLoader();
System.out.println("Thread Context ClassLoader: " + ctxLoader);
// DriverManager 的静态初始化块里用 ServiceLoader 加载驱动
// ServiceLoader 内部使用线程上下文类加载器来"反向"查找
// 这就是"打破双亲委派"的核心机制
Enumeration<Driver> drivers = DriverManager.getDrivers();
System.out.println("Loaded JDBC Drivers:");
while (drivers.hasMoreElements()) {
Driver d = drivers.nextElement();
System.out.println(" " + d.getClass().getName()
+ " (loaded by: " + d.getClass().getClassLoader() + ")");
}
}
}
步骤四:查看 JDK 源码中 ClassLoader.loadClass 实现
目标:直读源码,理解双亲委派的"法律条文"。
# 找到 ClassLoader.java 源码位置
# 路径: src/java.base/share/classes/java/lang/ClassLoader.java
核心方法 loadClass(String name, boolean resolve) 的逻辑(简化版):
// java.lang.ClassLoader 约 730 行位置
protected Class<?> loadClass(String name, boolean resolve)
throws ClassNotFoundException
{
synchronized (getClassLoadingLock(name)) {
// 第一步:检查是否已经加载过
Class<?> c = findLoadedClass(name);
if (c == null) {
try {
// 第二步:委托给父加载器(如果父加载器不为 null)
if (parent != null) {
c = parent.loadClass(name, false);
} else {
// parent == null → 委托给 Bootstrap ClassLoader
c = findBootstrapClassOrNull(name);
}
} catch (ClassNotFoundException e) {
// 父加载器找不到,继续下一步
}
if (c == null) {
// 第三步:父加载器也找不到,自己加载
c = findClass(name);
}
}
if (resolve) {
resolveClass(c);
}
return c;
}
}
关键发现:
synchronized (getClassLoadingLock(name))保证同一个类不会被并发加载(防止违反"类唯一性")。findBootstrapClassOrNull通过 JNI 调用 HotSpot 的JVM_FindClassFromBootLoader,这就是 Bootstrap 在 Java 层的"入口"——返回null说明它不是 Java 对象,getParent()无法返回有效的ClassLoader引用。
可能遇到的坑:
ClassNotFoundExceptionvsNoClassDefFoundError:前者是"加载时找不到 class 文件"(如类路径遗漏),后者是"编译时存在但运行时找不到"(如 jar 版本冲突导致类被删除),且后者是Error而非Exception,catch(Exception)抓不住。Class.forName()vsClassLoader.loadClass():前者默认会触发"链接+初始化"三个阶段,后者只完成"加载",不触发初始化。这就是为什么有些"预热"代码用Class.forName来确保静态块已执行。- 匿名类/局部类的命名:
OuterClass$1.class中的$1代表第一个匿名类,这些类也受双亲委派约束,但它们的 ClassLoader 总是与外围类一致。
3.3 测试验证矩阵
| 测试场景 | 预期行为 | 验证命令 |
|---|---|---|
| 同名类-不同 ClassLoader | ClassCastException |
运行步骤一的 ConflictDemo |
| Bootstrap 加载 String | String.class.getClassLoader() 返回 null |
jshell -e "String.class.getClassLoader()" |
| 双亲委派顺序 | 请求从子→父→Bootstrap 传递 | 运���步骤二的 DelegationDemo |
| SPI 线程上下文加载 | DriverManager 能加载 classpath 上的第三方驱动 |
运行步骤三的 SPIBreakDemo |
| 模块封装下的非法反射 | setAccessible(true) 抛 InaccessibleObjectException |
jshell -e "Class.forName(\"java.lang.ClassLoader\").getDeclaredField(\"parent\").setAccessible(true)" |
4. 项目总结
4.1 优点与缺点
| 维度 | 优点 | 缺点 |
|---|---|---|
| 安全性 | 防止核心类库被恶意替换(如自定义 java.lang.String) |
灵活性不足——合法的"向下加载"需求(如 SPI)需要额外机制 |
| 隔离性 | 不同 ClassLoader 加载的同名类完全隔离,适合 Web 容器多应用部署 | 隔离导致"类单例"(Singleton)被破坏——同一个类在不同加载器上下文中有不同实例 |
| 可扩展性 | findClass + defineClass 提供了清晰的扩展点 |
错误覆盖 loadClass 方法容易造成类加载死循环 |
| 模块化 | JDK 9+ 模块系统与双亲委派相互补充 | 类路径 vs 模块路径的混合使用增加了理解复杂度 |
| 诊断工具 | -verbose:class 可打印每个类的加载来源,排查冲突 |
类数量多时输出洪水般信息,需过滤技巧 |
4.2 适用场景
- 第三方 jar 冲突排查:
mvn dependency:tree看到的冲突只是冰山一角,运行时 ClassLoader 日志才是真相。 - 热部署/热加载:DevTools、JRebel 等工具本质上就是创建新的 ClassLoader 并 reload 类。
- 插件化架构:如果系统需要动态加载/卸载插件(如规则引擎),自定义 ClassLoader 是基础。
- SPI 机制的理解:JDBC、SLF4J、Servlet 容器都依赖线程上下文类加载器。
- 字节码增强框架调试:Java Agent 的
premain方法中使用的 ClassLoader 可能与业务代码不同。
不适用场景:
- 单体应用无动态扩展需求时,无需自定义 ClassLoader。
- 纯微服务 + 容器化部署(每个容器只跑一个 Java 进程)的场景下,类隔离优先级变低。
4.3 注意事项
| 类型 | 详细说明 |
|---|---|
| 类加载死锁 | 如果两个 ClassLoader 在 loadClass 中相互依赖且都持有了对方的锁,会造成死锁——getClassLoadingLock(name) 就是为了缓解此问题引入的类级锁 |
| 内存泄漏 | ClassLoader 被 GC 的条件是它所加载的所有类没有实例且没有 static 引用——如果某个框架把一个类的实例放在 ThreadLocal 里,这个 ClassLoader 就永远不会被回收 |
| static 变量"分裂" | 同一个类的 static 变量在不同 ClassLoader 中有独立副本——这在"全局配置单例"模式下极易出错 |
defineClass 的 class name 必须匹配 |
defineClass(name, bytes, off, len) 的 name 参数必须与 class 文件中 this_class 指向的全限定名一致,否则后续使用会出非确定性错误 |
4.4 常见踩坑经验
案例 1:Log4j2 在 Web 容器中"找不到配置"
某 Web 应用部署到 Tomcat 后,Log4j2 始终用默认配置而不用自定义的 log4j2.xml。根因:Log4j2 的 ConfigurationFactory 通过线程上下文 ClassLoader 查找配置文件,但容器初始化时线程上下文 ClassLoader 是 Tomcat 的 Common ClassLoader,看不到 WebApp WEB-INF/classes 下的文件。修复:将 log4j2.xml 放在 Tomcat 的 lib/ 目录,或显式用 -Dlog4j.configurationFile=/path/to/log4j2.xml 指定路径。
案例 2:Fastjson 多版本共存的"幽灵调用"
引入 A 服务和 B 依赖,两者各带一个不同版本的 Fastjson。Maven 选择了较新的版本,但另一个依赖的代码路径调用了旧版独有的 API(该 API 在新版本中已删除)。根因:Maven 依赖仲裁只保证编译期"只有一个 jar",但该 jar 中不包含被删除的方法 → NoSuchMethodError。修复:全局统一版本或用 shade 插件重定位包名以避免版本冲突。
案例 3:Groovy 脚本引擎导致 Metaspace OOM
一个订单规则引擎用 Groovy 动态执行商户自定义脚本。每次执行 GroovyShell.parse() 都会生成一个新类,Groovy 为每个脚本创建独立的 ClassLoader,但从未释放。根因:旧版 Groovy(❤️.0)的 GroovyClassLoader 默认不卸载类——每个脚本生成几十个辅助类,数千条规则 = 数十万个类 = Metaspace 耗尽。修复:升级 Groovy 3.0+,或使用 GroovyShell 的单例 + clearCache()。
4.5 思考题
-
进阶题:
java.lang.String的 ClassLoader 是null(Bootstrap),java.sql.DriverManager的 ClassLoader 也是null。但DriverManager却可以加载用户 classpath 上的 MySQL 驱动类——它靠的是什么机制?请从DriverManager源码 (src/java.sql/share/classes/java/sql/DriverManager.java) 中找到证据。 -
实战题:你的应用启动时加了
-verbose:classJVM 参数,发现org.springframework.core.SpringVersion被加载了两次:一次来自app.jar,一次来自/opt/tomcat/lib/spring-core.jar。这是否一定会导致问题?在什么条件下有风险、什么条件下安全?请设计一个检测脚本用于 CI 门禁。
答案提示:思考题 1 答案见第 3 章步骤三 SPI 部分及
DriverManager中ServiceLoader.load(Driver.class)的线程上下文加载机制;思考题 2 答案见本章"常见踩坑经验"案例 2。
下一章预告:第 5 章将聚焦"一个对象到底占多少字节"——带你用 JOL 工具解剖对象内存布局,深入 Compressed Oops 和指针压缩的底层原理。
延伸阅读与资源

微信公众号: 架构师日常笔记 欢迎关注!
浙公网安备 33010602011771号