一、先抛问题:为什么要打破双亲委派?

在讲原理前,先看两个真实开发场景中的 “矛盾”,理解打破双亲委派的必要性:

矛盾 1:Tomcat 多应用部署,Spring 版本冲突

假设你用 Tomcat 同时部署两个 Web 应用:

  • 应用 A:依赖 Spring 5.3(需要org.springframework.context.ApplicationContext的旧方法)
  • 应用 B:依赖 Spring 6.0(ApplicationContext新增了方法)

若遵循双亲委派:

  1. 应用 A 启动时,AppClassLoader委托父加载器加载 Spring 5.3 的ApplicationContext;
  1. 应用 B 启动时,AppClassLoader发现父加载器已加载该类,直接复用 Spring 5.3 版本;
  1. 应用 B 调用 Spring 6.0 的新增方法时,直接抛出NoSuchMethodError—— 版本冲突爆发。

矛盾 2:JDBC 加载驱动,启动类加载器 “管不了”

JDBC 的核心逻辑是 “接口(JDK 定义)+ 实现(第三方提供)”:

  • JDK 在rt.jar中定义java.sql.Driver接口(由启动类加载器加载);
  • MySQL 驱动com.mysql.cj.jdbc.Driver是实现类(在项目classpath,由应用类加载器加载)。

按双亲委派规则:

  • 启动类加载器加载的DriverManager(管理驱动),要加载Driver实现类;
  • 但启动类加载器的父加载器是null,无法 “向下委托” 给应用类加载器 —— 陷入 “能定义接口,却加载不了实现” 的死局。

这两个矛盾的核心:双亲委派的 “层级委托” 逻辑,无法满足 “隔离加载”“跨层级协作” 的实战需求。此时,“打破双亲委派” 就成了唯一解法。

二、核心原理:打破双亲委派的 “关键钥匙”

要打破双亲委派,必须先搞懂它的 “命门”——ClassLoader类的loadClass()方法。正是这个方法定义了 “向上委托” 的规则,修改它就能打破机制。

1. 双亲委派的 “命门”:loadClass()默认逻辑

JDK 源码中,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) {

// 父加载器存在,调用父加载器的loadClass

c = parent.loadClass(name, false);

} else {

// 父加载器为null(启动类加载器),尝试用启动类加载器加载

c = findBootstrapClassOrNull(name);

}

} catch (ClassNotFoundException e) {

// 父加载器加载失败,才会进入这里

}

// 步骤3:父加载器加载失败,自己加载(调用findClass())

if (c == null) {

long t1 = System.nanoTime();

c = findClass(name); // 自定义加载器需重写此方法

// 记录加载耗时(JVM监控用)

sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);

sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);

sun.misc.PerfCounter.getFindClasses().increment();

}

}

// 步骤4:若需要解析类(resolve=true),则解析类结构

if (resolve) {

resolveClass(c);

}

return c;

}

}

核心规律:loadClass()的逻辑是 “先委托父加载器,父失败再自己加载”—— 只要修改这个顺序,就能打破双亲委派。

2. 打破双亲委派的两种核心方式

方式 1:重写loadClass(),修改委托顺序

最直接的方式:自定义类加载器,重写loadClass(),把 “先委托父加载器” 改成 “先自己加载”。

比如:

  • 先尝试自己的findClass()加载类;
  • 自己加载失败,再委托父加载器。

这正是 Tomcat 的WebAppClassLoader采用的方案。

方式 2:用 “线程上下文类加载器”,绕开层级限制

当顶层加载器(如启动类加载器)需要加载底层类(应用类加载器范围的类)时,可通过 “线程上下文类加载器” 反向委托:

  • JVM 启动时,默认将线程的上下文类加载器设为AppClassLoader;
  • 顶层加载器(如DriverManager的加载器)通过Thread.currentThread().getContextClassLoader()获取底层加载器;
  • 用底层加载器加载目标类,绕开 “只能向上委托” 的限制。

这是 JDBC、Spring 等框架常用的方案。

三、3 个实战场景:打破双亲委派的 “落地案例”

光懂原理不够,结合真实场景才能理解其价值。下面 3 个场景,覆盖了 90% 需要打破双亲委派的情况。

场景 1:Tomcat—— 用自定义加载器实现 “应用隔离”

Tomcat 为了解决多应用版本冲突,自定义了 4 层类加载器(从父到子):

  1. Bootstrap ClassLoader:加载 JRE 核心类(rt.jar等);
  1. Common ClassLoader:加载 Tomcat 共用类(CATALINA_BASE/lib下的 jar);
  1. WebApp ClassLoader:每个 Web 应用一个,加载应用私有类(WEB-INF/classes、WEB-INF/lib);
  1. Jsp ClassLoader:每个 JSP 一个,避免 JSP 修改后重新加载整个应用。

其中,WebAppClassLoader是打破双亲委派的核心,它重写的loadClass()逻辑如下(关键步骤):


// Tomcat 9的WebAppClassLoader核心逻辑(简化)

@Override

public Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {

synchronized (getClassLoadingLock(name)) {

// 步骤1:检查当前WebAppClassLoader是否已加载该类

Class<?> clazz = findLoadedClass0(name);

if (clazz == null) {

try {

// 步骤2:先委托父加载器(CommonClassLoader)加载

clazz = super.loadClass(name, false);

} catch (ClassNotFoundException e) {

// 父加载器加载失败,进入下一步

}

// 步骤3:父加载器失败,自己加载应用私有类

if (clazz == null) {

// 关键:只加载应用私有范围的类(WEB-INF/classes、WEB-INF/lib)

if (name.startsWith("javax.servlet.") || isCommonClass(name)) {

// 公共类仍委托父加载器,避免重复

clazz = super.loadClass(name, false);

} else {

// 应用私有类,自己加载

clazz = findClass(name);

}

}

}

if (resolve) {

resolveClass(clazz);

}

return clazz;

}

}

核心效果:每个 Web 应用的WebAppClassLoader只加载自己的类,不同应用的 Spring、MyBatis 等依赖互不干扰 —— 版本冲突问题解决。

场景 2:JDBC—— 用线程上下文类加载器 “反向委托”

我们以 MySQL 8.0 驱动为例,看 JDBC 如何打破双亲委派:

步骤 1:DriverManager的 “困境”

DriverManager在rt.jar中,由启动类加载器加载。它需要加载classpath下的com.mysql.cj.jdbc.Driver,但启动类加载器无法委托给应用类加载器。

步骤 2:线程上下文类加载器 “救场”

DriverManager的static代码块(初始化时)会调用loadInitialDrivers(),核心代码如下:


// DriverManager.java(JDK 8)

private static void loadInitialDrivers() {

// 1. 获取线程上下文类加载器(默认是AppClassLoader)

ClassLoader cl = Thread.currentThread().getContextClassLoader();

// 2. 加载META-INF/services/java.sql.Driver文件(SPI配置文件)

Enumeration<DriverInfo> drivers = ServiceLoader.load(Driver.class, cl).iterator();

// 3. 遍历加载Driver实现类(如com.mysql.cj.jdbc.Driver)

while (drivers.hasMoreElements()) {

// 加载逻辑...

}

}

关键逻辑:ServiceLoader.load(Driver.class, cl)用线程上下文类加载器(AppClassLoader)加载Driver实现类,绕开了启动类加载器的层级限制 ——“反向委托” 成功。

场景 3:OSGi—— 用 “平级委托” 实现模块化热部署

OSGi(如 Eclipse 的 Equinox 框架)是 Java 模块化的经典实现,支持 “不重启程序,动态安装 / 卸载模块”。它打破双亲委派的方式更彻底:

核心设计:每个模块一个类加载器
  • OSGi 中每个模块(Bundle)有独立的类加载器;
  • 加载类时,不向上委托父加载器,而是按 “模块依赖关系” 委托:
    1. 若模块 A 依赖模块 B,则 A 的类加载器委托 B 的类加载器加载 B 的类;
    1. 若没有依赖,优先加载自身模块的类;
    1. 公共类(如 JDK 核心类)委托给系统类加载器。
热部署原理:卸载模块时销毁类加载器

当需要卸载模块时,OSGi 直接销毁该模块的类加载器 —— 由于类的生命周期与加载器绑定,加载器销毁后,模块的类也会被 JVM 回收,实现 “无感知热部署”。

四、代码实战:自定义类加载器打破双亲委派

下面通过一个完整案例,带你亲手实现 “打破双亲委派”,理解底层逻辑。

目标

自定义类加载器CustomClassLoader,优先加载D:/custom-classes/目录下的类,失败后再委托父加载器。

步骤 1:准备待加载的类

  1. 编写com.example.Test类:

package com.example;

public class Test {

// 打印加载器信息,验证加载来源

public void printLoader() {

System.out.println("Test类的加载器:" + this.getClass().getClassLoader().getClass().getName());

}

}

  1. 编译该类:用javac命令生成Test.class,并放到D:/custom-classes/com/example/目录下(注意目录结构与包名一致);
  1. 关键:不要把Test.class放到项目的classpath,避免被AppClassLoader默认加载。

步骤 2:实现自定义类加载器

重写loadClass()修改委托顺序,实现findClass()读取class文件:


import java.io.ByteArrayOutputStream;

import java.io.FileInputStream;

import java.io.IOException;

