摘要

类加载机制是 JVM 将磁盘上的 .class 字节码文件转化为内存中可执行类对象的完整过程。本文深入解析类加载的五个核心阶段(加载→验证→准备→解析→初始化),揭示双亲委派模型(Parent Delegation Model)的工作原理与设计意图,探讨破坏双亲委派的三种合法场景(JDBC/JNDI/OSGi/模块化热部署),并通过自定义 ClassLoader 实战和常见类加载问题(ClassNotFoundException vs NoClassDefFoundError、类冲突)帮助读者建立对类加载机制的完整认知。掌握本文内容,是解决框架冲突、热部署、插件化架构等工程难题的必备基础。


引言

你有没有遇到过这些"诡异"的问题:

  • 明明 classpath 里有这个 jar,却在运行时报 ClassNotFoundException
  • 两个框架都依赖同一个库的不同版本,导致 NoSuchMethodError
  • 热部署后内存泄漏、元空间不断增长?
  • Tomcat 里不同应用可以使用同一个类的不同版本,是怎么做到的?

这些问题的答案,都在类加载机制里。

类加载是 JVM 整个运行时体系的"入口",所有 Java 程序的执行都始于此。理解它,不仅能帮你排查上述问题,更能让你在设计插件系统、热更新方案、框架隔离时做出正确决策。


一、类加载的五个阶段

1.1 总体流程

一个 .class 文件从磁盘到可以被 JVM 执行,要经历以下五个阶段:

.class 文件
    │
    ▼
┌─────────────────────────────────────────────────────────┐
│                    类加载生命周期                           │
│                                                          │
│  ┌──────┐  ┌──────┐  ┌──────┐  ┌──────┐  ┌──────────┐  │
│  │ 加载  │→│ 验证  │→│ 准备  │→│ 解析  │→│  初始化   │  │
│  │Loading│  │Verify│  │Prepare│  │Resolve│  │   Init   │  │
│  └──────┘  └──────┘  └──────┘  └──────┘  └──────────┘  │
│                                                          │
│  ◀──────────────── 连接阶段 (Linking) ────────────────▶  │
│                                                          │
│  注:解析阶段可以在初始化后进行(支持动态绑定)                 │
└─────────────────────────────────────────────────────────┘
    │
    ▼
 类对象在方法区就绪,可以创建实例

1.2 第一阶段:加载(Loading)

做什么:把字节码二进制流读取到内存,在方法区创建类的数据结构,在堆中生成对应的 java.lang.Class 对象(作为访问入口)。

三件事

  1. 通过类的全限定名获取字节码(来源不限于文件,可以是网络、数据库、加密文件、动态生成)
  2. 将字节流所代表的静态存储结构转化为方法区的运行时数据结构
  3. 在堆中生成 Class 对象,作为该类的元数据访问入口

字节码来源多样性

// 常见来源:
// 1. 本地文件系统 .class 文件
// 2. JAR/WAR/ZIP 包
// 3. 网络传输(Applet,历史遗物)
// 4. 动态生成(CGLib/Javassist/ASM 在运行时生成)
// 5. 数据库(如 JDBC 驱动曾有此方案)
// 6. 加密字节码(反破解方案)

1.3 第二阶段:验证(Verification)

做什么:确保字节码内容合法合规,防止恶意字节码危害 JVM。

四层验证

验证类型 验证内容 示例
文件格式验证 魔数是否为 0xCAFEBABE,版本号是否兼容 .class 文件开头必须是 CA FE BA BE
元数据验证 类的继承关系是否合法(父类不能是 final 类) 不能继承 String 等 final 类
字节码验证 操作数栈和局部变量类型是否匹配,控制流是否正确 int 操作数不能用 double 指令处理
符号引用验证 被引用的类/方法/字段是否存在且有访问权限 调用 private 方法→抛 IllegalAccessError

💡 性能提示:验证阶段是类加载中最耗时的一步。如果你对 jar 包来源完全信任(如自己打包的 jar),可以用 -Xverify:none 跳过验证,加速启动(生产环境慎用!)

1.4 第三阶段:准备(Preparation)

做什么:为类的静态变量分配内存(在方法区/元空间)并赋零值,注意不是代码中写的初始值。

public class Demo {
    // 准备阶段:value = 0(零值),不是 100
    // 初始化阶段:value = 100(执行赋值字节码)
    static int value = 100;
    
