Java 类加载机制与双亲委派模型底层原理

13 阅读10分钟

Java 类加载机制与双亲委派模型底层原理

一、 这篇文章要解决什么问题

在日常的业务开发中,大部分开发者对“类加载”毫无感知:直接 import,然后 new 出对象就完事了。但当你跨越初级阶段,开始排查疑难杂症或负责底层架构时,通常会遭遇以下“毒打”:

  1. 极其诡异的类异常:明明看到 Jar 包在工程里,为什么运行时一直抛 ClassNotFoundExceptionNoClassDefFoundError
  2. 同名类版本冲突(Jar Hell):引入了不同的第三方库,它们又传递依赖了同一个基础库的两个不兼容版本,导致运行时报 NoSuchMethodError,无法启动。
  3. 底层框架原理一直是黑盒:Tomcat 是如何隔离不同 Web 应用依赖的?JDBC 是怎么不主动硬编码就能发现 MySQL 驱动的?Arthas 或热部署(Hot Reload)工具到底是怎么把修改后的代码替换到运行中的 JVM 里的?

这篇文章,就是要把 Java 程序的“出生机制”——类加载与双亲委派模型,揉碎了、白话式地讲透。懂了它,你就拥有了排查复杂依赖问题和理解各类中间件底层的“上帝视角”。


二、 核心原理

要讲透机制,首先必须明确两个核心概念。

1. 类的生命周期

当我们在代码里写下 new MyObject() 时,JVM 并不是凭空变出这个对象的。它必须先把 MyObject.class 这个二进制文件的数据,读取到内存的“方法区”中,并在堆上创建一个对应的 java.lang.Class 大管家对象。 这个过程通常分为三个核心大阶段:加载(Loading) -> 链接(Linking,含验证、准备、解析) -> 初始化(Initialization)。而我们平时常说的“类加载器(ClassLoader)”,主要接管的就是第一步:加载

2. 双亲委派模型究竟是什么?

其实“双亲委派”是一个典型的翻译背锅词汇。它的英文原名叫 Parents Delegation Model。这里有两个极易误解的点:

  • 没有“双亲”,只有“单向层级”:这里的 Parent 指的是类加载器对象内部的一个 parent 属性(即指向父加载器的引用),它不是继承关系(extends),而是组合关系
  • 什么是“委派”?:当一个类加载器接到了加载某个类的差事,它第一反应绝对不是自己去加载,而是做甩手掌柜,把这个任务“委派”给它的父加载器。父加载器收到后,也向自己的父加载器委派。
  • 一直委派到最顶层的引导类加载器(Bootstrap ClassLoader)。如果顶层加载器说:“这活儿我干不了,找不到这个类”,任务才会被驳回给下一级,子加载器这时候才会迫不得已尝试自己去查找并加载。

为什么要这么设计? 核心是为了安全隔离避免重复加载。 试想一下,如果黑客在你的项目里写了一个包含恶意代码的 java.lang.String 类。如果没有双亲委派,JVM 就会加载黑客的 String 类,整个系统底裤都被看穿。 有了双亲委派,不管你业务代码写了什么伪造类,任务都会一路丢给最顶层的 Bootstrap ClassLoader。Bootstrap 一看:“java.lang 包归我管,我直接加载官方提供的规范类就行了。” 于是黑客的伪造类永远没有机会被加载到内存中。这就构成了 Java 的沙箱安全机制


三、 流程/机制描述

在 JVM 初始化时,默认会构建一个层级分明的类加载器体系。它们各司其职,管辖范围严格划分:

  1. Bootstrap ClassLoader(引导类加载器): 最高层大佬。由 C++ 实现,深陷在 JVM 内核中。它负责加载 Java 核心类库,比如 $JAVA_HOME/jre/lib/rt.jar(包含了 java.util.*java.lang.* 等)。
  2. Extension ClassLoader(扩展类加载器): 二把手。负责加载 Java 的扩展类库,主要是 $JAVA_HOME/jre/lib/ext/ 目录下的 Jar 包。
  3. Application ClassLoader(应用程序类加载器): 干苦力的基层。负责加载当前应用 ClassPath(也就是你项目引入的各种三方依赖库和你自己写的代码)下的所有类。我们日常写的业务类,默认都是由它加载的。
  4. Custom ClassLoader(自定义类加载器): 根据业务需求,由开发者自己编写的代码(主要重写 findClass 方法)。

