Java 类加载机制双亲委派与自定义类加载器

发布于:2025-09-14 ⋅ 阅读:(19) ⋅ 点赞:(0)

我们来深入解析 Java 类加载机制。这是理解 Java 应用如何运行、如何实现插件化、以及解决一些依赖冲突问题的关键。

一、核心概念:类加载过程

一个类型(包括类和接口)从被加载到虚拟机内存开始,到卸载出内存为止,它的整个生命周期会经历加载(Loading)、连接(Linking)、初始化(Initialization)、使用(Using)和卸载(Unloading) 五个阶段。其中连接阶段又分为验证(Verification)、准备(Preparation)、解析(Resolution) 三步。

  • 加载: 查找并加载类的二进制字节流(如 .class 文件),并为其在方法区创建一个 java.lang.Class 对象作为访问入口。

  • 验证: 确保被加载的类的正确性和安全性,如文件格式、元数据、字节码、符号引用验证。

  • 准备: 为类变量(static 变量) 分配内存并设置默认初始值(零值)。例如 public static int value = 123; 在此阶段后 value 为 0

  • 解析: 将常量池内的符号引用替换为直接引用的过程。

  • 初始化: 执行类构造器 <clinit>() 方法的过程,该方法由编译器自动收集类中所有类变量的赋值动作静态语句块(static{}块) 中的语句合并产生。此时 value 才会被赋值为 123

重要原则:《Java虚拟机规范》严格规定了有且只有以下六种情况必须立即对类进行“初始化”:

  1. 遇到 newgetstaticputstaticinvokestatic 这四条字节码指令时。

  2. 使用 java.lang.reflect 包的方法对类进行反射调用时。

  3. 当初始化一个类时,发现其父类还未初始化,需先触发其父类的初始化。

  4. 虚拟机启动时,用户指定的主类(包含 main() 方法的那个类)。

  5. 当使用 JDK 7 新加入的动态语言支持时...

  6. 当一个接口中定义了 JDK 8 新加入的默认方法(default方法)时...


二、双亲委派模型 (Parents Delegation Model)

Java 虚拟机如何决定由哪个类加载器来加载一个类?答案就是双亲委派模型。

1. 类加载器层次结构

JVM 提供了三层类加载器:

  • 启动类加载器 (Bootstrap ClassLoader)

    • C++ 实现,是 JVM 自身的一部分。

    • 负责加载 <JAVA_HOME>/lib 目录下的核心类库(如 rt.jarcharsets.jar)或被 -Xbootclasspath 参数指定的路径中的类。

    • 所有类加载器的父加载器

  • 扩展类加载器 (Extension ClassLoader)

    • Java 实现,继承自 ClassLoader

    • 负责加载 <JAVA_HOME>/lib/ext 目录下,或由 java.ext.dirs 系统变量指定的路径中的所有类库。

  • 应用程序类加载器 (Application ClassLoader)

    • Java 实现,继承自 ClassLoader

    • 也叫系统类加载器 (System ClassLoader)

    • 负责加载用户类路径 (ClassPath) 上的所有类库。

    • 是程序中默认的类加载器ClassLoader.getSystemClassLoader() 返回的就是它。

除了这三个,用户还可以自定义类加载器。

2. 双亲委派的工作流程

当一个类加载器收到了类加载请求时,它不会自己去尝试加载,而是会把这个请求委托给父类加载器去完成。每一层的类加载器都是如此,因此所有的加载请求最终都应该传送到顶层的启动类加载器中。只有当父加载器反馈自己无法完成这个加载请求(它的搜索范围中没有找到所需的类)时,子加载器才会尝试自己去加载。

流程可以概括为:自底向上检查类是否已加载 -> 自顶向下尝试加载类。

图表

3. 双亲委派的优点
  1. 避免类的重复加载: 确保一个类在 JVM 中全局唯一。无论哪一个类加载器要加载这个类,最终都是委派给最顶层的启动类加载器去加载,从而保证了类的唯一性。

  2. 保证核心 API 的安全: 防止用户自定义一个核心类(例如 java.lang.String)来替换掉 Java 核心库中的类,从而确保了 Java 核心库的安全性和稳定性。例如,即使用户写了一个 java.lang.Object 类并放在 ClassPath 中,最终也只会由 Bootstrap ClassLoader 去加载核心库中的 Object 类,用户的类不会被加载。


三、打破双亲委派模型

双亲委派模型不是一个强制性的约束,而是 Java 设计者推荐给开发者的一种类加载器实现方式。在某些特定场景下,需要打破这个模型。

1. 历史原因:自身的缺陷

