深度剖析:打破双亲委派,实现同一类多个不同版本的秘籍

一、什么是双亲委派机制?

我们都知道,Java虚拟机在加载类的过程中需要借助类加载器来完成。在Java体系里,类加载器的种类繁多。那么,当Java虚拟机(JVM)想要加载一个.class文件时,究竟该由哪个类加载器来执行加载操作呢?

这就引出了“双亲委派机制”。

首先,我们要清楚,Java语言系统支持以下4种类加载器:

  • 启动类加载器(Bootstrap ClassLoader):负责加载Java核心类库。
  • 标准扩展类加载器(Extention ClassLoader):主要用于加载Java的扩展类库。
  • 应用类加载器(Application ClassLoader):负责加载应用程序的类路径下的类。
  • 用户自定义类加载器(User ClassLoader):由用户自行定义的类加载器,可满足特定的加载需求。

这四种类加载器之间存在着一种层次关系,如下图所示:

java类加载器关系

通常认为,上一层加载器是下一层加载器的父加载器。因此,除了启动类加载器(BootstrapClassLoader)之外,其他所有的加载器都拥有父加载器 。

那么,究竟什么是双亲委派机制呢?其含义为:当一个类加载器接收到类加载请求时,它并不会直接去加载指定的类,而是将该请求委托给自己的父加载器进行加载。只有在父加载器无法加载该类的情况下,才会由当前这个类加载器承担类的加载工作

那么,在哪些情况下父加载器会无法加载某个类呢?

实际上,Java中提供的这四种类加载器,各自承担着明确的职责:

  • 启动类加载器(Bootstrap ClassLoader):主要负责加载Java核心类库,具体包括%JRE_HOME%\lib目录下的rt.jar、resources.jar、charsets.jar等以及相关class文件。
  • 标准扩展类加载器(Extention ClassLoader):主要负责加载%JRE_HOME%\lib\ext目录下的jar包和class文件。
  • 应用类加载器(Application ClassLoader):主要负责加载当前应用classpath下的所有类。
  • 用户自定义类加载器(User ClassLoader):由用户自行定义,可加载指定路径的class文件。

这就意味着,像com.hollis.ClassHollis这样的用户自定义类,无论如何都不会被启动类加载器(Bootstrap)和标准扩展类加载器(Extention)加载 。

二、为什么需要双亲委派?

正如上文所述,由于类加载器之间存在严格的层次结构,这使得Java类也相应地具备了层次关系。从某种意义上讲,这种层次关系也体现为一种优先级。

例如,定义在java.lang包下的类,因其存储于rt.jar中,在加载过程中,会持续被委托至启动类加载器(Bootstrap ClassLoader),最终由它完成加载。

再看用户自定义的com.hollis.ClassHollis类,它同样会被层层委托至启动类加载器(Bootstrap ClassLoader)。然而,启动类加载器并不负责加载此类,于是转而由标准扩展类加载器(Extention ClassLoader)尝试加载。但由于该类也不在标准扩展类加载器的职责范围内,最终会由应用类加载器(Application ClassLoader)来加载。

这种双亲委派机制具备诸多优势:

其一,通过委派方式能够有效避免类的重复加载。当父加载器已经成功加载过某个类时,子加载器便不会再次重复加载该类。 其二,双亲委派机制保障了系统的安全性。启动类加载器(Bootstrap ClassLoader)在执行加载操作时,仅会加载JAVA_HOME目录下jar包中的类,例如java.lang.Integer。这意味着该类不会被轻易替换,除非有人恶意篡改你机器上的JDK。如此一来,便能够避免有人自定义具有破坏性功能的java.lang.Integer类被加载,有力地防止了核心Java API遭到篡改。

“父子加载器”之间的关系是继承吗?

不少人看到父加载器、子加载器这样的命名,便会下意识地认为Java中的类加载器之间存在继承关系。实际上,甚至网络上诸多文章也持有类似的错误观点。

在此特别强调,在双亲委派模型中,类加载器之间的父子关系通常并非通过继承(Inheritance)来实现,而是普遍采用组合(Composition)关系,以此复用父加载器的代码

