类加载机制

上一章讲了 GC——堆上的对象怎么”死”。这一章讲类怎么”生”——JVM 怎么把磁盘上的 .class 文件加载进内存、变成可用的类。这是 类加载机制(Class Loading Mechanism)

类加载是 Java 的”海关”——.class 文件就像进口货物,要经过”申报(加载)→ 检查(验证)→ 估值(准备)→ 派送(解析)→ 上架(初始化)“五道关卡才能用。这一章我们看清这五道关卡,以及”双亲委派”这个最常被面试问的机制。

一、类加载过程:五个阶段

JVM 规范规定,类加载分加载、验证、准备、解析、初始化 五个阶段。使用和卸载不算严格意义的”加载”,但完整生命周期是七阶段:

加载 → 验证 → 准备 → 解析 → 初始化 → 使用 → 卸载
 └───────────链接───────────┘

其中验证、准备、解析合称链接(Linking)

1.1 加载(Loading)

加载是”找字节码、读进内存”——做三件事:

  1. 通过类的全限定名获取定义此类的二进制字节流——可以从 zip/jar、网络、动态生成、文件系统等。
  2. 把这个字节流转成方法区的运行时数据结构——存到元空间。
  3. 在堆上生成一个 Class 对象——作为方法区数据的访问入口。

“二进制字节流”来源多样:

  • jar/war —— 最常见。
  • 网络 —— Applet 时代。
  • 动态生成 —— CGLIB、ByteBuddy、动态代理。
  • JSP 编译 —— Tomcat 把 JSP 编译成 class。
  • 数据库 —— 某些中间件。

加载阶段是开发可干预最强的——通过自定义类加载器控制字节流来源(后面讲)。

1.2 验证(Verification)

验证确保字节码安全、合法——避免恶意字节码搞垮 JVM。四类验证:

  • 文件格式——魔数 0xCAFEBABE、版本号、常量池索引合法。
  • 元数据——类是否有父类、是否继承 final 类、字段方法签名合法。
  • 字节码——方法体逻辑合法,操作数栈不溢出,跳转指令合法。
  • 符号引用——引用的类、字段、方法真实存在,访问权限合法。

验证失败的类抛 VerifyError

1.3 准备(Preparation)

准备类变量(static) 分配内存并设零值——不是初始化值!

public static int x = 123;
// 准备阶段: x = 0  (零值)
// 初始化阶段: x = 123 (赋值)

例外——static final 常量在编译期已知的,准备阶段直接赋值:

public static final int X = 123;   // 准备阶段: X = 123 (ConstantValue 属性)
public static final String S = "hello";   // 准备阶段: S = "hello"

注意准备阶段只处理类变量,不处理实例变量——实例变量随对象一起在堆上分配,零值在对象创建时设置(上一章讲过)。

1.4 解析(Resolution)

解析把常量池里的符号引用替换成直接引用

  • 符号引用——一个字符串,描述引用的目标(如 Ljava/lang/String;java/lang/Object.hashCode:()I)。和目标类的实际内存布局无关。
  • 直接引用——指向目标的指针、句柄或偏移量。和 JVM 内存布局相关。

解析可以延迟——某些 JVM 在第一次使用某符号引用时才解析(lazy resolution),不一定在类加载时就全解析。

1.5 初始化(Initialization)

初始化执行类的 <clinit> 方法——这是编译器自动生成的,把所有 static 变量赋值static 块按源码顺序合并:

public class Foo {
    static int a = 1;            // 编译后: a = 1
    static { a = 2; b = 3; }     // 编译后: a = 2; b = 3
    static int b = 4;            // 编译后: b = 4 (覆盖上面的 3)
    // <clinit> 内容:
    // a = 1; a = 2; b = 3; b = 4;
}

<clinit> 特点:

  • JVM 保证线程安全——多线程同时触发初始化,<clinit> 只执行一次,其他线程阻塞。这就是”单例的静态内部类实现”线程安全的原因。
  • 父类先初始化——子类 <clinit> 前先调父类 <clinit>
  • 接口的 <clinit> 不要求父接口先初始化——接口的初始化独立。
  • 如果没有 static 变量赋值/static 块,编译器不生成 <clinit>

