JVM 类加载器与双亲委派机制

30 阅读12分钟

本文介绍 类加载器、双亲委派机制以及打破双亲委派机制的原理,并根据常见场景去解释原理。

本文目标是回答三个核心问题:

  1. JVM 中为什么需要类加载器?
  2. 双亲委派机制到底解决了什么问题?
  3. 在什么场景下需要、有必要打破双亲委派?

类加载器

概念

类加载器(ClassLoader)是Java虚拟机提供给应用程序去实现获取类和接口字节码数据的技术。类加载器只参与加载过程中的字节码获取并加载到内存这一部分。

更易理解的定义是类加载器(ClassLoader) 是 Java 虚拟机提供的一种机制,用于将 .class 字节码文件加载到 JVM 内存中,并生成对应的 Class 对象。

需要注意的是:

  • 类加载器 只负责“加载”阶段,即查找字节码并将其读入内存
  • 后续的 验证、准备、解析、初始化 等步骤由 JVM 完成

image.png

分类

类加载器可以分为两类:

  • JVM 内部实现的类加载器(Java虚拟机底层源码,非 Java 语言实现)
  • 由 Java 代码实现的类加载器(继承 ClassLoader

双亲委派机制⭐⭐⭐

由于 JVM 中存在多个类加载器,那么一个类到底由谁来加载,就需要一套统一的规则,这就是双亲委派机制。

双亲委派机制的核心是解决一个类到底由谁加载的问题。

双亲委派机制过程

双亲委派机制指的是:当一个类加载器收到类加载请求时,不会自己先加载,而是把请求委派给父类加载器;只有当父类加载器无法完成加载时,当前类加载器才会尝试自己加载。

  1. 向上查找是否加载过:每个类加载器都有一个父类加载器,在类加载的过程中,每个类加载器都会先检查是否已经加载了该类,如果已经加载则直接返回,否则会将加载请求委派给父类加载器。向上查找如果已经加载过,就直接返回Class对象,加载过程结束。这样就能避免一个类重复加载。

  2. 向下尝试加载:如果所有的父类加载器都无法加载该类,则由当前类加载器自己尝试加载。所以看上去是自顶向下尝试加载。如果第二次再去加载相同的类,仍然会向上进行委派,如果某个类加载器加载过就会直接返回。

向下委派加载起到了一个加载优先级的作用。

双亲委派的执行过程

  1. 当前类加载器收到类加载请求
  2. 检查该类是否已被加载(findLoadedClass
  3. 若未加载,则将请求交给父类加载器
  4. 父类加载器重复上述流程
  5. 若所有父加载器都无法加载,则由当前类加载器调用 findClass 自行加载

双亲委派机制有什么用?

  1. 保证类加载的安全性:通过双亲委派机制避免恶意代码替换JDK中的核心类库,比如Java.lang.String,确保核心类库的完整性和安全性

  2. 避免重复加载:双亲委派机制可以避免一个类被多次加载

类加载器的双亲委派机制-父类加载器的小细节

每个Java实现的类加载器中保存了一个成员变量叫“父”(Parent)类加载器,可以理解为它的上级,并不是继承关系

  • 应用程序类加载器的parent父类加载器是扩展类加载器,而扩展类加载器的parentnull,但是在代码逻辑上,扩展类加载器依然会把启动类加载器当成父类加载器。
  • 启动类加载器使用C++编写,没有父类加载器。

总结

类的双亲委派机制是什么?

1、当一个类加载器去加载某个类的时候,会自底向上查找是否加载过,如果加载过就直接返回,如果一直到最顶层的类加载器都没有加载,再由顶向下进行加载。

2、应用程序类加载器的父类加载器是扩展类加载器,扩展类加载器的父类加载器是启动类加载器。

3、双亲委派机制的好处有两点:第一是避免恶意代码替换JDK中的核心类库,比如java.lang.String,确保核心类库的完整性和安全性。第二是避免一个类重复地被加载。

打破双亲委派机制⭐⭐⭐

在某些场景下,严格的双亲委派机制反而会成为限制,因此 JVM 提供了“打破双亲委派”的可能。

常见方式有三种,其中前两种最为重要。

01 自定义类加载器

问题背景:Tomcat 的类隔离需求

使用一个Tomcat案例来进行说明:

一个Tomcat程序中是可以运行多个Web应用的,如果这两个应用中出现了相同限定名的类,比如Servlet类(com.xxx.MyServlet),Tomcat要保证这两个类都能加载并且它们应该是不同的类。

如果不打破双亲委派机制,当应用类加载器加载Web应用1中的MyServlet之后,Web应用2中相同限定名的MyServlet类就无法被加载了。

解决办法:Tomcat使用了自定义类加载器来实现应用之间类的隔离。Tomcat 为 每一个 Web 应用创建独立的类加载器,每一个应用会有一个独立的类加载器加载对应的类。

  • 不同 Web 应用 → 不同类加载器

  • 即使类的全限定名相同,也可以同时存在

JVM 中判断“是否为同一个类”的标准是:相同的类加载器 + 相同的类的全限定名

ClassLoader 核心源码解析

先来分析ClassLoader的原理,ClassLoader中包含了4个核心方法。

双亲委派机制的核心代码就位于loadClass方法中。

阅读双亲委派机制的核心代码ClassLoader#loadClass,分析如何通过自定义的类加载器打破双亲委派机制。

从下面代码可以看出:委派逻辑在 loadClass ,实际加载逻辑在 findClass / defineClass

    //loadClass的源码如下:resolve参数默认为false
    public Class<?> loadClass(String name) throws ClassNotFoundException {
        return loadClass(name, false);
    }
    //调用了如下方法
    protected Class<?> loadClass(String name, boolean resolve){
        //....

        //parent等于null说明父类加载器是启动类加载器,直接调用findBootstrapClassOrNull
        //否则调用父类加载器的加载方法
        if (parent != null) {
            c = parent.loadClass(name, false);
        } else {
            c = findBootstrapClassOrNull(name);
        }
        //扩展类加载器和启动类加载器都不能加载该类,则由应用程序加载器自行加载
        if (c == null) {
            // If still not found, then invoke findClass in order
            // to find the class.
            c = findClass(name);//findClass会去调用defineClass
        }
        
        //resolve参数表示是否调用resolveClass执行连接阶段
        if (resolve) {
            resolveClass(c);
        }
        
        //....
    }

打破双亲委派机制的自定义类加载器实例代码:

核心思路
  • 重写 loadClass

  • 不再优先委派给父类加载器,而是直接委派给自定义的类加载器

  • 仅对 java.* 包继续走父加载器(这是出于安全性考虑,防止用户自定义类冒充或替换 JDK 核心类库中的类(如 java.lang.String)

@Override
public Class<?> loadClass(String name) throws ClassNotFoundException {
    if (name.startsWith("java.")) {
        return super.loadClass(name);
    }
    byte[] data = loadClassData(name);
    return defineClass(name, data, 0, data.length);
}

完整代码如下:

    package classloader.broken;

    import org.apache.commons.io.IOUtils;

    import java.io.File;
    import java.io.FileInputStream;
    import java.io.IOException;
    import java.io.InputStream;
    import java.nio.charset.StandardCharsets;
    import java.security.ProtectionDomain;
    import java.util.regex.Matcher;

    /**
    * 打破双亲委派机制 - 自定义类加载器
    */
    //继承至ClassLoader
    public class BreakClassLoader1 extends ClassLoader {

        private String basePath;
        private final static String FILE_EXT = ".class";

        public void setBasePath(String basePath) {
            this.basePath = basePath;
        }

        private byte[] loadClassData(String name)  {
            try {
                String tempName = name.replaceAll("\.", Matcher.quoteReplacement(File.separator));
                FileInputStream fis = new FileInputStream(basePath + tempName + FILE_EXT);
                try {
                    return IOUtils.toByteArray(fis);
                } finally {
                    IOUtils.closeQuietly(fis);
                }

            } catch (Exception e) {
                System.out.println("自定义类加载器加载失败,错误原因:" + e.getMessage());
                return null;
            }
        }
        
        //无需关注上面实现的工具类,只关注loadClass和main方法
        @Override
        public Class<?> loadClass(String name) throws ClassNotFoundException {
            if(name.startsWith("java.")){
                return super.loadClass(name);
            }
            byte[] data = loadClassData(name);
            return defineClass(name, data, 0, data.length);
        }

        public static void main(String[] args) throws ClassNotFoundException, InstantiationException, IllegalAccessException, IOException {
            BreakClassLoader1 classLoader1 = new BreakClassLoader1();
            classLoader1.setBasePath("E:\lib\");

            Class<?> clazz1 = classLoader1.loadClass("com.itheima.my.A");

            System.out.println(clazz1.getClassLoader());//BreakClassLoader1@4769b07t

            //自定义加载器的父类加载器
            ClassLoader classLoader = classLoader1.getParent();
            System.out.println(classLoader);//AppClassLoader@18b4aac2
        }
    }
父加载器为什么是 AppClassLoader?

其中有个问题,我们没给自定义类加载器赋父加载器,为什么默认打印出父加载器是AppClassLoader呢

        //自定义加载器的父类加载器
        ClassLoader classLoader = classLoader1.getParent();
        System.out.println(classLoader);//AppClassLoader@18b4aac2

以Jdk8为例,自定义加载器需要继承至ClassLoader类,ClassLoader类中提供了构造方法设置parent的内容:

    private ClassLoader(Void unused, ClassLoader parent) {
        this.parent = parent;
        if (ParallelLoaders.isRegistered(this.getClass())) {
            parallelLockMap = new ConcurrentHashMap<>();
            package2certs = new ConcurrentHashMap<>();
            assertionLock = new Object();
        } else {
            // no finer-grained lock; lock on the classloader instance
            parallelLockMap = null;
            package2certs = new Hashtable<>();
            assertionLock = this;
        }
    }
    //这个构造方法由另外一个构造方法调用,其中父类加载器由getSystemClassLoader方法设置,checkCreateClassLoader() 方法返回的是AppClassLoader
    protected ClassLoader(ClassLoader parent) {
        this(checkCreateClassLoader(), parent);
    }

思考

回到 "一个Tomcat程序中是可以运行多个Web应用的,如果这两个应用中出现了相同限定名的类,比如Servlet类,Tomcat要保证这两个类都能加载并且它们应该是不同的类。Tomcat使用了自定义类加载器来实现应用之间类的隔离。每一个应用会有一个独立的类加载器加载对应的类。" 的问题

两个自定义类加载器加载相同限定名的类,不会冲突吗?

不会冲突,在同一个Java虚拟机中,只有相同类加载器+相同的类限定名才会被认为是同一个类。

    BreakClassLoader1 classLoader1 = new BreakClassLoader1();
    classLoader1.setBasePath("E:\lib\");

    Class<?> clazz1 = classLoader1.loadClass("com.itheima.my.A");

    BreakClassLoader1 classLoader2 = new BreakClassLoader1();
    classLoader2.setBasePath("E:\lib\");

    Class<?> clazz2 = classLoader2.loadClass("com.itheima.my.A");

    System.out.println(clazz1 == clazz2);//相同的类名使用不同类加载器classLoader1和classLoader2加载,返回false

重要建议

如果你的目标是为了实现一个自定义类加载器并扩展一些其他渠道去加载类(如网络、加密文件等),那么不应该去破双亲委派机制,应该去重写findClass方法,否则容易破坏 JVM 的类加载体系。

02 线程上下文类加载器(TCCL)

在前面介绍的双亲委派机制中有一个重要原则:父类加载器无法直接访问子类加载器加载的类

但在实际开发中,某些核心类却必须在运行时使用由应用类加载器加载的实现类,JDBC 就是一个典型例子。为了解决这一矛盾,JVM 引入了 线程上下文类加载器(Thread Context ClassLoader,TCCL)

问题定义:JDBC场景分析

JDBC案例:JDBC中使用了DriverManager来管理项目中引入的不同数据库的驱动,比如mysql驱动、oracle驱动。

  1. DriverManager类位于rtjar包中,由启动类加载器Bootstrap加载。

  1. 依赖中的mysql驱动对应的类,由应用程序类加载器来加载。

问题来了,启动类加载器,如何加载应用类加载器中的类?

JDBC 驱动加载场景中,核心类 DriverManager 由启动类加载器加载,却在运行时使用了由应用类加载器加载的驱动实现类,这种父加载器对其子加载器中类的直接依赖,突破了双亲委派模型中父加载器对下层类不可见的设计约束

下面就引入SPI机制解决这个问题

SPI机制的引入

为了解决上述问题,Java 引入了 SPI(Service Provider Interface)机制。SPI 的核心思想是:接口由 JDK 提供,具体实现由第三方提供,JDK 在运行时动态发现并加载实现类。

在 JDBC 场景中:

  • java.sql.Driver 是 SPI 接口
  • 各数据库厂商在自己的 Jar 包中提供实现
  • 并在 META-INF/services/java.sql.Driver 文件中声明实现类

DriverManager 在初始化时,会通过 SPI 机制扫描并加载这些驱动实现。

问题:SPI中是如何如何“绕过”双亲委派,获取到应用程序类加载器的?

关键就在于:线程上下文类加载器(TCCL)

SPI 在加载服务实现类时,并不是使用 DriverManager 自身的类加载器,而是:优先使用当前线程中保存的上下文类加载器

而在大多数应用场景下:

  • 线程上下文类加载器默认就是 应用程序类加载器(AppClassLoader)

因此,加载流程变为:

  1. 启动类加载器加载 DriverManager
  2. DriverManager 初始化阶段触发 SPI 加载
  3. SPI 使用 线程上下文类加载器
  4. 成功加载位于应用类路径下的 JDBC 驱动实现类

这样就实现了: 由父加载器加载的核心类,在运行时使用子加载器加载的实现类

总结

是否真的打破了双亲委派机制?

但其实:JDBC只是在DriverManager加载完之后,通过初始化阶段触发了驱动类的加载,类的加载依然遵循双亲委派机制。

类加载规则本身来看:

  • JDBC 驱动类的加载:依然遵循双亲委派机制
  • 并没有修改 loadClass 的委派顺序

真正变化的是: 类加载的“触发者”和“使用的类加载器”发生了变化

因此:

  • 双亲委派机制 没有被破坏
  • 只是通过 线程上下文类加载器 + SPI 解决了父加载器无法访问子加载器类的问题

这一点非常重要,需要理清逻辑

03 OSGi模块化

历史上,OSGi模块化框架。它存在同级之间的类加载器的委托加载。OSGi还使用类加载器实现了热部署的功能。但目前已经不再使用OSGi模块了

OSGi 通过 更加复杂的类加载器体系 实现了:

  • 模块之间的强隔离
  • 同级类加载器之间的委托加载
  • 基于类加载器的热部署与热卸载

与传统 JVM 的树状类加载结构不同,OSGi 中的类加载器更像是一张“网”,模块之间可以按规则相互可见。

不过,随着 Java 9 模块系统(JPMS)以及 Spring Boot 等技术的发展,OSGi 在实际项目中的使用已经大幅减少,不必深入了解