以下是ClassLoader中对父加载器的定义:

public abstract class ClassLoader {
    // The parent class loader for delegation
    private final ClassLoader parent;
}

三、双亲委派实现原理是什么?

双亲委派模型对于保障Java程序的稳定运行起着至关重要的作用,但其实现过程并不复杂。

实现双亲委派的代码集中于java.lang.ClassLoader的loadClass()方法中

protected Class<?> loadClass(String name, boolean resolve)
        throws ClassNotFoundException
    {
        synchronized (getClassLoadingLock(name)) {
            // First, check if the class has already been loaded
            Class<?> c = findLoadedClass(name);
            if (c == null) {
                long t0 = System.nanoTime();
                try {
                    if (parent != null) {
                        c = parent.loadClass(name, false);
                    } else {
                        c = findBootstrapClassOrNull(name);
                    }
                } catch (ClassNotFoundException e) {
                    // ClassNotFoundException thrown if class not found
                    // from the non-null parent class loader
                }

                if (c == null) {
                    // If still not found, then invoke findClass in order
                    // to find the class.
                    long t1 = System.nanoTime();
                    c = findClass(name);

                    // this is the defining class loader; record the stats
                    sun.misc.PerfCounter.getParentDelegationTime().addTime(t1 - t0);
                    sun.misc.PerfCounter.getFindClassTime().addElapsedTimeFrom(t1);
                    sun.misc.PerfCounter.getFindClasses().increment();
                }
            }
            if (resolve) {
                resolveClass(c);
            }
            return c;
        }
    }

这段代码逻辑清晰,主要包含以下几个步骤:

  1. 首先检查类是否已被加载。
  2. 若未加载,则调用父加载器的loadClass()方法进行加载。
  3. 若父加载器为空,默认使用启动类加载器作为父加载器。
  4. 如果父类加载失败并抛出ClassNotFoundException异常后,再调用自身的findClass()方法进行加载。

如何主动破坏双亲委派机制?

了解了双亲委派模型的实现方式后,想要破坏该机制就相对容易了。

由于双亲委派过程在loadClass方法中实现,若要破坏这一机制,可自定义一个类加载器,重写其中的loadClass方法,使其不执行双亲委派

loadClass()、findClass()、defineClass()的区别

ClassLoader中与类加载相关的方法众多,前面已提及loadClass,此外还有findClass和defineClass等。那么,这几个方法有何不同呢?

  • loadClass():这是主要执行类加载的方法,默认的双亲委派机制就体现在该方法中。
  • findClass():根据名称或位置加载.class字节码。
  • definclass():将字节码转换为Class。

这里需要着重说明一下loadClass和findClass。前面提到,当我们想要自定义一个类加载器并破坏双亲委派原则时,会重写loadClass方法。

那么,若想定义一个类加载器,同时又不想破坏双亲委派模型,该怎么做呢?

此时,可以继承ClassLoader并重写findClass方法。findClass()方法是JDK 1.2之后在ClassLoader中新添加的。

/**
 * @since  1.2
 */
protected Class<?> findClass(String name) throws ClassNotFoundException {
    throw new ClassNotFoundException(name);
}

该方法仅抛出一个异常,没有默认实现。

自JDK 1.2起,不再鼓励用户直接覆盖loadClass()方法,而是建议将自定义的类加载逻辑实现在findClass()方法中。

因为在loadClass()方法的逻辑里,若父类加载器加载失败,便会调用自身的findClass()方法来完成加载。

所以,若要定义一个遵循双亲委派模型的自定义类加载器,可继承ClassLoader,并在findClass方法中实现自己的加载逻辑

四、破坏双亲委派实践

双亲委派模型并非是一种具有强制约束力的规范,它实际上是Java设计者向开发者们推荐的类加载器实现方式。正因如此,该模型中的委派和加载顺序并非不可改变,完全是可以被打破的。

若开发者想要自定义类加载器,通常需要继承 ClassLoader 类,并对 findClass 方法进行重写。而要是开发者期望类加载过程不遵循双亲委派的顺序,那就不仅要重写 findClass 方法,还需要对 loadClass 方法进行重写 。