    // 特例:final static 常量(编译期常量)在准备阶段直接赋 123
    static final int CONST = 123;
}

各类型零值对照

数据类型 零值
int 0
long 0L
float 0.0f
double 0.0d
boolean false
char ‘\u0000’
引用类型 null

1.5 第四阶段:解析(Resolution)

做什么:将常量池中的符号引用(Symbolic Reference,文本形式的类名/方法名/字段名)替换为直接引用(内存地址)。

符号引用(编译期):
  "com.example.UserService.findById(I)Lcom/example/User;"

        ↓ 解析

直接引用(运行期):
  方法在内存中的实际入口地址(如 0x7f3a8b4c0)

解析的对象

  • 类和接口的解析
  • 字段的解析
  • 类方法的解析
  • 接口方法的解析

💡 延迟解析:JVM 规范允许解析发生在初始化之后(即懒解析),这是实现动态绑定(多态)的基础。

1.6 第五阶段:初始化(Initialization)

做什么:执行类的 <clinit>() 方法——这是由编译器自动合成的方法,包含所有静态变量赋值语句和静态代码块,按源文件中的顺序执行。

public class InitDemo {
    static int a = 10;          // ① 赋值 a=10
    
    static {
        a = 20;                 // ② 修改 a=20
        System.out.println("静态代码块执行");  // ③
    }
    
    static int b = a * 2;       // ④ b = 40(此时 a=20)
    
    // 编译器生成的 <clinit>() 方法等价于:
    // a = 10; a = 20; print(); b = a*2;
}

六种触发初始化的时机(主动引用)

  1. new 实例化对象、读写静态字段、调用静态方法
  2. 用反射 Class.forName("xxx") 且未初始化
  3. 初始化子类时,先触发父类初始化
  4. JVM 启动时指定的主类(含 main() 方法)
  5. JDK 7+ 中 MethodHandle 解析到的类
  6. JDK 11+ 中定义了 default 方法的接口,实现类初始化时触发接口初始化

不会触发初始化的场景(被动引用)

// 1. 通过子类引用父类静态字段,只初始化父类
System.out.println(Child.parentStaticField);

// 2. 通过数组定义引用类,不会触发类初始化
SuperClass[] arr = new SuperClass[10];

// 3. 引用编译期常量,不会触发类初始化(常量已内联到调用方字节码)
System.out.println(ConstClass.CONST_VALUE);

二、类加载器体系

2.1 类加载器的层次结构