1.6 何时触发初始化

主动引用(必须初始化):

  1. newgetstaticputstaticinvokestatic 四条字节码指令——new 对象、读写静态字段(非 final)、调静态方法。
  2. 反射调用(Class.forName)。
  3. 初始化子类时,父类先初始化。
  4. JVM 启动时的主类(含 main 的类)。
  5. MethodHandle 句柄对应的类。

被动引用(不初始化):

  1. 通过子类访问父类的静态字段——只初始化父类,不初始化子类。
  2. ClassName[] arr = new ClassName[10] ——不初始化 ClassName,只初始化数组类型。
  3. 访问 static final 常量(编译期常量)——直接进调用方的常量池,不触发定义类初始化。
class Parent { static int x = 1; static { System.out.println("Parent init"); } }
class Child extends Parent { static { System.out.println("Child init"); } }

System.out.println(Child.x);   // 只输出 "Parent init" + "1", 不输出 "Child init"

二、双亲委派模型

2.1 类加载器的层次

JVM 内置三种类加载器(JDK 9+ 略有调整):

加载器JDK 8JDK 9+加载什么
Bootstrap ClassLoaderC++ 实现C++ 实现JAVA_HOME/lib(rt.jar、java.lang.*)
Extension ClassLoaderExtClassLoaderPlatformClassLoaderJAVA_HOME/lib/ext 或 JDK 系统模块
Application ClassLoaderAppClassLoaderAppClassLoaderclasspath(应用自身 + 依赖)

层次关系:

Bootstrap ClassLoader  (C++ 实现, 没有父加载器)
       ↑ parent
Extension/Platform ClassLoader
       ↑ parent
Application ClassLoader
       ↑ parent
用户自定义 ClassLoader

注意——“父加载器”不是”父类”(继承关系),而是组合关系——每个 ClassLoader 内部有个 parent 字段指向父加载器。

2.2 双亲委派的工作流程

当类加载器收到加载请求时,先委派给父加载器,父加载器加载失败才自己加载:

// ClassLoader.loadClass 的简化逻辑
protected Class<?> loadClass(String name, boolean resolve) {
    // 1. 检查是否已加载
    Class<?> c = findLoadedClass(name);
    if (c == null) {
        try {
            // 2. 委派给父加载器
            if (parent != null) {
                c = parent.loadClass(name, false);
            } else {
                c = findBootstrapClassOrNull(name);
            }
        } catch (ClassNotFoundException e) {
            // 父加载器找不到
        }
        if (c == null) {
            // 3. 父都找不到, 自己找
            c = findClass(name);
        }
    }
    return c;
}

2.3 为什么双亲委派

保证类的全局唯一性——同一个类只会被加载一次(最优先的加载器加载)。

举例——java.lang.Object 永远由 Bootstrap 加载。如果用户写个 java.lang.Object 想覆盖,会先委派给 Bootstrap,Bootstrap 找到 JDK 的 Object 就加载了——用户的”假冒”版本被忽略。这是安全——防止核心类被替换、防止类的重复加载。

2.4 类的唯一性

JVM 用 (类全限定名 + 类加载器) 唯一标识一个类。同一个 class 文件被两个不同 ClassLoader 加载,得到的是两个不同的 Class——equals 返回 falseinstanceof 不匹配。这是 Tomcat 等容器隔离应用的关键。

三、打破双亲委派

双亲委派很好,但有些场景必须”打破”它——让子加载器先加载,或绕过父加载器。

3.1 Tomcat:Web 应用隔离

Tomcat 部署多个 Web 应用,每个应用有自己的 WEB-INF/classesWEB-INF/lib。要求:

  • 应用 A 和应用 B 的类互不可见——避免冲突(如 A 用 Spring 5、B 用 Spring 4)。
  • 应用类不能被 Tomcat 自己加载——否则 Tomcat 重启才能更新应用。
  • Tomcat 自己的类对应用可见——应用要用 Servlet API。