1、直接使用自定义类加载器进行加载

以下是一个自定义的类加载器 TestClassLoader,其中对 findClassloadClass 方法进行了重写:

public class TestClassLoader extends ClassLoader {
    public TestClassLoader(ClassLoader parent) {
        super(parent);
    }
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // 1、获取 class 文件的二进制字节数组
        byte[] data = null;
        try {
            System.out.println(name);
            String namePath = name.replaceAll("\\.", "\\\\");
            String classFile = "C:\\study\\myStudy\\ZooKeeperLearning\\zkops\\target\\classes\\" + namePath + ".class";
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            FileInputStream fis = new FileInputStream(new File(classFile));
            byte[] bytes = new byte[1024];
            int len = 0;
            while ((len = fis.read(bytes)) != -1) {
                baos.write(bytes, 0, len);
            }
            data = baos.toByteArray();
        } catch (FileNotFoundException e) {
            e.printStackTrace();
        } catch (IOException e) {
            e.printStackTrace();
        }
        // 2、将字节码加载到 JVM 的方法区,
        // 并在 JVM 的堆区创建一个 java.lang.Class 对象实例,
        // 用于封装 Java 类的相关数据和方法
        return this.defineClass(name, data, 0, data.length);
    }
    @Override
    public Class<?> loadClass(String name) throws ClassNotFoundException{
        Class<?> clazz = null;
        // 直接由自身进行加载
        clazz = this.findClass(name);
        if (clazz != null) {
            return clazz;
        }

        // 若自身无法加载,再调用父类的 loadClass 方法,维持双亲委托模式
        return super.loadClass(name);
    }
}

测试环节

在初始化自定义的类加载器时,需要传入一个 parent 参数来指定其父类加载器。这里我们将加载 TestClassLoader 的类加载器指定为 TestClassLoader 的父类加载器:

public static void main(String[] args) throws Exception {
        // 初始化 TestClassLoader,并将加载 TestClassLoader 类的类加载器设置为 TestClassLoader 的父类加载器
        TestClassLoader testClassLoader = new TestClassLoader(TestClassLoader.class.getClassLoader());
        System.out.println("TestClassLoader 的父类加载器:" + testClassLoader.getParent());
        // 加载 Demo 类
        Class clazz = testClassLoader.loadClass("study.stefan.classLoader.Demo");
        System.out.println("Demo 的类加载器:" + clazz.getClassLoader());
    }

运行上述测试代码后,程序出现报错:找不到 java\lang\Object.class。这就让人疑惑了,加载 study.stefan.classLoader.Demo 类怎么会和 Object 类产生关联呢?

Java破坏双亲委派报错截图

很快我们就会意识到,在 Java 中所有的类都默认继承了超类 Object。所以当加载 study.stefan.classLoader.Demo 类时,其也会尝试加载父类 Object。由于 Object 类和 study.stefan.classLoader.Demo 类并不处于同一个目录,我们需要找到 Object.class 的目录(将 jre/lib/rt.jar 解压),并对 TestClassLoader#findClass 方法进行如下修改:当遇到前缀为 java. 的类时,就去查找官方的 class 文件。

Java破坏双亲委派修改代码截图

再次运行测试代码,结果仍然报错!

Java破坏双亲委派再次报错截图

报错信息显示: Prohibited package name: java.lang

我们跟进异常堆栈信息发现:TestClassLoader#findClass 方法的最后一行代码调用了 java.lang.ClassLoader#defineClass 方法,而 java.lang.ClassLoader#defineClass 方法最终调用了以下代码:

Java破坏双亲委派代码调用截图1

Java破坏双亲委派代码调用截图2

从这些信息可以看出,Java 禁止用户使用自定义的类加载器来加载以 java. 开头的官方类。也就是说,只有启动类加载器 BootstrapClassLoader 才有资格加载以 java. 开头的官方类。

由此我们可以得出结论:由于在 Java 中所有类都继承自 Object 类,当加载自定义类 study.stefan.classLoader.Demo 时,会继续加载其父类,而最顶级的父类 Object 属于 Java 官方类,只能由 BootstrapClassLoader 进行加载。