工作流转图示:

类加载器的层级结构与双亲委派流转图.png

具体执行机制:

  1. 比如代码碰到了 UserEntity 类,应用类加载器接到任务。
  2. 应用类加载器内部查缓存,没加载过?向上委派给扩展类加载器。
  3. 扩展类加载器查缓存,没加载过?向上委派给引导类加载器。
  4. 引导类加载器扫描核心库路径(rt.jar等),发现没有 UserEntity。抛回给扩展类加载器。
  5. 扩展类加载器扫描 ext 路径,发现也没有。抛回给应用类加载器。
  6. 应用类加载器扫描你的 ClassPath,找到了编译出的 UserEntity.class,读取字节流并转换为 Class 对象,加载成功。

四、 关键代码/示例

双亲委派的精髓,全都在 ClassLoader 这个骨架类的 loadClass 方法里。核心源码(抽取并简化核心逻辑)非常优美,堪称模板方法模式的典范:

1. JDK中体现双亲委派模型的核心源码骨架 (示意性逻辑,非可执行代码)

protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    // 首先,检查类是否已经被加载过了
    Class<?> c = findLoadedClass(name);
    if (c == null) {
        try {
            if (parent != null) {
                // 如果父加载器不为空,调用父级的loadClass向上委派
                c = parent.loadClass(name, false);
            } else {
                // 如果父加载器为空,说明已经到了顶层,调用BootstrapClassLoader
                c = findBootstrapClassOrNull(name);
            }
        } catch (ClassNotFoundException e) {
            // 父加载器抛出异常,说明父级无法完成加载
        }
        
        if (c == null) {
            // 父加载器都找不到,这才调用自身的 findClass 方法尝试自己加载
            c = findClass(name);
        }
    }
    return c;
} 
 

使用示例

1. 自定义类加载器
import java.io.ByteArrayOutputStream;
import java.io.InputStream;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.Paths;
 
public class MyCustomClassLoader extends ClassLoader {

    // 指定类文件所在的根路径
    private final String classRootPath;

    public MyCustomClassLoader(String classRootPath) {
        this.classRootPath = classRootPath;
    }

    /**
     * 我们绝不要去重写 loadClass 方法(这会破坏双亲委派的核心骨架),
     * 而是要重写 findClass 方法。当父加载器宣告失败兜底时,JVM 就会回调这里。
     */
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // 读取 Class 文件字节码到内存中
        byte[] classData = loadClassData(name);
        if (classData == null) {
            throw new ClassNotFoundException();
        }
        // 调用底层的 native 方法,把字节流转换为 JVM 中的 Class 对象
        return defineClass(name, classData, 0, classData.length);
    }

    // 从磁盘(或网络、数据库)加载字节数组的实现细节
    private byte[] loadClassData(String className) {
        // 把全限定名转换为路径,例如 com.example.Test -> com/example/Test.class
        String filePath = className.replace('.', '/') + ".class";
        Path path = Paths.get(classRootPath, filePath);

        try (InputStream is = Files.newInputStream(path);
             ByteArrayOutputStream bos = new ByteArrayOutputStream()) {

            byte[] buffer = new byte[1024];
            int len;
            while ((len = is.read(buffer)) != -1) {
                bos.write(buffer, 0, len);
            }
            return bos.toByteArray();
        } catch (Exception e) {
            // 异常统一下沉记录或处理
            e.printStackTrace();
            return null;
        }
    }
} 
 
2. 准备一个待加载的测试类
package cn.ctzw;

public class Test {

   public void hello() {
        System.out.println("业务逻辑执行:Hello!我是由自定义类加载器加载的!");
    }
}

并使用 javac Test.java 编译成 Test.class 文件。随后将它移动到你指定的磁盘路径下。

javac -encoding UTF-8 -d . Test.java
3. 使用自定义类加载器装载并执行
import java.lang.reflect.Method;

/**
 * @author jvs
 */
public class ClassLoaderTest {