public class CustomClassLoader extends ClassLoader {

// 自定义类加载路径(根目录)

private final String classPath;

// 构造方法:指定加载路径,父加载器默认是AppClassLoader

public CustomClassLoader(String classPath) {

this.classPath = classPath;

}

/**

* 重写loadClass():先自己加载,失败再委托父加载器

*/

@Override

protected Class<?> loadClass(String className, boolean resolve) throws ClassNotFoundException {

synchronized (getClassLoadingLock(className)) {

// 1. 检查当前加载器是否已加载该类

Class<?> loadedClass = findLoadedClass(className);

if (loadedClass == null) {

try {

// 2. 第一步:自己加载(打破双亲委派的核心)

loadedClass = findClass(className);

} catch (ClassNotFoundException e) {

// 3. 自己加载失败,委托父加载器

loadedClass = super.loadClass(className, resolve);

}

}

// 4. 解析类(若需要)

if (resolve) {

resolveClass(loadedClass);

}

return loadedClass;

}

}

/**

* 实现findClass():从自定义路径读取class文件,转为字节数组

*/

@Override

protected Class<?> findClass(String className) throws ClassNotFoundException {

try {

// 转换类名为路径(com.example.Test → com/example/Test.class)

String classFilePath = className.replace(".", "/") + ".class";

String fullPath = classPath + classFilePath;

// 读取class文件字节

FileInputStream fis = new FileInputStream(fullPath);

ByteArrayOutputStream bos = new ByteArrayOutputStream();

byte[] buffer = new byte[1024];

int len;

while ((len = fis.read(buffer)) != -1) {

bos.write(buffer, 0, len);

}

byte[] classBytes = bos.toByteArray();

// 关闭流

fis.close();

bos.close();

// 调用JVM底层方法,将字节数组转为Class对象

return defineClass(className, classBytes, 0, classBytes.length);

} catch (IOException e) {

throw new ClassNotFoundException("类加载失败:" + className, e);

}

}

}

步骤 3:测试验证

编写测试类,验证Test类是否被CustomClassLoader加载:


public class ClassLoaderTest {

public static void main(String[] args) throws Exception {

// 1. 创建自定义类加载器,指定加载路径

CustomClassLoader customLoader = new CustomClassLoader("D:/custom-classes/");

// 2. 加载com.example.Test类

Class<?> testClass = customLoader.loadClass("com.example.Test");

// 3. 反射创建实例并调用方法

Object testInstance = testClass.getConstructor().newInstance();

testClass.getMethod("printLoader").invoke(testInstance);

// 4. 验证加载器类型

System.out.println("加载器是否为CustomClassLoader:" +

(testClass.getClassLoader() instanceof CustomClassLoader));

}

}

测试结果与分析

运行后输出:


Test类的加载器:CustomClassLoader

加载器是否为CustomClassLoader:true

结论:Test类没有被父加载器(AppClassLoader)加载,而是被CustomClassLoader直接加载 —— 我们成功打破了双亲委派。

五、避坑指南:打破双亲委派的 3 个致命风险

打破双亲委派虽然能解决问题,但会破坏 JVM 的 “安全约定”,这些风险必须警惕:

风险 1:核心类重复加载,触发ClassCastException

若自定义加载器加载了 JVM 核心类(如java.lang.String),会导致:

  • 启动类加载器加载的String和自定义加载器加载的String,是两个不同的类(Java 中 “类身份”= 加载器 + 全限定名);
  • 当你尝试类型转换时,会直接抛出异常:

// 自定义加载器加载java.lang.String

Class<?> customStringClass = customLoader.loadClass("java.lang.String");

Object customString = customStringClass.getConstructor().newInstance();

// 报错:java.lang.ClassCastException: java.lang.String cannot be cast to java.lang.String

String normalString = (String) customString;

避坑方案:自定义加载器永远不加载java.lang.、java.util.等核心包下的类,优先让启动类加载器加载。

风险 2:恶意加载器篡改核心类,引发安全漏洞

若恶意攻击者自定义加载器,加载篡改后的java.lang.Runtime(比如在exec()方法中植入删除文件的代码):

  • 由于打破了双亲委派,恶意Runtime会被加载;
  • 当调用Runtime.getRuntime().exec("rm -rf /")时,会执行恶意操作 ——JVM 的安全机制无法拦截。

避坑方案:生产环境禁用自定义加载器,或通过SecurityManager限制加载器的权限。

风险 3:类依赖混乱,抛出NoClassDefFoundError

若多个自定义加载器交叉加载类,会导致依赖断裂:

  • 加载器 A 加载ClassA,ClassA依赖ClassB;
  • 加载器 B 加载ClassB;
  • 当ClassA调用ClassB的方法时,JVM 会认为ClassB不存在,抛出NoClassDefFoundError。

避坑方案:明确每个加载器的加载范围,避免交叉依赖;若必须依赖,通过 “线程上下文类加载器” 统一加载源。

六、总结:打破双亲委派的 “决策清单”

不是所有场景都需要打破双亲委派,只有满足以下 3 个条件时,才考虑使用:

  1. 有明确的隔离需求:如 Tomcat 多应用部署、OSGi 模块化,需要不同模块的类互不干扰;
  1. 有跨层级协作需求:如 JDBC、Spring SPI,顶层加载器需要加载底层类;
  1. 能控制加载范围:确保不加载核心类,避免重复加载和安全风险。

对于普通业务开发(如写接口、操作数据库),完全不需要打破双亲委派—— 遵循 JVM 的默认机制,才能保证程序的稳定性和安全性。

最后记住:双亲委派是 JVM 的 “安全盾”,打破它是 “特殊场景下的无奈之举”,而非 “炫技手段”。理解其原理、场景和风险,比单纯掌握 “怎么打破” 更重要。

Logo

Agent 垂直技术社区,欢迎活跃、内容共建。

更多推荐