2、绕过应用类加载器(AppClassLoader)和标准扩展类加载器(ExtClassLoader)

既然存在上述限制,那么可以尝试先将 com.stefan.DailyTest.classLoader.Demo 类交由启动类加载器(BootstrapClassLoader)进行加载。

在Java中,无法直接对启动类加载器(BootstrapClassLoader)进行引用。因此,在初始化 TestClassLoader 时,将传入的 parent 设置为 null,这意味着把 TestClassLoader 的父类加载器指定为启动类加载器(BootstrapClassLoader) ,具体代码如下:

package com.stefan.DailyTest.classLoader;

public class Test {
    public static void main(String[] args) throws Exception {
        // 初始化TestClassLoader,将加载TestClassLoader类的类加载器设为TestClassLoader的父类加载器
        TestClassLoader testClassLoader = new TestClassLoader(null);
        System.out.println("TestClassLoader的父类加载器:" + testClassLoader.getParent());
        // 加载Demo类
        Class clazz = testClassLoader.loadClass("com.stefan.DailyTest.classLoader.Demo");
        System.out.println("Demo的类加载器:" + clazz.getClassLoader());
    }
}

双亲委派的核心逻辑体现在 loadClass 方法中。鉴于当前类加载器的层级关系为 TestClassLoader -> BootstrapClassLoader,在 TestClassLoader 中无需对 loadClass 方法进行重写。

运行上述测试代码,结果如下:

Java破坏双亲委派运行结果截图

可以看到,程序运行成功,Demo 类由自定义的类加载器 TestClassLoader 加载完成,这表明双亲委派模型已被成功破坏。

若不破坏双亲委派模型,由于 Demo 类位于 classpath 路径下,按照正常机制,应由应用类加载器(AppClassLoader)负责加载。所以,此次操作实际上破坏的是应用类加载器(AppClassLoader)这一层级的双亲委派机制。

3、利用自定义类加载器加载扩展类

假设在 classpath 路径下,由上述 TestClassLoader 加载的类中使用到了 <JAVA_HOME>\lib\ext 目录下的扩展类。在这种情况下,这些扩展类同样会由 TestClassLoader 尝试加载,但往往会出现类文件找不到的错误。

实际上,自定义类加载器是具备加载 <JAVA_HOME>\lib\ext 目录下扩展类的能力的,关键在于自定义类加载器要能够准确找到扩展类的类路径。

下面以扩展目录 com.sun.crypto.provider 下的类为例进行说明:

  1. Demo 类中引用一个扩展类

    import com.sun.crypto.provider.ARCFOURCipher;
    public class Demo {
        public Demo() {
            ARCFOURCipher arcfourCipher = new ARCFOURCipher();
            System.out.println("ARCFOURCipher.getClassLoader=" + arcfourCipher.getClass().getClassLoader());
        }
    }
    
  2. 修改 TestClassLoader#findClass 方法

Java破坏双亲委派-修改findClass方法截图

  1. 在测试代码中调用 Demo 类的构造器

Java破坏双亲委派-调用Demo构造器截图

  1. 运行测试代码

通过上述步骤,自定义类加载器成功加载了扩展类。

Java破坏双亲委派-运行结果截图

由此可以得出结论:<JAVA_HOME>\lib\ext 目录下的扩展类并非强制要求只能由 ExtClassLoader 加载,自定义类加载器同样能够实现对其加载。

4、Tomcat 中破坏双亲委派的实际场景

在 Java 的类加载体系里,只有以 java. 开头的官方库类必须由启动类加载器进行加载,这一规则无法被打破。不过,扩展类加载器和应用程序类加载器所遵循的双亲委派机制是可以被破坏的。

了解了相关理论后,还需要结合实际场景,精准找到破坏双亲委派机制的切入点。我们不妨看看优秀的开源框架是如何操作的,以 Tomcat 为例:

tomcat 破坏双亲委派