JVM 内置三层类加载器,形成父子关系(通过组合而非继承):

           ┌────────────────────────────────────────┐
           │    Bootstrap ClassLoader(启动类加载器)  │
           │    - C++ 实现,JVM 核心的一部分           │
           │    - 加载:$JAVA_HOME/jre/lib/*.jar      │
           │      (rt.jar, charsets.jar...)           │
           │    - Java 代码中获取:null               │
           └────────────────┬───────────────────────┘
                            │(父子关系,非继承)
           ┌────────────────▼───────────────────────┐
           │    Extension ClassLoader(扩展类加载器)  │
           │    - Java 实现(sun.misc.Launcher)       │
           │    - 加载:$JAVA_HOME/jre/lib/ext/*.jar  │
           │    - 或 java.ext.dirs 系统属性指定目录    │
           └────────────────┬───────────────────────┘
                            │
           ┌────────────────▼───────────────────────┐
           │    Application ClassLoader(应用类加载器)│
           │    - Java 实现,也叫系统类加载器           │
           │    - 加载:classpath 下的类              │
           │    - ClassLoader.getSystemClassLoader() │
           └────────────────┬───────────────────────┘
                            │
           ┌────────────────▼───────────────────────┐
           │         自定义 ClassLoader               │
           │    - 继承 ClassLoader,重写 findClass()   │
           │    - 用于热部署、加密类、插件系统           │
           └────────────────────────────────────────┘

⚠️ JDK 9 模块化后,Extension ClassLoader 改为 Platform ClassLoader,但核心机制相同。

2.2 双亲委派模型(Parent Delegation Model)

这是 JVM 类加载最核心的设计——当一个类加载器收到加载请求时,先把请求委托给父加载器,只有父加载器无法加载时,才由自己加载。

工作流程

自定义 ClassLoader 收到加载请求
    │
    ├──→ 委托给 Application ClassLoader
    │           │
    │           ├──→ 委托给 Extension ClassLoader
    │           │           │
    │           │           ├──→ 委托给 Bootstrap ClassLoader
    │           │           │           │
    │           │           │           ├── 在核心库中找到?→ 加载成功 ✅
    │           │           │           └── 没找到 ↓
    │           │           │
    │           │           ├── 在扩展目录中找到?→ 加载成功 ✅
    │           │           └── 没找到 ↓
    │           │
    │           ├── 在 classpath 中找到?→ 加载成功 ✅
    │           └── 没找到 ↓
    │
    └── 在自定义路径中找到?→ 加载成功 ✅
        没找到 → 抛 ClassNotFoundException ❌

源码逻辑(ClassLoader.loadClass 核心逻辑)

protected Class<?> loadClass(String name, boolean resolve) throws ClassNotFoundException {
    synchronized (getClassLoadingLock(name)) {
        // 1. 先检查是否已经加载过
        Class<?> c = findLoadedClass(name);
        if (c == null) {
            try {
                // 2. 委托给父加载器(双亲委派核心)
                if (parent != null) {
                    c = parent.loadClass(name, false);
                } else {
                    // 父加载器为null,委托给Bootstrap
                    c = findBootstrapClassOrNull(name);
                }
            } catch (ClassNotFoundException e) {
                // 父加载器加载失败,继续往下
            }
            if (c == null) {
                // 3. 父加载器无法加载,自己尝试加载
                c = findClass(name);  // 子类应重写此方法
            }
        }
        if (resolve) {
            resolveClass(c);
        }
        return c;
    }
}

双亲委派的价值

  1. 安全性:防止用户自定义类冒充核心库(如自定义 java.lang.String),Bootstrap ClassLoader 永远优先加载核心库
  2. 唯一性:同一个类只会被加载一次,避免多份相同类在 JVM 中共存导致类型不兼容
  3. 稳定性:核心库不会被随意替换,JVM 行为可预期

三、打破双亲委派的三大场景

双亲委派是"建议"而非强制,某些场景下必须打破它:

3.1 场景一:SPI 机制(JDBC/JNDI)

问题java.sql.Driver 在核心库(Bootstrap 加载),但具体实现(如 com.mysql.jdbc.Driver)在 classpath(Application ClassLoader 加载)。Bootstrap ClassLoader 无法"向下"加载 classpath 中的类。

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

// JDBC DriverManager 核心加载逻辑
// java.sql.DriverManager 由 Bootstrap 加载
// 但需要加载 SPI 实现类(MySQL Driver)

// 解决方案:使用线程上下文类加载器
ClassLoader contextClassLoader = Thread.currentThread().getContextClassLoader();
// 上下文类加载器默认是 Application ClassLoader
// 通过它来加载 MySQL Driver 等 SPI 实现

// 这本质上是"父委托子",打破了双亲委派
ServiceLoader<Driver> loadedDrivers = ServiceLoader.load(Driver.class, contextClassLoader);

流程示意

Bootstrap CL(加载 DriverManager)
    │  需要加载 MySQL Driver
    │  但 Bootstrap 找不到
    ▼
Thread.currentThread().getContextClassLoader()
    │  = Application ClassLoader
    ▼
Application CL 加载 com.mysql.jdbc.Driver ✅

3.2 场景二:OSGi 模块化框架

OSGi(Eclipse 插件系统、Karaf 容器)将双亲委派改为网状结构:每个 Bundle(模块)有独立的 ClassLoader,模块间通过声明 Import/Export 包来控制类的可见性。

Bundle A ClassLoader ←──Export org.foo──→ Bundle B ClassLoader
         ↕                                          ↕
Bundle C ClassLoader ←──Import org.foo─────────────┘

这使得不同 Bundle 可以使用同一个类的不同版本而互不干扰。

3.3 场景三:热部署(Tomcat/Spring DevTools)

Tomcat 为每个 Web 应用创建独立的 WebAppClassLoader,打破双亲委派(优先自己加载而非委托父加载器),实现:

  • 不同 Web 应用可以有不同版本的同名类
  • 重新部署时销毁旧 ClassLoader,创建新 ClassLoader 重新加载
                   Common ClassLoader(Tomcat 共享类)
                          │
              ┌───────────┴───────────┐
              ▼                       ▼
   WebApp1 ClassLoader        WebApp2 ClassLoader
   (加载 /WEB-INF/classes)   (加载 /WEB-INF/classes)
   (加载 /WEB-INF/lib)       (加载 /WEB-INF/lib)
   
两个应用可以有 MyService.class 的不同版本,互不干扰

四、自定义 ClassLoader 实战

4.1 基础实现:加载自定义路径的类

import java.io.*;
import java.nio.file.*;

/**
 * 自定义类加载器:从指定目录加载 .class 文件
 */