Tomcat 设计了多层 ClassLoader:

Bootstrap

Extension

Application (System)

Common (Tomcat 公共, 加载 $CATALINA_HOME/lib)

┌──────────┴──────────┐
Catalina              Shared (可选)
(Tomcat 内部类)         ↑
                  ┌────┴────┐
              WebApp1     WebApp2
              (各应用独立)

每个 WebApp ClassLoader 打破双亲委派——先在自己的 WEB-INF/classes 找,找不到才委派给父。这就是”应用类优先”——同名类在每个 WebApp 里独立存在。

3.2 SPI:JDBC 的反向加载

SPI(Service Provider Interface) 是 Java 的扩展机制。问题——java.sql.DriverManager(JDK 内部)要加载 com.mysql.cj.jdbc.Driver(第三方 jar),但 Bootstrap ClassLoader 看不到 classpath 上的第三方 jar。

解法Thread.currentThread().getContextClassLoader()——线程上下文类加载器。DriverManager 用线程上下文 ClassLoader(默认是 AppClassLoader)反向加载驱动类,打破了”父加载器看不到子加载器加载的类”的限制。

// DriverManager 内部 (简化)
ServiceLoader<Driver> loaders = ServiceLoader.load(
    Driver.class,
    Thread.currentThread().getContextClassLoader()   // 用线程上下文加载
);

这是”父加载器请求子加载器加载类”的反向操作——双亲委派的”漏洞”,但是必要的。

3.3 热部署:JRebel、热重载

热部署要求”修改 class 文件后不重启 JVM 就生效”。核心思路——每次修改都新建一个 ClassLoader 重新加载类。新类和旧类是不同 Class 对象(不同加载器),互不干扰。旧的 ClassLoader 失去引用后被 GC,旧类才卸载。

这是”打破双亲委派”的极端用法——同一个类被多次加载,每次都是”新版本”。

3.4 OSGi:模块化类加载

OSGi 用”网状”类加载结构——每个 bundle 一个 ClassLoader,bundle 之间可以声明依赖关系,互相可见。完全打破层次结构,支持模块化。Java 9 的 JPMS 在某种程度上是 OSGi 思想的简化版。

四、自定义类加载器

4.1 为什么要自定义

  • 从非标准来源加载——网络、加密文件、数据库、内存生成。
  • 隔离——同一份代码不同版本共存。
  • 加密保护——加密的 class 文件,自定义加载器解密。
  • 热部署——重新加载类。

4.2 实现

继承 ClassLoader,重写 findClass(不建议重写 loadClass——会破坏双亲委派):

public class MyClassLoader extends ClassLoader {
    private final String classPath;

    public MyClassLoader(String classPath, ClassLoader parent) {
        super(parent);
        this.classPath = classPath;
    }

    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        byte[] bytes = loadClassBytes(name);
        if (bytes == null) throw new ClassNotFoundException(name);
        // defineClass 把字节码转成 Class
        return defineClass(name, bytes, 0, bytes.length);
    }

    private byte[] loadClassBytes(String name) {
        String path = classPath + "/" + name.replace('.', '/') + ".class";
        try {
            java.nio.file.Path p = java.nio.file.Paths.get(path);
            if (!java.nio.file.Files.exists(p)) return null;
            return java.nio.file.Files.readAllBytes(p);
        } catch (Exception e) {
            return null;
        }
    }
}

defineClassClassLoader 的关键方法——把字节码”定义”成 Class 对象,填充方法区。

4.3 加密 class 文件示例

public class EncryptedClassLoader extends ClassLoader {
    @Override
    protected Class<?> findClass(String name) throws ClassNotFoundException {
        byte[] encrypted = loadEncryptedBytes(name);
        byte[] decrypted = decrypt(encrypted);   // 解密
        return defineClass(name, decrypted, 0, decrypted.length);
    }

    private byte[] decrypt(byte[] data) {
        // 简单异或加密示例 (生产用 AES)
        for (int i = 0; i < data.length; i++) {
            data[i] ^= 0x42;
        }
        return data;
    }
    // ...
}