这里就不展示 Tomcat 的源码了。在 Tomcat 中,能够同时部署多个 Web 项目。为了确保每个 Web 项目相互独立,避免类加载上的冲突,Tomcat 自定义了类加载器 WebappClassLoaderWebappClassLoader 继承自 URLClassLoader,并重写了 findClassloadClass 方法,同时将 WebappClassLoader 的父类加载器设置为 AppClassLoader

WebappClassLoaderloadClass 方法中,会先检查缓存,查看该类是否已经被加载过。若未加载,则将加载请求交给 ExtClassLoaderExtClassLoader 会进一步将请求传递给 BootstrapClassLoader 进行加载。如果这几个加载器都无法加载该类,那么就由 WebappClassLoader 自己尝试加载。若自身也无法完成加载,就会遵循原始的双亲委派机制,将请求交给 AppClassLoader 进行递归加载。

5、一个较为完整的自定义类加载器示例

通常情况下,自定义类加载器会继承 URLClassLoader,它们之间的类关系如图所示:

类关系图

以下是一个自定义类加载器的代码示例:

public class TestClassLoader extends URLClassLoader {
    public TestClassLoader(ClassLoader parent) {
        super(new URL[0], parent);
    }
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // 1、先在自己的路径中查找类
        Class<?> clazz = null;
        try {
            clazz = findClassInternal(name);
        } catch (Exception e) {
            // 忽略异常
        }
        if (clazz != null) {
            return clazz;
        }
        // 2、在父类路径中查找类
        return super.findClass(name);
    }

    private Class<?> findClassInternal(String name) throws IOException {
        byte[] data = null;
        try {
            String dir = "C:\\study\\myStudy\\ZooKeeperLearning\\zkops\\target\\classes\\";
            String namePath = name.replaceAll("\\.", "\\\\");
            String classFile = dir + namePath + ".class";
            ByteArrayOutputStream baos = new ByteArrayOutputStream();
            FileInputStream fis = new FileInputStream(new File(classFile));
            byte[] bytes = new byte[1024];
            int len = 0;
            while ((len = fis.read(bytes)) != -1) {
                baos.write(bytes, 0, len);
            }
            data = baos.toByteArray();
            // 将字节码加载到 JVM 的方法区,
            // 并在 JVM 的堆区创建一个 java.lang.Class 对象的实例
            // 用于封装 Java 类的相关数据和方法
            return this.defineClass(name, data, 0, data.length);
        } catch (Exception e) {
            throw e;
        }
    }
    @Override
    public Class<?> loadClass(String name) throws ClassNotFoundException{
        // 1、先委托给扩展类加载器(ExtClassLoader)进行加载
        ClassLoader classLoader = getSystemClassLoader();
        while (classLoader.getParent() != null) {
            classLoader = classLoader.getParent();
        }
        Class<?> clazz = null;
        try {
            clazz = classLoader.loadClass(name);
        } catch (ClassNotFoundException e) {
            // 忽略异常
        }
        if (clazz != null) {
            return clazz;
        }
        // 2、由自己进行加载
        clazz = this.findClass(name);
        if (clazz != null) {
            return clazz;
        }
        // 3、若自己无法加载,再调用父类的 loadClass 方法,维持双亲委托模式
        return super.loadClass(name);
    }
}

五、Class.forName 和 ClassLoader.loadClass 的区别

  1. forName(String name, boolean initialize, ClassLoader loader) 方法允许我们指定使用的类加载器 classLoader
  2. 如果不显式传入 classLoader,那么默认会使用当前类的类加载器。以下是相关代码示例:
public static Class<?> forName(String className)
        throws ClassNotFoundException {
    Class<?> caller = Reflection.getCallerClass();
    return forName0(className, true, ClassLoader.getClassLoader(caller), caller);
}

类的完整加载过程包括:加载 ——> 验证 ——>准备 ——>解析 ——> 类初始化 ——>使用(对象实例初始化) ——> 卸载。

当调用 java.lang.Class.forName 方法时,实际上会调用 forName0 方法,其中第二个参数 initialize = true。这表明 Class.forName 方法会触发类初始化( <clinit>())操作。

Class.forName