public class FileSystemClassLoader extends ClassLoader {
    
    private final String classDir;
    
    public FileSystemClassLoader(String classDir) {
        // 指定父加载器为 Application ClassLoader(默认行为)
        super(ClassLoader.getSystemClassLoader());
        this.classDir = classDir;
    }
    
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        // 将类名转为文件路径(com.example.Foo → com/example/Foo.class)
        String filePath = classDir + File.separator 
                          + name.replace('.', File.separatorChar) + ".class";
        
        try {
            byte[] classBytes = Files.readAllBytes(Paths.get(filePath));
            // 核心方法:将字节码转为 Class 对象
            return defineClass(name, classBytes, 0, classBytes.length);
        } catch (IOException e) {
            throw new ClassNotFoundException("找不到类文件: " + filePath, e);
        }
    }
    
    public static void main(String[] args) throws Exception {
        FileSystemClassLoader loader = new FileSystemClassLoader("/tmp/classes");
        
        // 加载类
        Class<?> clazz = loader.loadClass("com.example.HelloWorld");
        
        // 验证类加载器
        System.out.println("ClassLoader: " + clazz.getClassLoader());
        // 输出:ClassLoader: FileSystemClassLoader@...
        
        // 调用方法
        Object instance = clazz.getDeclaredConstructor().newInstance();
        clazz.getMethod("sayHello").invoke(instance);
    }
}

4.2 进阶实现:加密类加载器

/**
 * 加密类加载器:解密后加载(简单 XOR 示例)
 */
public class EncryptedClassLoader extends ClassLoader {
    
    private static final byte XOR_KEY = 0x5A;  // 加密密钥
    
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        String filePath = name.replace('.', '/') + ".encrypted";
        
        try (InputStream is = getClass().getResourceAsStream("/" + filePath)) {
            if (is == null) throw new ClassNotFoundException(name);
            
            byte[] encrypted = is.readAllBytes();
            byte[] decrypted = decrypt(encrypted);  // 解密
            
            return defineClass(name, decrypted, 0, decrypted.length);
        } catch (IOException e) {
            throw new ClassNotFoundException(name, e);
        }
    }
    
    private byte[] decrypt(byte[] encrypted) {
        byte[] decrypted = new byte[encrypted.length];
        for (int i = 0; i < encrypted.length; i++) {
            decrypted[i] = (byte) (encrypted[i] ^ XOR_KEY);
        }
        return decrypted;
    }
}

4.3 热部署:类的卸载与重加载

/**
 * 热部署示例:监听文件变化,重新加载类
 */
public class HotDeployDemo {
    
    private volatile ClassLoader currentLoader;
    private volatile Class<?> currentClass;
    
    public void reload() throws Exception {
        // 旧的 ClassLoader 和 Class 会在 GC 时被回收
        // (前提:没有其他地方持有该类的引用)
        currentLoader = new FileSystemClassLoader("/hot/classes");
        currentClass = currentLoader.loadClass("com.example.HotService");
        System.out.println("类已重新加载,版本: " + getVersion());
    }
    
    private String getVersion() throws Exception {
        return (String) currentClass.getMethod("getVersion")
                                    .invoke(currentClass.getDeclaredConstructor().newInstance());
    }
}

⚠️ 类卸载条件:一个类要被 GC 卸载,必须同时满足:

  1. 该类所有实例已被回收
  2. 加载该类的 ClassLoader 已被回收
  3. 该类对应的 java.lang.Class 对象没有被任何引用

这就是为什么热部署不及时会导致元空间内存泄漏


五、类加载常见问题排查

5.1 ClassNotFoundException vs NoClassDefFoundError

这两个异常经常被混淆,本质不同:

特征 ClassNotFoundException NoClassDefFoundError
类型 受检异常(Exception) 错误(Error)
触发时机 运行时动态加载时找不到类 编译时存在,运行时 classpath 中不存在
常见来源 Class.forName()、反射调用 静态初始化失败、编译后 jar 被删除
典型场景 忘记把 jar 加入 classpath 静态块抛异常、运行时 jar 丢失
// ClassNotFoundException 示例
try {
    Class.forName("com.mysql.jdbc.Driver");  // jar 不在 classpath
} catch (ClassNotFoundException e) {
    System.err.println("MySQL 驱动未找到,请检查 classpath");
}