class 文件先加密,自定义加载器解密——保护代码防止反编译。

五、类的卸载

类被卸载需要三个条件同时满足

  1. 堆上没有任何该类的实例——所有对象都已被 GC。
  2. 该类的 Class 对象没被任何地方引用——没人持有 Class<?>
  3. 该类的 ClassLoader 已被回收——加载它的加载器没了。
ClassLoader loader = new MyClassLoader(...);
Class<?> clazz = loader.loadClass("com.example.Foo");
Object obj = clazz.newInstance();

// 卸载 Foo 需要:
obj = null;       // 1. 无实例
clazz = null;     // 2. Class 对象无引用
loader = null;    // 3. ClassLoader 无引用
// GC 后, Foo 类被卸载, 元空间内存释放

JVM 自带的 BootstrapClassLoader 永远不卸载——所以 JDK 自带的类(java.lang.* 等)一辈子不会卸载。这就是为什么元空间可能因”动态生成类太多”而 OOM——动态类卸载不及时。

六、实战:观察类加载

下面的例子演示双亲委派、自定义类加载器、SPI 机制、类加载的层次结构。

Java · 在线运行

观察重点

  • Main.class.getClassLoader() 是 AppClassLoader——应用类由它加载。
  • String.class.getClassLoader() 是 null——表示由 Bootstrap(C++ 实现)加载。
  • loadClass("java.lang.String") 委派给 Bootstrap——双亲委派保证 JDK 的 String 被加载。
  • ServiceLoader.load(Driver.class)——JDBC 用同样的 SPI 机制,靠线程上下文 ClassLoader 反向加载。
  • c1 == c2 返回 false——不同 ClassLoader 加载的相同字节码是不同的 Class,这就是 Tomcat 隔离的基础。
  • getLoadedClassCount 不含 Bootstrap 加载的核心类——实际类数更多。

七、本章小结

概念核心要点
加载找字节码、读入内存、生成 Class 对象
验证字节码合法性、安全性
准备static 变量分配内存、零值
解析符号引用→直接引用
初始化执行 <clinit>,static 变量赋值/static 块
双亲委派子先委派父,父失败才自己加载
Bootstrap加载 JDK 核心(C++,无父)
Extension/Platform加载扩展/JDK 模块
Application加载 classpath
打破双亲委派Tomcat 隔离、SPI 反向加载、热部署
自定义加载器重写 findClass,调 defineClass
类的唯一性类名 + ClassLoader 联合标识
卸载条件无实例 + 无 Class 引用 + 加载器回收

记忆口诀

  • 加载五阶段——加载→验证→准备→解析→初始化。
  • 准备是零值,初始化才是赋值——static int x = 1 准备阶段 x=0。
  • 双亲委派保安全——子先问父,父加载不了再自己加载,防止假冒核心类。
  • Bootstrap 是 null——C++ 实现,加载 java.lang.*
  • Tomcat 打破双亲委派——每个 WebApp 独立加载器,应用类优先。
  • SPI 反向加载——Thread.currentThread().getContextClassLoader()
  • 类名 + 加载器 = 唯一标识——同字节码不同加载器 = 不同 Class。
  • 卸载要三无——无实例、无 Class 引用、无加载器。

结语:类加载是 Java 的”海关”

类加载是 Java 的”海关”——所有 .class 文件要经过这里才能进入 JVM 运行。理解类加载,才能理解:

  • 为什么 Tomcat 能部署多个 Web 应用互不干扰。
  • 为什么 JDBC 驱动放 classpath 就能用,不需要 Class.forName
  • 为什么热部署能实现”改了不重启”。
  • 为什么 ClassNotFoundExceptionNoClassDefFoundError 不同(前者是加载时找不到,后者是初始化时找不到)。

下一章我们讲 性能监控与调优——用 JDK 自带的 jps/jstat/jstack/jmap/jcmd、JConsole、VisualVM、JFR、async-profiler 等工具观察运行中的 JVM,排查 OOM、CPU 飙高、卡顿等问题。这是把内存模型、GC、类加载的知识”用起来”的实战。我们下一章见。