java.lang.ClassLoader.loadClass 方法会调用被 protected 修饰的 loadClass(String name, boolean resolve) 方法,其第二个参数 resolve = false。这意味着该方法不会进行类的解析操作,自然也就不会触发类初始化,像静态变量的初始化、静态代码块的执行等操作都不会发生。

ClassLoader#loadClass

六、线程上下文类加载器

线程上下文类加载器本质上是一种类加载器传递机制。我们能够借助 java.lang.Thread#setContextClassLoader 方法为某个线程设置上下文类加载器。在该线程后续的执行过程中,就可以通过 java.lang.Thread#getContextClassLoader 方法将这个类加载器取出并使用。

若在创建线程时没有设置上下文类加载器,那么会从父线程( parent = currentThread())中获取。要是在整个应用程序的全局范围内都未曾设置过,默认会使用应用程序类加载器。

线程上下文类加载器的出现,主要是为了方便打破双亲委派机制:

一个典型的例子就是 JNDI 服务。如今,JNDI 已成为 Java 的标准服务,其代码由启动类加载器负责加载(在 JDK 1.3 时被放入 rt.jar 中)。然而,JNDI 的主要目的是对资源进行集中管理和查找,这就需要调用由独立厂商实现并部署在应用程序的 ClassPath 下的 JNDI 接口提供者(SPI,Service Provider Interface)的代码。但启动类加载器是无法加载 ClassPath 下的类的。

不过,有了线程上下文类加载器就方便多了。JNDI 服务利用线程上下文类加载器来加载所需的 SPI 代码,也就是让父类加载器请求子类加载器来完成类加载操作。这种行为实际上打破了双亲委派模型的层级结构,逆向使用了类加载器,这其实已经违背了双亲委派模型的一般原则,但也是不得已而为之。

在 Java 中,所有涉及 SPI 的加载操作基本都采用这种方式,例如 JNDI、JDBC、JCE、JAXB 和 JBI 等。

摘自《深入理解 Java 虚拟机》周志明

七、要点回顾

  1. Java 的类加载过程,是先获取 .class 文件的二进制字节码数组,然后将其加载到 JVM 的方法区,同时在 JVM 的堆区创建一个 java.lang.Class 对象实例,用于封装 Java 类的相关数据和方法。
  2. Java 默认包含三个类加载器,分别是启动类加载器(BootstrapClassLoader)、扩展类加载器(ExtClassLoader)和应用程序类加载器(也称为系统类加载器,AppClassLoader)。类加载器之间存在父子关系,但这种关系并非继承关系,而是组合关系。若 parent = null,则其对应的父级加载器为启动类加载器。需要注意的是,启动类加载器无法被 Java 程序直接引用。
  3. 双亲委派体现了类加载器之间的层级关系,类的加载过程是一个递归调用的过程。具体而言,首先会逐层向上委托父类加载器进行加载,直至到达最顶层的启动类加载器。当启动类加载器无法完成加载时,再逐层向下委托给子类加载器进行加载。
  4. 当加载一个类时,其对应的父类也会被加载。若该类中还引用了其他类,则会按需进行加载,并且这些类都会由加载当前类的类加载器来完成加载操作。
  5. 双亲委派机制的主要目的是确保 Java 官方类库 <JAVA_HOME>\lib 的加载安全性,防止被开发者随意覆盖。
  6. <JAVA_HOME>\lib<JAVA_HOME>\lib\ext 属于 Java 官方核心类库,通常不会对 ExtClassLoader 及其以上层级的双亲委派机制进行破坏。
  7. 破坏双亲委派机制主要有两种方式:一是自定义类加载器,此时必须重写 findClassloadClass 方法;二是利用线程上下文类加载器的传递性,使父类加载器调用子类加载器的加载操作。
  8. ClassLoader.loadClassClass.forName 的区别在于,ClassLoader.loadClass 不会对类进行解析和类初始化操作,而 Class.forName 则具备完整的类加载过程。

版权声明:本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌侵权/违法违规的内容, 请发送邮件举报,一经查实,本站将立刻删除。

文章由技术书栈整理,本文链接:https://study.disign.me/article/202510/1.parent-delegation-in-Java.md

发布时间: 2025-03-03