双亲委派模型很好地解决了各个类加载器协作时基础类型的一致性问题(越基础的类由越上层的加载器进行加载)。但如果基础类型要调用回用户的代码,双亲委派模型就无法解决了。一个典型的例子就是 JNDI(Java Naming and Directory Interface),它的代码由启动类加载器加载,但需要调用由独立厂商实现并部署在应用程序 ClassPath 下的 JNDI 服务提供者接口(SPI)的代码。启动类加载器不可能“认识”这些用户代码

2. 解决方案:线程上下文类加载器 (Thread Context ClassLoader)

为了解决这个问题,Java 引入了线程上下文类加载器。这个类加载器可以通过 java.lang.Thread.setContextClassLoader() 方法进行设置,如果创建线程时未设置,它将会从父线程中继承一个;如果在应用程序的全局范围内都没有设置过,那默认就是应用程序类加载器。

SPI(Service Provider Interface)机制(如 JDBC)就利用了这个方案:

  1. java.sql.DriverManager(由 Bootstrap ClassLoader 加载)在初始化时,会通过 ServiceLoader 去加载 java.sql.Driver 接口的实现类。

  2. ServiceLoader 的 load 方法会使用线程上下文类加载器(默认是 AppClassLoader)去加载 META-INF/services 目录下的配置文件中的实现类。

  3. 这样,位于 ClassPath 下的第三方数据库驱动包(如 mysql-connector-java.jar)中的实现类(如 com.mysql.cj.jdbc.Driver)就能被成功加载并注册了。

这个过程可以看作是:父类加载器(Bootstrap)请求子类加载器(AppClassLoader)来完成类加载动作,这种行为实际上打破了双亲委派模型的层次结构,是一种逆向的委托。

3. 其他打破双亲委派的场景
  • 热部署、热替换: 例如 OSGi、JSP 应用服务器(如 Tomcat)。每个模块(Bundle)都有自己的类加载器,当需要更换一个 Bundle 时,就把 Bundle 连同类加载器一起换掉,从而实现代码的热替换。

  • 实现应用隔离: 例如 Tomcat 为每个 Web 应用创建一个独立的 WebappClassLoader,优先加载 /WEB-INF/ 下的类,应用之间互不干扰,这同样打破了双亲委派(它没有先委托给父加载器,而是自己先尝试加载)。


四、自定义类加载器

1. 为什么要自定义?
  • 从非标准来源加载类(如网络、加密的字节流)。

  • 实现类的隔离和热部署。

  • 修改类的加载方式(如打破双亲委派)。

2. 如何实现?

通常不直接重写 loadClass() 方法(因为该方法实现了双亲委派逻辑),而是重写 findClass(String name) 方法

步骤

  1. 继承 java.lang.ClassLoader

  2. 重写 findClass 方法:

    • 在这个方法中,根据指定的类名(如 com.example.MyClass)去查找类的字节码(byte[])。

    • 找到后,调用父类的 defineClass(byte[] b, int off, int len) 方法,将字节数组转换为 Class 对象。

  3. (可选)如果要打破双亲委派,可以重写 loadClass 方法,实现自己的加载逻辑。

示例代码框架

java

public class MyClassLoader extends ClassLoader {
    private String classPath; // 自定义的类查找路径

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // 1. 根据 name 和 classPath,找到 .class 文件,读取为 byte[]
        byte[] classData = loadClassData(name);
        if (classData == null) {
            throw new ClassNotFoundException();
        } else {
            // 2. 调用 defineClass,将字节码定义为 Class 对象
            return defineClass(name, classData, 0, classData.length);
        }
    }

    private byte[] loadClassData(String name) {
        // 实现从自定义路径(文件、网络等)加载类的字节码的逻辑
        // 将包名中的 '.' 替换为路径分隔符 '/'
        String path = name.replace('.', '/').concat(".class");
        path = classPath + "/" + path;
        // ... 读取文件并返回 byte[]
        return data;
    }
}

总结

概念 核心要点
双亲委派模型 保证核心类安全、避免重复加载。工作流程:子加载器委托父加载器加载,父加载器无法完成时子加载器才自己加载。
打破双亲委派 SPI 等场景需要父加载器请求子加载器完成加载。通过线程上下文类加载器实现,是 Java 生态扩展性的重要基础。
自定义类加载器 重写 findClass 方法,从特定来源获取字节码后调用 defineClass。常用于加载非标准来源的类、实现隔离和热部署。

理解类加载机制,尤其是双亲委派和打破它的场景,对于解决日常开发中的 ClassNotFoundException、NoClassDefFoundError、依赖冲突以及构建模块化系统都至关重要。