Skip to content

02.3 - 自定义类加载器实战

定位: 掌握自定义 ClassLoader 的原理和实现,理解 Java 类加载的扩展点 面试高频度: ⭐⭐⭐⭐ 考查方式: 手写 ClassLoader、findClass vs loadClass、defineClass 原理、热部署方案

一、这是什么?为什么需要它?

标准 ClassLoader 的局限

Application ClassLoader 只能从 classpath 上的文件系统和 JAR 包加载类。但实际开发中有更多加载需求:

需求场景标准加载器能否满足
从网络加载Applet、远程热部署不能
从数据库加载配置中心、元数据驱动不能
从加密文件加载防止反编译的商用框架不能
隔离同名类中间件(Tomcat、OSGI)不能
热加载/热部署开发期热替换、生产环境不停机更新不能

自定义 ClassLoader 就是为这些场景而生——它让你完全控制"字节流从哪里来"。

隔离同名类的核心原理

JVM 中一个类的唯一标识是 (ClassLoader, 全限定名)。所以两个不同的 ClassLoader 可以加载完全相同的类,而 JVM 视其为两个不同的类型:

ClassLoader A → 加载 com.example.Foo → ClassA_Foo  (内存地址 0x1000)
ClassLoader B → 加载 com.example.Foo → ClassB_Foo  (内存地址 0x2000)

ClassA_Foo ≠ ClassB_Foo   ← JVM 认为是不同类型!

这正是 Tomcat 隔离不同 Web 应用、OSGI 隔离不同 Bundle 的底层原理。

二、原理拆解

2.1 findClass vs loadClass:职责分离的设计模式

┌────────────────────────────────────────────────────────────┐
│                ClassLoader 核心方法协作                       │
│                                                            │
│  loadClass(name)    ← 模板方法 (Template Method Pattern)    │
│      │                                                     │
│      ├─ ① findLoadedClass(name) → 检查是否已加载            │
│      │                                                     │
│      ├─ ② parent.loadClass(name)  → 委派父加载器             │
│      │      ↓ (父加载器无法加载 → ClassNotFoundException)    │
│      │                                                     │
│      └─ ③ findClass(name) → 自己加载                        │
│               │                                             │
│               ▼                                             │
│     findClass(name)    ← 钩子方法,子类重写                   │
│         │                                                   │
│         ├─ ① 获取字节流 (从文件/网络/数据库…)                 │
│         │                                                   │
│         ├─ ② defineClass(name, bytes, 0, len)               │
│         │      ↓                                            │
│         │  将字节流 → JVM 内部 Class 对象                    │
│         │  括号完成: 验证字节流格式 + 分配方法区元数据         │
│         │   + 在堆上创建 Class<?> 对象                       │
│         │                                                   │
│         └─ ③ 返回 Class<?>                                   │
└────────────────────────────────────────────────────────────┘

为什么设计成两个方法?

  • loadClass模板方法,实现了"检查→委派→自加载"的标准流程。一般来说子类不需要改它——改了就等于打破了双亲委派
  • findClass钩子方法,只负责"从哪里获取字节流"。子类只需重写这个,不用关心委派逻辑

如果需要打破双亲委派(如 Tomcat),才去重写 loadClass。绝大多数场景只需重写 findClass

2.2 defineClass 的原理

defineClass 是 ClassLoader 中最关键的原生方法——它处在 Java 层和 JVM 内部的交界处

defineClass(字节流)                                 JVM 内部
      │                                             ┌──────────────┐
      │  Java 层                                    │  验证字节流   │
      │                                             │  格式合法?    │
      ▼                                             └──────┬───────┘
┌──────────────────┐                                       │
│ 调用 native 方法   │         ┌──────────────────────────┐  │
│ defineClass0()    │ ──────→ │ 解析字节流 → 方法区数据结构 │  │
│ (JNI 调用)        │         │ ├ 常量池展开为运行时常量池   │  │
└──────────────────┘         │ ├ 字段表、方法表创建元数据 │  │
                             │ ├ 访问权限、版本号检查     │  │
                             │ └ 分配 vtable/itable 空间  │  │
                             └──────────────────────────┘  │
                                      │                     │
                                      ▼                     │
                             ┌──────────────────────────┐   │
                             │ 堆上创建 Class<?> 对象     │   │
                             │ Class 对象中持有指向       │   │
                             │ 方法区类元数据的指针       │   │
                             └──────────────────────────┘   │
                                      │                     │
                                      ▼                     │
                             Class 对象返回给 Java 层        │
                             └──────────────────────────────┘