// NoClassDefFoundError 示例(编译时有,运行时无)
// 假设编译时有 Foo.class,但运行时 jar 被删除
Foo foo = new Foo();  // 抛 NoClassDefFoundError

5.2 类冲突(ClassCastException / LinkageError)

同名类被不同 ClassLoader 加载,JVM 认为它们是两个不同的类:

// 错误场景:同一个类被两个不同的 ClassLoader 加载
ClassLoader loader1 = new URLClassLoader(urls);
ClassLoader loader2 = new URLClassLoader(urls);

Class<?> clazz1 = loader1.loadClass("com.example.User");
Class<?> clazz2 = loader2.loadClass("com.example.User");

Object user1 = clazz1.newInstance();
// 虽然都叫 User,但它们是不同的 Class 对象!
clazz2.cast(user1);  // 抛 ClassCastException!!

排查思路

// 打印类加载器,确认是否为同一个
System.out.println(obj.getClass() + " loaded by " + obj.getClass().getClassLoader());

5.3 静态初始化异常导致的 ExceptionInInitializerError

public class ConfigLoader {
    // 静态初始化失败
    static final Properties props;
    
    static {
        props = new Properties();
        try {
            props.load(new FileInputStream("config.properties")); // 文件不存在
        } catch (IOException e) {
            throw new RuntimeException("配置加载失败", e);  // 抛出运行时异常
        }
    }
}

// 第一次使用 ConfigLoader 时:
// → ExceptionInInitializerError(包装了 RuntimeException)
// 
// 第二次使用 ConfigLoader 时(即使文件已存在):
// → NoClassDefFoundError(JVM 认为该类初始化已失败,不再重试)

六、JDK 9+ 模块化对类加载的影响

JDK 9 引入的模块系统(JPMS)对类加载带来了显著变化:

JDK 8 及以前                     JDK 9+
─────────────────────────────────────────────
Bootstrap ClassLoader           Bootstrap ClassLoader
  rt.jar(巨型jar)   →分解→      java.base 模块及其他核心模块

Extension ClassLoader            Platform ClassLoader
  ext/*.jar           →替换→      java.se 及平台模块

Application ClassLoader          Application ClassLoader
  classpath                       classpath + 模块路径

模块化的主要影响

  • 强封装:sun.misc.Unsafe 等内部 API 默认不可访问(需 --add-opens 开放)
  • 类加载路径:--module-path 替代部分 -classpath 场景
  • 模块访问检查在类加载阶段增加

七、总结

类加载机制是 JVM 架构的第一道"关卡":

  • 五个阶段:加载(获取字节码)→验证(安全检查)→准备(零值初始化)→解析(符号引用→直接引用)→初始化(执行静态代码)
  • 双亲委派:从下往上委托,从上往下尝试加载,确保核心类的唯一性与安全性
  • 打破双亲委派:SPI 机制用线程上下文加载器反向委托,Tomcat/OSGi 为隔离性设计自己的加载体系
  • 自定义 ClassLoader:继承 ClassLoader,重写 findClass(),可实现热部署、加密类加载等场景
  • 常见问题ClassNotFoundException(运行时找不到)vs NoClassDefFoundError(编译后丢失),类冲突(不同 ClassLoader 加载同名类)

下一篇预告:类加载完成后,对象和数据"住"在哪里?运行时数据区各个区域的精确边界在哪里?内存溢出到底是哪个区域先撑爆的?第03篇将带你深入解剖运行时数据区。


系列导航

参考资料

  1. 《深入理解Java虚拟机(第3版)》第7章 — 周志明著
  2. JVM Specification: Chapter 5 - Loading, Linking, and Initializing
  3. Java ClassLoader API 文档
  4. Understanding the Java ClassLoader
  5. Tomcat ClassLoader HOW-TO
  6. JEP 261: Module System

Logo

AtomGit 是由开放原子开源基金会联合 CSDN 等生态伙伴共同推出的新一代开源与人工智能协作平台。平台坚持“开放、中立、公益”的理念,把代码托管、模型共享、数据集托管、智能体开发体验和算力服务整合在一起,为开发者提供从开发、训练到部署的一站式体验。

更多推荐