1. 项目背景

业务场景:某支付系统在引入一个新的风控 SDK(.jar 包)后,应用启动即报 ClassCastException: com.alibaba.fastjson.JSONObject cannot be cast to com.alibaba.fastjson.JSONObject。运维和开发足足排查了 2 小时——同一个类名,两个包,抛出的异常信息竟然说"不能把 A 转成 A"。更诡异的是,SDK 的 jar 里明确包含了 fastjson,而主应用也依赖了同一个版本的同名 jar,怎么会冲突?

痛点:

  1. 类命名空间隔离的盲区:大多数开发以为 "classpath 上只有一个 jar 就不会冲突"。实际上,同一个类被不同的 ClassLoader 加载后,JVM 会认为它们是两个不同的类型instanceof 判断、ClassCastException 转换、静态变量——全部"分裂"成两套。
  2. 类加载顺序不可控:Maven/Gradle 依赖树中有多个同名类时,谁先被加载取决于 classpath 顺序或模块路径优先级,而这个顺序在不同环境(本地 IDE / CI / 容器)可能不同。
  3. 框架"打破"双亲委派的隐蔽性: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 眼里这就是两个完全不同的类型,跟你写的 StringInteger 一样互不兼容。

技术映射: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.sqljava.xml 等),不再加载 lib/ext
  • Application ClassLoader(系统类加载器):加载 classpath 上的类和 --module-path 上的非平台模块。这是 ClassLoader.getSystemClassLoader() 返回的实例,也是你应用代码的直接"父加载器"。

技术映射:Bootstrap ↔ 国家级图书馆(核心基础藏书),Platform ↔ 省级图书馆(扩展标准库),Application ↔ 你书架上的自购书(业务代码和第三方依赖)。

小白:既然"双亲委派"这么好(保障了核心类库的安全——你不能用一个自定义 java.lang.String 覆盖 JDK 的 String),那为什么 Tomcat、SPI、OSGi 要打破它?

大师:问到了整个模型的核心张力——

  1. 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 使用线程上下文类加载器来"向下"查找实现
  1. Tomcat WebApp ClassLoader:每个 webapp 需要隔离——WebApp A 的 Spring 5.x 不能与 WebApp B 的 Spring 6.x 冲突。Tomcat 为每个 WebApp 分配独立的 WebAppClassLoader,加载顺序是先自己后父——打破了"先父后子"的默认规则。

  2. 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;
    }
}

关键发现

  1. synchronized (getClassLoadingLock(name)) 保证同一个类不会被并发加载(防止违反"类唯一性")。
  2. findBootstrapClassOrNull 通过 JNI 调用 HotSpot 的 JVM_FindClassFromBootLoader,这就是 Bootstrap 在 Java 层的"入口"——返回 null 说明它不是 Java 对象,getParent() 无法返回有效的 ClassLoader 引用。

可能遇到的坑

  1. ClassNotFoundException vs NoClassDefFoundError:前者是"加载时找不到 class 文件"(如类路径遗漏),后者是"编译时存在但运行时找不到"(如 jar 版本冲突导致类被删除),且后者是 Error 而非 Exceptioncatch(Exception) 抓不住。
  2. Class.forName() vs ClassLoader.loadClass():前者默认会触发"链接+初始化"三个阶段,后者只完成"加载",不触发初始化。这就是为什么有些"预热"代码用 Class.forName 来确保静态块已执行。
  3. 匿名类/局部类的命名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 适用场景

  1. 第三方 jar 冲突排查mvn dependency:tree 看到的冲突只是冰山一角,运行时 ClassLoader 日志才是真相。
  2. 热部署/热加载:DevTools、JRebel 等工具本质上就是创建新的 ClassLoader 并 reload 类。
  3. 插件化架构:如果系统需要动态加载/卸载插件(如规则引擎),自定义 ClassLoader 是基础。
  4. SPI 机制的理解:JDBC、SLF4J、Servlet 容器都依赖线程上下文类加载器。
  5. 字节码增强框架调试: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 思考题

  1. 进阶题java.lang.String 的 ClassLoader 是 null(Bootstrap),java.sql.DriverManager 的 ClassLoader 也是 null。但 DriverManager 却可以加载用户 classpath 上的 MySQL 驱动类——它靠的是什么机制?请从 DriverManager 源码 (src/java.sql/share/classes/java/sql/DriverManager.java) 中找到证据。

  2. 实战题:你的应用启动时加了 -verbose:class JVM 参数,发现 org.springframework.core.SpringVersion 被加载了两次:一次来自 app.jar,一次来自 /opt/tomcat/lib/spring-core.jar。这是否一定会导致问题?在什么条件下有风险、什么条件下安全?请设计一个检测脚本用于 CI 门禁。

答案提示:思考题 1 答案见第 3 章步骤三 SPI 部分及 DriverManagerServiceLoader.load(Driver.class) 的线程上下文加载机制;思考题 2 答案见本章"常见踩坑经验"案例 2。


下一章预告:第 5 章将聚焦"一个对象到底占多少字节"——带你用 JOL 工具解剖对象内存布局,深入 Compressed Oops 和指针压缩的底层原理。

延伸阅读与资源

Java 工程师进阶:从 JVM 生产排障到OpenJDK原理

posted on 2026-09-11 15:51  一天不进步,就是退步  阅读(5)  评论(0)    收藏  举报