双亲委派模型

举报
yd_227606267 发表于 2026/10/31 13:03:15 2026/10/31
【摘要】 讲透双亲委派的规则、破坏场景与工程取舍

双亲委派是类加载器之间的一种协作约定:收到加载请求时,先委派给父加载器,父加载器无法完成时才自己加载。它保证了 java.lang.String 无论在哪个加载器下都指向同一个类,是 Java 安全模型的地基。但在 SPI、热部署、模块化和 Tomcat 等场景中,这个模型会被有意打破。本文讲清规则、破坏原因和替代方案。

模型的结构

类加载器的层次关系是「父子」而非「继承」——通过组合关系持有 parent 引用,而不是 extends。

Bootstrap ClassLoader
        ↑
Platform ClassLoader(JDK 8 为 Extension)
        ↑
Application ClassLoader
        ↑
自定义 ClassLoader

JDK 8 与 JDK 17 的差异:

| 项目 | JDK 8 | JDK 17 |

| --- | --- | --- |

| 第二层名称 | ExtClassLoader | PlatformClassLoader |

| 核心类路径 | rt.jar | jrt:/ 模块镜像 |

| 扩展机制 | lib/ext 目录 | 平台模块,--add-modules |

| 获取方式 | ClassLoader.getSystemClassLoader().getParent() | 同上,返回 Platform |

Bootstrap ClassLoader 在 Java 中拿不到引用,String.class.getClassLoader() 返回 null 就是这个原因。

工作流程

ClassLoader.loadClass 的核心逻辑是:

protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    synchronized (getClassLoadingLock(name)) {
        // 1. 已加载则直接返回
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            long t0 = System.nanoTime();
            try {
                // 2. 父加载器不为空则委派
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    // 3. 父加载器为空则委派给 Bootstrap
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // 父加载器无法加载,继续
            }
            if (c == null) {
                // 4. 自己加载
                c = findClass(name);
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

四个步骤对应委派的核心:先查缓存,再向上委派,最后自己加载。注意 findBootstrapClassOrNull 在 JDK 中是通过 native 方法实现的,Java 层看不到。

委派带来的两个保证

  • 唯一性:同一个类在同一个加载器层次中只会被加载一次,避免核心类被替换。
  • 安全性:用户自定义的 java.lang.String 永远不会被加载,因为 Bootstrap 已经加载了真正的 String。

如果用户写了一个同名类并放在 classpath 上,JVM 会优先使用 Bootstrap 加载的版本,自定义版本被静默忽略。这是「同名类不生效」问题最常见的成因。

破坏双亲委派的三次浪潮

第一次:JDK 1.2 之前

双亲委派在 JDK 1.2 才引入,此前的 loadClass 是抽象方法,用户必须重写。为了兼容,JDK 1.2 新增了 findClass,并让 loadClass 变成可重写但默认实现委派逻辑的方法。这是历史包袱,不是设计破坏。

第二次:SPI 与线程上下文类加载器

SPI(Service Provider Interface)要求接口由 Bootstrap 加载,实现由 Application 加载。但 Bootstrap 无法「向下」加载 classpath 中的实现类。

以 JDBC 为例:java.sql.Driver 在 java.sql 模块中,由 Bootstrap 加载;com.mysql.cj.jdbc.Driver 在 classpath 中,由 Application 加载。Bootstrap 要加载实现类,就必须突破层次限制。

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

ServiceLoader<Driver> loader = ServiceLoader.load(Driver.class);
for (Driver driver : loader) {
    System.out.println(driver.getClass().getName());
}

ServiceLoader.load 内部使用 Thread.currentThread().getContextClassLoader() 来加载实现类。默认情况下 TCCL 就是 Application ClassLoader,因此可以加载到 classpath 中的实现。

这是「父加载器请求子加载器加载」的典型场景,方向与双亲委派相反。

第三次:热部署与模块化

OSGi 和 Tomcat 需要「同一个类在不同模块中有不同版本」,这要求打破唯一性。Tomcat 的类加载结构是:

Bootstrap
   ↑
System
   ↑
Common
   ↑
   ├── Webapp1
   └── Webapp2

每个 Web 应用有独立的 WebappClassLoader,它优先加载自己的 WEB-INF/classes 和 WEB-INF/lib,加载不到才委派给 Common。这被称为「子优先」或「破坏双亲委派」,目的是实现应用隔离。

Tomcat 的 WebappClassLoaderBase.loadClass 明确实现了这个顺序:先查本地缓存,再查自己的仓库,最后才委派父加载器。唯一的例外是 javax.servlet 等容器共享的类,这些被强制委派。

线程上下文类加载器的正确用法

TCCL 的本质是给「父加载器」一个向下查找的入口。框架在启动时把当前线程的 TCCL 设置为应用加载器,之后任何需要加载实现类的地方都通过它获取。

ClassLoader tccl = Thread.currentThread().getContextClassLoader();
try {
    Thread.currentThread().setContextClassLoader(customLoader);
    // 此处 ServiceLoader 会用 customLoader 加载实现
    ServiceLoader<Codec> codecs = ServiceLoader.load(Codec.class);
    codecs.forEach(System.out::println);
} finally {
    Thread.currentThread().setContextClassLoader(tccl);
}

务必用 try-finally 恢复原值。如果忘记恢复,后续复用该线程的请求会继承错误的加载器,表现为「偶发找不到类」——这类问题在压测时几乎不出现,只在线上特定线程上复现。

如何确认一个类由谁加载

排查类冲突时,第一步是确认加载器身份:

public class WhoLoadedMe {
    public static void main(String[] args) throws Exception {
        Class<?> clazz = Class.forName("com.mysql.cj.jdbc.Driver");
        ClassLoader loader = clazz.getClassLoader();
        System.out.println("class = " + clazz);
        System.out.println("loader = " + loader);
        System.out.println("loader parent = " + loader.getParent());
        System.out.println("code source = " + clazz.getProtectionDomain().getCodeSource().getLocation());
    }
}

CodeSource 会给出该类来自哪个 jar 或目录。当同一个类出现在多个依赖中时,输出能直接指出实际生效的那一份,比逐个翻 mvn dependency:tree 更快。

隔离方案对比

打破委派的目的都是隔离,但实现层次不同:

| 方案 | 隔离粒度 | 委派方向 | 代价 |

| --- | --- | --- | --- |

| 默认双亲委派 | 无 | 父优先 | 无法多版本共存 |

| Tomcat Webapp | 每个 Web 应用 | 子优先,容器类除外 | 应用间类型不兼容 |

| OSGi | 每个 Bundle | 显式 Import/Export 包 | 复杂度高,学习成本大 |

| JDK 9 模块层 | 每个 Layer | 按模块图解析 | 需要模块化改造 |

JDK 9 引入的 ModuleLayer 提供了一种标准化的隔离手段,可以在同一进程内加载多个版本的模块,而不必自己实现类加载器:

ModuleLayer layer = ModuleLayer.boot();
layer.modules().forEach(m -> System.out.println(m.getName() + " <- " + m.getClassLoader()));

多版本共存仍然困难,因为模块图在启动时确定,动态加载需要新建 Layer。对绝大多数业务应用来说,Tomcat 式的子优先已经够用,OSGi 的收益往往抵不过维护成本。

如何自定义加载器而不破坏模型

如果只是想从特殊位置加载类,应该重写 findClass 而不是 loadClass。这样既能保持委派语义,又能扩展加载来源。

public class EncryptedClassLoader extends ClassLoader {
    private final Path dir;
    private final byte key;

    public EncryptedClassLoader(Path dir, byte key, ClassLoader parent) {
        super(parent);
        this.dir = dir;
        this.key = key;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        try {
            byte[] encrypted = Files.readAllBytes(dir.resolve(name.replace('.', '/') + ".class"));
            byte[] bytes = new byte[encrypted.length];
            for (int i = 0; i < encrypted.length; i++) {
                bytes[i] = (byte) (encrypted[i] ^ key);
            }
            return defineClass(name, bytes, 0, bytes.length);
        } catch (IOException e) {
            throw new ClassNotFoundException(name, e);
        }
    }
}

重写 loadClass 才会改变委派顺序,只有实现隔离或热部署时才需要这么做。

常见坑 / 最佳实践

  1. 在自定义加载器中直接重写 loadClass 并复制父类逻辑。一旦 JDK 升级导致 loadClass 实现变化(如 JDK 17 增加了并行加载能力),复制来的代码会失去这些改进。优先重写 findClass。
  2. 同一个类被两个加载器加载后做强制类型转换。com.foo.User 在加载器 A 和 B 中是两个不同的 Class,转换必抛 ClassCastException。跨加载器传递对象应使用接口加公共父加载器,或使用序列化/JSON 转换。
  3. 依赖 TCCL 的框架在异步线程中失效。线程池复用线程时 TCCL 不会自动传递,导致 ServiceLoader 找不到实现。应在任务提交时捕获 getContextClassLoader() 并在任务内设置。
  4. Tomcat 下把依赖放在 $CATALINA_HOME/lib。这会加载到 Common 加载器,被所有应用共享,版本冲突和类泄漏风险陡增。应用依赖应放在 WEB-INF/lib。
  5. JDK 17 上通过反射访问核心类内部。模块化后 --illegal-access 默认拒绝,需要显式 --add-opens java.base/java.lang=ALL-UNNAMED,否则大量框架会报 InaccessibleObjectException。

小结

  • 双亲委派通过「先委派父加载器,失败再自己加载」保证类的唯一性和核心类安全。
  • 实现上重写 findClass 是扩展加载来源,重写 loadClass 才是改变委派顺序。
  • SPI 通过线程上下文类加载器实现父加载器请求子加载器,是第一次真正的破坏。
  • Tomcat 等容器用子优先的加载器实现应用隔离,代价是跨应用类型不兼容。
  • JDK 9 起扩展加载器改为平台加载器,核心类由模块系统管理,rt.jar 已不存在。
【声明】本内容来自华为云开发者社区博主,不代表华为云及华为云开发者社区的观点和立场。转载时必须标注文章的来源(华为云社区)、文章链接、文章作者等基本信息,否则作者和本社区有权追究责任。如果您发现本社区中有涉嫌抄袭的内容,欢迎发送邮件进行举报,并提供相关证据,一经查实,本社区将立刻删除涉嫌侵权内容,举报邮箱: cloudbbs@huaweicloud.com
  • 点赞
  • 收藏
  • 关注作者

评论(0)

0/1000
抱歉,系统识别当前为高风险访问,暂不支持该操作

全部回复

上滑加载中

设置昵称

在此一键设置昵称,即可参与社区互动!

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。

*长度不超过10个汉字或20个英文字符,设置后3个月内不可修改。