defineClass 会触发"验证阶段"的文件格式验证和字节码验证。所以自定义 ClassLoader 不需要显式调用验证——defineClass 内部已经做了。

2.3 resolveClass 的作用

resolveClass 是可选的,它触发链接阶段(验证→准备→解析)。但不是必须立即执行——可以延迟解析(迟到绑定)。

java
protected Class<?> loadClass(String name, boolean resolve) {
    // ...
    if (resolve) {
        resolveClass(c);  // 链接(如果 resolve=true)
    }
    return c;
}

实现自定义 ClassLoader 时,通常不需要在 findClass 中调用 resolveClass,让调用方决定是否需要链接。标准的模板——loadClass(name, true) 会完成链接,loadClass(name, false) 不会。

三、图解全景

┌─────────────────────────────────────────────────────────────────────┐
│              自定义 ClassLoader 完整加载流程                          │
│                                                                     │
│  外部请求: loader.loadClass("com.example.MyService")                  │
│      │                                                               │
│      ▼                                                               │
│  ┌─ ClassLoader.loadClass() 模板方法 ──────────────────────────────┐ │
│  │  ① findLoadedClass("com.example.MyService") → 已加载? → 直接返回  │ │
│  │  ② parent.loadClass(...) → 父加载器能加载? → 父加载器返回         │ │
│  │  ③ findClass("com.example.MyService") → 自己加载                 │ │
│  └─────────────────────────────────────────────────────────────────┘ │
│      │                                                               │
│      ▼                                                               │
│  ┌─ 我们重写的 findClass() ─────────────────────────────────────────┐ │
│  │  ① 从加密文件读取字节流 → 解密                                    │ │
│  │  ② defineClass(name, decryptedBytes, 0, len)                    │ │
│  │  ③ 返回 Class<?>                                                 │ │
│  └─────────────────────────────────────────────────────────────────┘ │
│      │                                                               │
│      ▼                                                               │
│  ┌─ defineClass (原生方法) ──────────────────────────────────────────┐│
│  │  → 验证字节流格式                                                  ││
│  │  → 在方法区创建类元数据                                            ││
│  │  → 在堆上创建 Class<?> 对象                                        ││
│  │  → 返回 Class<?> 引用                                             ││
│  └───────────────────────────────────────────────────────────────────┘│
│                                                                       │
│  resolveClass(c)  → 完成链接(可选,默认懒加载)                         │
└───────────────────────────────────────────────────────────────────────┘

四、实战验证

4.1 从文件系统加载加密的 Class 文件

java
// EncryptedClassLoader.java
import java.io.*;
import java.security.*;

public class EncryptedClassLoader extends ClassLoader {

    private final String classPath;
    private final byte key;  // XOR 加密秘钥(简单演示用,实际应使用 AES 等)

    public EncryptedClassLoader(String classPath, byte key, ClassLoader parent) {
        super(parent);
        this.classPath = classPath;
        this.key = key;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // ① 将包名转为文件路径
        String fileName = name.replace('.', File.separatorChar) + ".class";
        File classFile = new File(classPath, fileName);

        if (!classFile.exists()) {
            throw new ClassNotFoundException(name + " not found at " + classFile);
        }

        try {
            // ② 读取加密字节流
            byte[] encryptedBytes = readFile(classFile);
            System.out.println("Read " + encryptedBytes.length + " encrypted bytes for " + name);

            // ③ 解密
            byte[] decryptedBytes = decrypt(encryptedBytes);
            System.out.println("Decrypted " + decryptedBytes.length + " bytes");

            // ④ defineClass 将字节流 → Class 对象
            return defineClass(name, decryptedBytes, 0, decryptedBytes.length);
        } catch (IOException e) {
            throw new ClassNotFoundException(name, e);
        }
    }

    private byte[] readFile(File file) throws IOException {
        FileInputStream fis = new FileInputStream(file);
        ByteArrayOutputStream baos = new ByteArrayOutputStream();
        byte[] buffer = new byte[4096];
        int len;
        while ((len = fis.read(buffer)) != -1) {
            baos.write(buffer, 0, len);
        }
        fis.close();
        return baos.toByteArray();
    }

    private byte[] decrypt(byte[] data) {
        // 简单的 XOR 解密(每个字节异或 key)
        // 实际中应用标准的 AES 等算法
        byte[] result = new byte[data.length];
        for (int i = 0; i < data.length; i++) {
            result[i] = (byte) (data[i] ^ key);
        }
        return result;
    }
}

配合使用:

java
// EncryptedClassLoaderDemo.java
public class EncryptedClassLoaderDemo {
    public static void main(String[] args) throws Exception {
        // 假设我们有一个加密后的 HelloService.class
        // 先用 XOR 57 加密
        String classPath = "./encrypted-classes";

        EncryptedClassLoader loader = new EncryptedClassLoader(
            classPath, (byte) 0x57, ClassLoader.getSystemClassLoader()
        );

        Class<?> clazz = loader.loadClass("com.example.HelloService");
        Object instance = clazz.getDeclaredConstructor().newInstance();

        // 调用方法(通过反射)
        clazz.getMethod("sayHello").invoke(instance);
    }
}

4.2 热部署原理演示

java
// HotSwapDemo.java —— 模拟热部署
public class HotSwapDemo {
    public static void main(String[] args) throws Exception {
        String classPath = "./hotswap-classes";

        // 第一版: 用旧 ClassLoader 加载
        ClassLoader loader1 = new EncryptedClassLoader(classPath, (byte) 0x42,
            ClassLoader.getSystemClassLoader());
        Class<?> clazz1 = loader1.loadClass("com.example.MyService");
        Object inst1 = clazz1.getDeclaredConstructor().newInstance();
        System.out.println("Version 1: " + clazz1.getClassLoader());
        clazz1.getMethod("doWork").invoke(inst1);

        // 模拟:更新了 class 文件

        // 第二版: 用新的 ClassLoader 重新加载(旧的 ClassLoader 和 Class 对象仍存在)
        ClassLoader loader2 = new EncryptedClassLoader(classPath, (byte) 0x42,
            ClassLoader.getSystemClassLoader());
        Class<?> clazz2 = loader2.loadClass("com.example.MyService");
        Object inst2 = clazz2.getDeclaredConstructor().newInstance();
        System.out.println("Version 2: " + clazz2.getClassLoader());

        // 证明两个是不同类型的
        System.out.println("Same class? " + clazz1.equals(clazz2));  // false

        // 跨加载器赋值会抛 ClassCastException
        try {
            com.example.MyService s = (com.example.MyService) inst2;
        } catch (ClassCastException e) {
            System.out.println("Cross-loader cast fails: " + e.getMessage());
        }
    }
}

热部署的关键: 用新的 ClassLoader 加载新版本的类,旧版本的 ClassLoader 和旧类(可能还在被旧对象引用)继续运行直到 GC。新旧版本共存,通过淘汰旧对象来实现平滑升级。

五、面试视角

追问答案要点
自定义 ClassLoader 需要重写哪两个方法?只需要重写 findClass(获取字节流 → defineClass)。如果要打破双亲委派才需要重写 loadClass
findClassloadClass 的职责分别是什么?loadClass 是模板方法,实现"检查已加载→委派父加载器→自加载"的标准流程。findClass 是钩子方法,子类通过它自定义字节流来源
defineClass 做了什么?它是 native 方法,是 Java 层到 JVM 内部的边界。接收字节流后:验证格式 → 在方法区创建类元数据 → 在堆上创建 Class<?> 对象
什么是 resolveClass?必须调用吗?resolveClass 触发链接阶段(验证→准备→解析)。不是必须的,JVM 允许延迟解析(迟到绑定)
同一个 .class 文件被两个 ClassLoader 加载会怎样?JVM 视其为两个完全不同的类型。classA == classB 为 false,跨加载器类型转换抛 ClassCastException
为什么自定义 ClassLoader 能实现热部署?新的 ClassLoader 加载新版本的类,旧版本的类继续运行。新旧 ClassLoader 互不干扰,旧对象 GC 后旧 Class 会被卸载(如果 ClassLoader 本身也可达 GC)
ClassNotFoundExceptionNoClassDefFoundError 的区别?ClassNotFoundException 是异常,通常在 loadClass()Class.forName() 显式加载类时抛出(类在 classpath 上不存在)。NoClassDefFoundError 是错误,编译期存在的类运行时找不到(通常在类加载成功后其依赖的另一个类找不到时触发)
在自定义 ClassLoader 中如何打破双亲委派?重写 loadClass 方法,改变委派顺序——不再先委派父加载器,而是先尝试自己加载。Tomcat 的 WebappClassLoader 就是如此

📚 相关链接

  • **类加载生命周期** — 自定义 ClassLoader 控制加载阶段的字节流来源
  • **双亲委派模型与打破** — findClass 保持委派,loadClass 打破委派
  • **Tomcat与Spring类加载分析** — Tomcat WebappClassLoader 是自定义 ClassLoader 的经典实现
  • ← 返回 **类加载索引**

Knowledge4J — Java 知识库