    public static void main(String[] args) {
        try {
            // 1. 指定你的字节码文件存放的绝对或相对根路径
            String classRootPath = "/data/classes";
            // 2. 实例化自定义的类加载器
            MyCustomClassLoader myClassLoader = new MyCustomClassLoader(classRootPath);
            // 3. 触发类加载:使用底层的 loadClass 方法传入类的全限定名
            // 此时:由于 AppClassLoader 和 ExtClassLoader 在各自路径下都找不到这个类,
            // 兜底一圈后,最终会回调到我们重写的 findClass 方法中。
            Class<?> clazz = myClassLoader.loadClass("cn.ctzw.Test");
            // 4. 因为在当前代码的编译期我们并没有 Test 类,所以必须使用反射来实例化它
            Object instance = clazz.getDeclaredConstructor().newInstance();
            // 5. 拿到类身上的 hello 方法
            Method method = clazz.getMethod("hello");
            // 6. 执行目标方法
            method.invoke(instance);
            // 7. 打印出该类的实际加载器,验证结果
            System.out.println("当前 Test 类的类加载器是:" + clazz.getClassLoader().getClass().getSimpleName());
        } catch (Exception e) {
            // 异常统一下沉处理
            e.printStackTrace();
        }
    }
} 

当你运行这段代码时,控制台将输出:

业务逻辑执行:Hello!我是由自定义类加载器加载的!
当前 Test 类的类加载器是:MyCustomClassLoader

五、 常见误区

  1. 误区一:两个同名的类,只要全路径名一样,在机器上就是同一个类。 真相:在 JVM 中,类的唯一标识性是【全限定名】+【加载它的类加载器实例】。即使是同一份完全一模一样的 User.class 二进制文件,如果分别被 ClassLoader AClassLoader B 加载,它们在堆内存中产生的 Class 对象也是截然不同的两个。互相之间转换对象类型会直接抛出 ClassCastException!这是由于加载器命名空间不同造成的。这也是实现“依赖隔离”的基本盘。

  2. 误区二:双亲委派模型是绝对不能打破的神圣规则。 真相:并非如此。双亲委派只是一种“推荐规则”,并非强制性约束。实际上,Java 历史上经历了多次大规模“打破双亲委派”的真实场景:

    • SPI 机制(典型如 JDBC 驱动加载):核心的 java.sql.DriverManager 归 Bootstrap 老大加载。但在连接数据库时,它需要去实例化具体的、由第三方厂商提供并随便塞在 ClassPath 下的 MySQL 驱动实现类。这就是明显的“顶层加载器要求加载底层的类”。为了解决这个倒反天罡的问题,Java 引入了线程上下文类加载器(Thread Context ClassLoader, TCCL),底层相当于让父加载器去借用子加载器的手打破了双亲委派委派链。
    • Tomcat 容器实现:Tomcat 是主动选择破坏双亲委派的。每个 Web 应用下 WEB-INF/lib 里的类,Tomcat 的 WebappClassLoader 会优先自己加载,而不是无脑委派给父级。为什么?为了保证不同 WAR 包如果用了版本完全不同、但全限定名一样的类(如一个用 Spring 3,一个用 Spring 5)能够互不影响,彼此隔离。

六、 实际工作中怎么用

很多开发者觉得造轮子写加载器离自己很远,事实并非如此。理解底层,你可以解决以下高端场景局:

  1. 解决极其恶劣的依赖冲突(Jar Hell): 假如历史包袱太重,你的工程必须同时依赖 rocketmq-client-v1.jarrocketmq-client-v2.jar,而这两者底层类名一模一样但方法完全变了。依靠 Maven 的 exclude 排包根本行不通。这时可以怎么做?参考阿里 SOFAArk 或 OSGi 的思路,写两个自定义的类加载器,分别去读取 V1 和 V2 包。利用“不同类加载器加载的同名类完全隔离”的特性,将冲突强行隔绝在两堵墙之内。
  2. 实现插件化系统或热部署(Hot Reload): 如何让线上系统不重启的情况下更新某个逻辑类? JVM 其实不支持同一个类加载器把同名类重复卸载和加载。但我们可以取巧:当需要更新代码时,直接把负责加载该组件的那个自定义 ClassLoader 实例废弃掉,被当作垃圾回收。然后用一个新的自定义加载器实例去读取最新的 .class 文件。这正是 JRebel 以及大多数热修复工具更新类的核心机制之一。
  3. 加密与防反编译: 核心敏感代码容易被竞品拿去 jd-gui 丢反编译工具看光?可以先用 AES 把 .class 文件加密成打乱的二进制。然后自定义一个 ClassLoader,在 findClass 读取文件后,先走一遍 AES 解密,再调用底层的 defineClass。这样,你的程序能正常运行,但直接拿你包出去解压出来看的人只会得到一堆不知所云的乱码。